From c3402fdc43521b400f491fff51d25b98e84c73ca Mon Sep 17 00:00:00 2001 From: oskibundles-hue <292641982+oskibundles-hue@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:49:30 -0700 Subject: [PATCH 1/3] fix(python): resolve absolute sibling imports to the importing file's directory An absolute from-import used the bare module name as the edge target id. That id only matches a file node when the basename is unique across the scan, so a repo carrying a vendored copy of the same module left the target matching nothing: the edge dangled, was pruned, and a real dependency vanished from the graph. The symbol-level pass does not cover it either, since it resolves imported functions and classes -- so `from m import CONST` left no trace while `from m import func` in the same file resolved normally. Probe the importing file's own directory first, which is how the import resolves at runtime for a script that does `sys.path.insert(0, os.path.dirname(__file__))`. Setting target_path lets the existing target_file stamp canonicalize the id, exactly as the relative-import branch already does. The self-resolution guard is load-bearing: a module named contracting.py doing `from contracting import constants` imports the external package of that name, not itself, and must not gain a fabricated self-loop. Only absolute from-imports that resolve to an existing sibling file change; everything else keeps the bare-name behaviour. Co-Authored-By: Claude Opus 5 --- graphify/extract.py | 29 +++++++++++++- tests/test_python_import_resolution.py | 54 +++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 32c59484db..98b8a91465 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -374,7 +374,34 @@ def _import_python(node, source: bytes, file_nid: str, stem: str, edges: list, s target_path = base / rel tgt_nid = _make_id(str(target_path)) else: - tgt_nid = _make_id(raw) + # An absolute from-import resolves against the importing file's + # own directory when a script puts that directory on sys.path + # (`sys.path.insert(0, os.path.dirname(__file__))`), so probe + # the sibling module before falling back to the bare name. The + # bare name only matches a file node when the basename is + # unique across the scan: with a vendored copy of the same + # module also in the scan the target matches nothing, the edge + # dangles and is pruned, and a real dependency disappears from + # the graph. Setting target_path lets the target_file stamp + # below canonicalize the id, exactly as the relative branch + # does. The self-resolution guard is required: a module named + # contracting.py doing `from contracting import constants` + # imports the external package of that name, not itself, and + # must not gain a fabricated self-loop + # (tests/test_import_self_loops.py). + sibling = Path(str_path).parent / (raw.replace(".", "/") + ".py") + try: + sibling_exists = ( + sibling.is_file() + and sibling.resolve() != Path(str_path).resolve() + ) + except OSError: + sibling_exists = False + if sibling_exists: + target_path = sibling + tgt_nid = _make_id(str(target_path)) + else: + tgt_nid = _make_id(raw) edge = { "source": file_nid, "target": tgt_nid, diff --git a/tests/test_python_import_resolution.py b/tests/test_python_import_resolution.py index 5de9e5a5eb..c48f929f3f 100644 --- a/tests/test_python_import_resolution.py +++ b/tests/test_python_import_resolution.py @@ -2,7 +2,7 @@ from pathlib import Path -from graphify.extract import extract +from graphify.extract import _make_id, extract from graphify.extractors.resolution import _resolve_python_module_path @@ -150,3 +150,55 @@ def test_python_parameter_return_and_generic_contexts(tmp_path: Path): assert ("process()", "Payload", "parameter_type") in pairs assert ("process()", "Result", "return_type") in pairs assert ("process_many()", "Payload", "generic_arg") in pairs + + +def test_absolute_sibling_import_resolves_to_importing_files_directory(tmp_path: Path): + # A script that puts its own directory on sys.path resolves + # `from layouts import LAYOUTS` against that directory. The bare module + # name only matches a file node when the basename is unique across the + # scan, so a vendored copy of the same module leaves the target matching + # nothing: the edge dangles, is pruned, and a real dependency vanishes. + # The importing file's own directory wins; the vendored twin never does. + live = _write( + tmp_path / "live/layouts.py", + "LAYOUTS = {'hero': (1080, 1080)}\n", + ) + vendored = _write( + tmp_path / "vendor/layouts.py", + "LAYOUTS = {'legacy': (600, 600)}\n", + ) + build = _write( + tmp_path / "live/build.py", + "import os\n" + "import sys\n\n" + "sys.path.insert(0, os.path.dirname(__file__))\n\n" + "from layouts import LAYOUTS\n\n" + "def render():\n" + " return LAYOUTS\n", + ) + + result = extract([live, vendored, build], cache_root=tmp_path) + + build_file = _node_id(result, "build.py", "live/build.py") + live_file = _node_id(result, "layouts.py", "live/layouts.py") + vendored_file = _node_id(result, "layouts.py", "vendor/layouts.py") + + assert _has_edge(result, build_file, live_file, "imports_from") + assert not _has_edge(result, build_file, vendored_file, "imports_from") + + +def test_absolute_import_without_sibling_keeps_bare_module_target(tmp_path: Path): + # No sibling on disk means the import is external (or unresolvable): the + # target stays the bare module name, exactly as before the sibling probe. + module = _write(tmp_path / "app/main.py", "from requests import Session\n") + + result = extract([module], cache_root=tmp_path) + + main_file = _node_id(result, "main.py", "app/main.py") + targets = [ + edge["target"] + for edge in result["edges"] + if edge["source"] == main_file and edge["relation"] == "imports_from" + ] + + assert targets == [_make_id("requests")] From cc189dba8f00e4bcb695f3a955e03aea6571d46d Mon Sep 17 00:00:00 2001 From: oskibundles-hue <292641982+oskibundles-hue@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:08:13 -0700 Subject: [PATCH 2/3] fix(python): resolve a sibling package directory, not just a module file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sibling probe added in the previous commit looked only for `.py`, so an absolute `from pkg import X` whose sibling is a package directory fell through to the bare module name. That is the same dangling edge the fix set out to remove: the bare id only matches a file node when the basename is unique, so a vendored copy of the same package left the edge matching nothing. Probe with _probe_python_module_candidate instead — the resolver the relative-import branch already uses — which resolves a directory to its __init__.py, an exact file, or a .py suffix. The self-resolution guard is unchanged. Multi-level names were checked at the same time and already resolved correctly: `from a.b import V` lands on a/b.py, and on a/b/__init__.py when a.b is itself a package. Co-Authored-By: Claude Opus 5 --- graphify/extract.py | 19 +++++++++++-------- tests/test_python_import_resolution.py | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 98b8a91465..7832acdc90 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -377,7 +377,10 @@ def _import_python(node, source: bytes, file_nid: str, stem: str, edges: list, s # An absolute from-import resolves against the importing file's # own directory when a script puts that directory on sys.path # (`sys.path.insert(0, os.path.dirname(__file__))`), so probe - # the sibling module before falling back to the bare name. The + # the sibling module before falling back to the bare name. + # _probe_python_module_candidate is the same resolver the + # relative branch uses, so a sibling package directory lands on + # its __init__.py rather than being missed. The # bare name only matches a file node when the basename is # unique across the scan: with a vendored copy of the same # module also in the scan the target matches nothing, the edge @@ -389,15 +392,15 @@ def _import_python(node, source: bytes, file_nid: str, stem: str, edges: list, s # imports the external package of that name, not itself, and # must not gain a fabricated self-loop # (tests/test_import_self_loops.py). - sibling = Path(str_path).parent / (raw.replace(".", "/") + ".py") + sibling = _probe_python_module_candidate( + Path(str_path).parent / raw.replace(".", "/") + ) try: - sibling_exists = ( - sibling.is_file() - and sibling.resolve() != Path(str_path).resolve() - ) + if sibling is not None and sibling.resolve() == Path(str_path).resolve(): + sibling = None except OSError: - sibling_exists = False - if sibling_exists: + sibling = None + if sibling is not None: target_path = sibling tgt_nid = _make_id(str(target_path)) else: diff --git a/tests/test_python_import_resolution.py b/tests/test_python_import_resolution.py index c48f929f3f..5aeba37385 100644 --- a/tests/test_python_import_resolution.py +++ b/tests/test_python_import_resolution.py @@ -202,3 +202,27 @@ def test_absolute_import_without_sibling_keeps_bare_module_target(tmp_path: Path ] assert targets == [_make_id("requests")] + + +def test_absolute_sibling_import_resolves_a_package_directory(tmp_path: Path): + # Same ambiguity as above, but the sibling is a package directory rather + # than a module file. Probing only for `pkg.py` missed it, so the edge fell + # back to the bare name and dangled whenever the basename was not unique. + live = _write(tmp_path / "live/pkg/__init__.py", "LAYOUTS = {'hero': (1080, 1080)}\n") + vendored = _write(tmp_path / "vendor/pkg/__init__.py", "LAYOUTS = {'legacy': (600, 600)}\n") + build = _write( + tmp_path / "live/build.py", + "import os\n" + "import sys\n\n" + "sys.path.insert(0, os.path.dirname(__file__))\n\n" + "from pkg import LAYOUTS\n", + ) + + result = extract([live, vendored, build], cache_root=tmp_path) + + build_file = _node_id(result, "build.py", "live/build.py") + live_init = _node_id(result, "__init__.py", "live/pkg/__init__.py") + vendored_init = _node_id(result, "__init__.py", "vendor/pkg/__init__.py") + + assert _has_edge(result, build_file, live_init, "imports_from") + assert not _has_edge(result, build_file, vendored_init, "imports_from") From be95af60c6aef8de5c74800a78c3a9caaafd895b Mon Sep 17 00:00:00 2001 From: oskibundles-hue <292641982+oskibundles-hue@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:49:52 -0700 Subject: [PATCH 3/3] fix(python): only probe a sibling from a non-package directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python 3 removed implicit relative imports, so inside a package `from models import Thing` is absolute and means the installed distribution — not the sibling module sitting next to the importer. Probing there could invent an edge to a same-named local file and hide a genuine third-party dependency. Skip the probe when the importing file's own directory holds an __init__.py. What is left is exactly the case the probe was added for: a plain script directory, which the interpreter puts on sys.path itself, so the sibling really does shadow any installed package of that name. No coverage is lost on this repo — all 18 bare sibling imports under worked/ live in non-package directories, and all 18 still resolve to a real file with none dangling and no self-loops. Co-Authored-By: Claude Opus 5 --- graphify/extract.py | 20 ++++++++++++++++++-- tests/test_python_import_resolution.py | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 7832acdc90..c3d4558398 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -392,8 +392,24 @@ def _import_python(node, source: bytes, file_nid: str, stem: str, edges: list, s # imports the external package of that name, not itself, and # must not gain a fabricated self-loop # (tests/test_import_self_loops.py). - sibling = _probe_python_module_candidate( - Path(str_path).parent / raw.replace(".", "/") + # Only a directory that is NOT itself a package can shadow an + # installed distribution: Python 3 removed implicit relative + # imports, so inside a package `from models import X` is + # absolute and means the installed `models`, not the sibling. + # A plain script directory is the case this probe exists for — + # there the interpreter puts the script's own directory on + # sys.path, so the sibling really does win. + importer_dir = Path(str_path).parent + try: + in_package = (importer_dir / "__init__.py").is_file() + except OSError: + in_package = True + sibling = ( + None + if in_package + else _probe_python_module_candidate( + importer_dir / raw.replace(".", "/") + ) ) try: if sibling is not None and sibling.resolve() == Path(str_path).resolve(): diff --git a/tests/test_python_import_resolution.py b/tests/test_python_import_resolution.py index 5aeba37385..5b422a0035 100644 --- a/tests/test_python_import_resolution.py +++ b/tests/test_python_import_resolution.py @@ -226,3 +226,21 @@ def test_absolute_sibling_import_resolves_a_package_directory(tmp_path: Path): assert _has_edge(result, build_file, live_init, "imports_from") assert not _has_edge(result, build_file, vendored_init, "imports_from") + + +def test_absolute_import_inside_a_package_is_not_taken_as_a_sibling(tmp_path: Path): + # Python 3 has no implicit relative imports: inside a package, + # `from models import Thing` means the installed distribution, not the + # sibling module. Only a non-package directory — a plain script directory, + # which the interpreter puts on sys.path — may resolve to its sibling. + _write(tmp_path / "pkg/__init__.py", "") + sibling = _write(tmp_path / "pkg/models.py", "class Thing:\n pass\n") + consumer = _write(tmp_path / "pkg/service.py", "from models import Thing\n") + + result = extract([sibling, consumer], cache_root=tmp_path) + + service_file = _node_id(result, "service.py", "pkg/service.py") + sibling_file = _node_id(result, "models.py", "pkg/models.py") + + assert not _has_edge(result, service_file, sibling_file, "imports_from") + assert _has_edge(result, service_file, _make_id("models"), "imports_from")