From 8cdbb0ea4f8222dda531e641acf117f926a3c0c9 Mon Sep 17 00:00:00 2001 From: rohit-jsfreaky Date: Tue, 6 Oct 2026 16:28:22 +0530 Subject: [PATCH] fix(python): keep missing relative-import targets out of the checkout path A relative import of a module with no file behind it (`from .absent import x`) built its target from the attempted absolute path, so the id became e.g. `c_users__..._pkg_absent_py`. The root-relative id remap never rewrites it (there is no node to anchor on), so the checkout location and the OS username ended up in graph.json and every clone got a different id. Use the dotted module name relative to the scan root instead (`pkg_absent`), the id an unresolved absolute import of the same module already gets, so `from .absent` and `from pkg.absent` now meet on one node. An import that climbs above the scan root falls back to a `ref` id. Imports that resolve, and attempted paths that exist, are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf --- graphify/extract.py | 30 +++++++++++++++++++++++++- tests/test_python_import_resolution.py | 25 +++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/graphify/extract.py b/graphify/extract.py index 5d9181fba5..3e122fa1a2 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -674,6 +674,23 @@ def _python_import_bindings(node, source: bytes) -> list[tuple[str, str]]: return bindings +def _missing_relative_module_name(attempted: Path, root: Path, raw: str) -> str: + """Dotted module name, relative to ``root``, of an unresolved relative import. + + ``attempted`` is the file the import would have named (``pkg/missing.py`` or + ``pkg/__init__.py``). Falls back to ``ref:`` + the raw specifier when the + import climbs above the scan root, so the id never carries an absolute path. + """ + try: + rel = attempted.relative_to(root) + except ValueError: + return f"ref:{raw}" + parts = list(rel.with_suffix("").parts) + if parts and parts[-1] == "__init__": + parts.pop() + return ".".join(parts) if parts else f"ref:{raw}" + + def _import_python( node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None, scan_root: Path | None = None, @@ -749,13 +766,24 @@ def _import_python( target_path = _resolve_python_module_path( module_name, current_path, root, level=dots ) + missing = False if target_path is None: base = current_path.parent for _ in range(dots - 1): base = base.parent rel = (module_name.replace(".", "/") + ".py") if module_name else "__init__.py" target_path = base / rel - tgt_nid = _make_id(str(target_path)) + missing = not target_path.is_file() + if missing: + # No file behind the import: minting the id from the + # attempted absolute path bakes the checkout location (and + # OS username) into the graph, and the root-relative id + # remap never rewrites it. Use the dotted module name + # relative to the scan root, the id an unresolved absolute + # import of the same module already gets. + tgt_nid = _make_id(_missing_relative_module_name(target_path, root, raw)) + else: + tgt_nid = _make_id(str(target_path)) else: # Use the shared scan-root-aware resolver for absolute imports. # It stops at the corpus boundary and handles package roots the diff --git a/tests/test_python_import_resolution.py b/tests/test_python_import_resolution.py index f987f5cd2e..51b03196b5 100644 --- a/tests/test_python_import_resolution.py +++ b/tests/test_python_import_resolution.py @@ -147,6 +147,31 @@ def test_overdeep_relative_import_is_unresolved_not_fatal(tmp_path: Path): assert _node_id(result, "ok()", "pkg/mod.py") +def _missing_import_targets(checkout: Path) -> set[str]: + _write(checkout / "pkg" / "__init__.py", "") + source = _write( + checkout / "pkg" / "app.py", + "from .absent import helper\n" + "from pkg.absent import other\n\n" + "def run():\n" + " return helper(), other()\n", + ) + result = extract([source, checkout / "pkg" / "__init__.py"], cache_root=checkout) + return {e["target"] for e in result["edges"] if e["relation"] == "imports_from"} + + +def test_missing_relative_import_target_id_does_not_depend_on_the_checkout(tmp_path: Path): + """A relative import of a module with no file behind it minted its target id + from the attempted absolute path (``..._pkg_absent_py``), which the + root-relative remap never rewrites: the checkout location and OS username + ended up in the graph. It now gets the dotted module name, the id an + unresolved absolute import of the same module already gets.""" + first = _missing_import_targets(tmp_path / "clone_one") + second = _missing_import_targets(tmp_path / "elsewhere" / "clone_two") + + assert first == second == {"pkg_absent"} + + def test_ordinary_relative_import_still_resolves(tmp_path: Path): target = _write(tmp_path / "pkg" / "sibling.py", "def helper():\n return 1\n") source = _write(tmp_path / "pkg" / "mod.py", "from .sibling import helper\n")