diff --git a/graphify/build.py b/graphify/build.py index 6992dbc57..d1477fb12 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -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 diff --git a/tests/test_import_self_loops.py b/tests/test_import_self_loops.py new file mode 100644 index 000000000..3716baa3b --- /dev/null +++ b/tests/test_import_self_loops.py @@ -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"