fix(python): resolve absolute sibling imports to the importing file's directory - #3468
Conversation
… 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 <noreply@anthropic.com>
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 2 advisory finding(s) below merit a look before merge.
Formal verification. No changes could be formally verified in this run.
Graphify review — findings
Resolves absolute from X import ... targets in _import_python against the importing file's own directory first, probing for a sibling .py module before falling back to the bare module name. This fixes dangling/pruned import edges when a same-named module is vendored elsewhere in the scan, since the bare name only resolves when the basename is unique. Skips the sibling when it resolves to the importing file itself, so a module can still import an external package of the same name without gaining a self-loop.
Worth a look
- Sibling absolute import probe misses package directories —
graphify/extract.py:391· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Multi-level absolute import builds wrong sibling path —
graphify/extract.py:400· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 1859 functions depend on the 248 functions this change touches.
Health — this change adds coupling hotspots:
- new:
extract()— 565 callers, 43 callees - new:
_rebuild_code()— 115 callers, 51 callees - new:
extract_js()— 85 callers, 4 callees - new:
extract_xaml()— 19 callers, 17 callees - new:
dispatch_command()— 2 callers, 124 callees - new:
_get_extractor()— 26 callers, 6 callees - new:
run_pipeline()— 8 callers, 13 callees - new:
collect_files()— 17 callers, 6 callees - …and 28 more — each is listed as a finding
Verification — 1859 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 1694 function(s) in the blast radius were not formally verified this run
Test selection
Test selection
104 of 262 test file(s) selected (40%) via static blast radius.
tests/test_astro_extraction.py— impacttests/test_astro_import_ids.py— impacttests/test_build.py— impacttests/test_builtin_global_type_refs.py— impacttests/test_case_sensitive_resolution.py— impacttests/test_cjs_module_extension.py— impacttests/test_cpp_nested_and_cli.py— impacttests/test_cpp_objc_cross_file_calls.py— impacttests/test_cross_extension_reexport_self_cycle.py— impacttests/test_cross_language_call_resolution.py— impacttests/test_cross_repo_member_calls.py— impacttests/test_csharp_call_site_generic_args.py— impacttests/test_csharp_enum_members.py— impacttests/test_csharp_field_generic_args.py— impacttests/test_csharp_generic_callsites.py— impacttests/test_csharp_interface_dispatch.py— impacttests/test_csharp_member_calls.py— impacttests/test_csharp_member_nodes.py— impacttests/test_csharp_object_creation.py— impacttests/test_csharp_partial_classes.py— impacttests/test_csharp_type_resolution.py— impacttests/test_definition_file_portability.py— impacttests/test_detect.py— impacttests/test_dotnet.py— impacttests/test_duplicate_annotation_edges.py— impacttests/test_extract.py— impacttests/test_extract_cache_location.py— impacttests/test_file_label_disambiguation.py— impacttests/test_file_node_id_spec.py— impacttests/test_forwarding_review_findings.py— impacttests/test_go_builtin_call_targets.py— impacttests/test_go_qualified_resolution.py— impacttests/test_import_extension_resolution.py— impacttests/test_import_self_loops.py— impacttests/test_imported_export_forwarding.py— impacttests/test_incremental.py— impacttests/test_indirect_call_arrow_single_param_shadow.py— impacttests/test_indirect_call_catch_binding_shadow.py— impacttests/test_indirect_call_external_import_shadow.py— impacttests/test_indirect_call_for_of_binding_shadow.py— impacttests/test_indirect_call_function_expression_shadow.py— impacttests/test_indirect_call_nested_closure_shadow.py— impacttests/test_indirect_dispatch.py— impacttests/test_indirect_dispatch_assign_return.py— impacttests/test_indirect_dispatch_getattr.py— impacttests/test_inferred_confidence_rubric.py— impacttests/test_inherited_field_receivers.py— impacttests/test_java_member_calls.py— impacttests/test_java_type_resolution.py— impacttests/test_js_callback_calls.py— impact- … and 54 more
Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.
Formal verification
Could not verify: Could not verify \_import\_python.
The verifier did not have enough to check \_import\_python, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)
· 36 more finding(s) on lines outside this diff (see the check run).
Base:
v8The bug
In
graphify/extract.py,_import_pythonuses the bare module name as theimports_fromedge target for an absolute from-import. That id only matches afile node when the basename is unique across the scan. Two things go wrong:
same name leaves the target matching nothing. The edge dangles, is pruned, and
a real dependency disappears from the graph.
dependency is drawn as a third-party package instead of the local file.
The symbol-level pass in
extractors/resolution.pydoesn't cover it either: itresolves imported functions and classes, so
from m import CONSTleaves no tracewhile
from m import funcin the same file resolves normally.The fix
Probe the importing file's own directory before falling back to the bare name.
That is exactly how the import resolves at runtime for a script that does
sys.path.insert(0, os.path.dirname(__file__)). Settingtarget_pathlets theexisting
target_filestamp canonicalize the id, the same way the relative-importbranch already does.
The self-resolution guard is load-bearing: a module named
contracting.pydoingfrom contracting import constantsimports the external package of that name, notitself, and must not gain a fabricated self-loop
(
tests/test_import_self_loops.pycatches this).Only absolute from-imports that resolve to an existing sibling file change
behaviour. Everything else keeps the bare-name target it had before.
Tests
Two added to
tests/test_python_import_resolution.py:test_absolute_sibling_import_resolves_to_importing_files_directory—live/layouts.py, a duplicatevendor/layouts.py, andlive/build.pydoingsys.path.insertthenfrom layouts import LAYOUTS. Asserts theimports_fromedge lands on
live/layouts.pyand never on the vendored twin. Fails onunpatched code, passes with the fix.
test_absolute_import_without_sibling_keeps_bare_module_target— pins theunchanged path: no sibling on disk means the target stays the bare module id.
Verification
Run on Python 3.12 via
uv run --frozen pytest tests/ -q, on this branch's basecommit and again with the change:
v8@ 3f82bf7)The 17 failures are byte-identical sets before and after (
test_skillgen.py,test_ollama_retry_cap.py,test_install.py, and one timing-sensitivetest_ts_import_type_arguments.pycase) — all pre-existing and unrelated. The +2passed are the two new tests.
ruff check --config pyproject.tomlclean on both changed files.Effect on a real graph
graphify extract . --code-onlyover this repo itself, before and after:worked/example/raw/{api,validator}.pypreviously pointed at a phantom externalnode
processor; they now point at the realworked/example/raw/processor.py,and the phantom node is gone (that is the −1 node).
worked/httpx/raw/{auth,client,transport,utils}.py→worked/httpx/raw/models.pyare 4 edges that did not exist before.
models.pyhas a duplicate basename inthis repo (
graphify/extractors/models.py), which is exactly the ambiguity thatmade them dangle; they resolve to the correct sibling, not the twin.
imports/imports_from/re_exportsself-loops in the resulting graph.🤖 Generated with Claude Code