Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions graphify/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,16 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
# (unknown ext, e.g. a manifest) is never mistaken for a phantom.
if src_fam is not None and tgt_fam is not None and src_fam != tgt_fam:
continue
# A file-level import or re-export cannot carry useful connectivity when
# both endpoints resolve to the same node. This most often happens when
# the target is an unresolved bare module name (``builtins``, ``poseidon``)
# that the legacy-ID alias index above mistakes for the importing file's
# own old stem. It also covers a nested module importing its parent file:
# at file-node granularity that relationship necessarily collapses. Keep
# other self-edges, notably recursive ``calls``, because those are real
# program structure rather than import-resolution artifacts.
if src == tgt and _edge_rel in ("imports", "imports_from", "re_exports"):
continue
# Preserve original edge direction - undirected graphs lose it otherwise,
# causing display functions to show edges backwards.
attrs["_src"] = src
Expand Down
107 changes: 107 additions & 0 deletions tests/test_import_self_loops.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
from pathlib import Path

import pytest

from graphify.build import build_from_json
from graphify.extract import extract


def _write(path: Path, source: str) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(source, encoding="utf-8")
return path


def _import_self_loops(result: dict) -> list[dict]:
return [
edge
for edge in result["edges"]
if edge.get("relation") in {"imports", "imports_from", "re_exports"}
and edge.get("source") == edge.get("target")
]


def _built_import_self_loops(result: dict) -> list[tuple[str, str, dict]]:
graph = build_from_json(result, directed=True)
return [
(source, target, data)
for source, target, data in graph.edges(data=True)
if source == target
and data.get("relation") in {"imports", "imports_from", "re_exports"}
]


@pytest.mark.parametrize(
("relative_path", "source"),
[
("src/contracting/stdlib/builtins.py", "import builtins\n"),
("playground/services/contracting.py", "from contracting import constants\n"),
],
)
def test_python_external_import_matching_current_basename_has_no_self_loop(
tmp_path: Path,
relative_path: str,
source: str,
) -> None:
module = _write(tmp_path / relative_path, source)

result = extract([module], cache_root=tmp_path, parallel=False)

assert _import_self_loops(result) == []
assert _built_import_self_loops(result) == []


@pytest.mark.parametrize(
("relative_path", "source"),
[
(
"packages/compiler/src/fixture.rs",
"mod tests { use crate::fixture::Fixture; }\n",
),
(
"packages/zk/src/poseidon.rs",
"use ark_crypto_primitives::sponge::poseidon::{PoseidonConfig, PoseidonSponge};\n",
),
],
)
def test_rust_import_matching_current_basename_has_no_self_loop(
tmp_path: Path,
relative_path: str,
source: str,
) -> None:
module = _write(tmp_path / relative_path, source)

result = extract([module], cache_root=tmp_path, parallel=False)

assert _import_self_loops(result) == []
assert _built_import_self_loops(result) == []


def test_recursive_call_self_loop_is_preserved() -> None:
result = {
"nodes": [
{
"id": "module_recurse",
"label": "recurse()",
"file_type": "code",
"source_file": "module.py",
"source_location": "L1",
"_origin": "ast",
}
],
"edges": [
{
"source": "module_recurse",
"target": "module_recurse",
"relation": "calls",
"confidence": "EXTRACTED",
"source_file": "module.py",
"source_location": "L2",
}
],
}

graph = build_from_json(result, directed=True)

assert graph.has_edge("module_recurse", "module_recurse")
assert graph["module_recurse"]["module_recurse"]["relation"] == "calls"