Skip to content

fix(python): let package imports pass a same-named type stub - #4136

Open
krishhgg wants to merge 1 commit into
Graphify-Labs:v8from
krishhgg:fix/python-package-stub-import
Open

krishhgg wants to merge 1 commit into
Graphify-Labs:v8from
krishhgg:fix/python-package-stub-import

Conversation

@krishhgg

@krishhgg krishhgg commented Oct 6, 2026

Copy link
Copy Markdown

What does this PR do?

In a src-layout repo, a Python package import could not reach the package when a type with the same case-folded name was referenced from another file.

In Flask (d73fa1c), the tests do import flask and annotate app: flask.Flask. The annotation leaves a sourceless stub for the type Flask. Node ids are case-folded, so the stub's id is flask, the same id as the dotted-module alias for src/flask/__init__.py. _repoint_python_package_imports (#2072) refuses any alias that already exists as a node id, so it never repointed import flask or from flask import .... No file under tests/ had an import edge to src/flask/__init__.py, and affected on the package listed no tests.

The refusal protects a real case: when a scan-root pkg.py exists, the resolver binds import pkg to it, and the nested src/pkg alias must not take that edge. This PR keeps the refusal for any id held by a node with a source_file. It ignores the collision only when all of these hold:

  • Every node with that id is sourceless.
  • The alias names exactly one scanned file, and that file can be imported under the dotted name: exact .py or .pyi suffix, every path component an NFKC-stable identifier (so src/shop.v2/ never spells shop.v2).
  • The import statement, NFKC-normalized the way Python reads identifiers, spells the module exactly like that file's dotted path (flask, not Flask).
  • The edge has no target_file, so the resolver's own pick is never replaced.
  • Exactly one node holds the destination file id, and its source_file is the alias file. If __init__.h or __init__.pyi shares the id, the edge stays where v8 put it.
  • The top-level name is not a standard-library or built-in module name. The names come from a fixed constant, _PYTHON_STDLIB_MODULE_NAMES (333 names), the union of sys.stdlib_module_names | sys.builtin_module_names on CPython 3.10, 3.12, 3.13 and 3.14, not from the running interpreter. It is a deliberate superset.
  • On disk, the file is what Python loads for that dotted name from its own sys.path root. The check reads directory listings, not the scan, so files excluded by .graphifyignore still count. A package directory on the way must have no same-named module beside it, and the file must come first in finder order (extension modules, .py, .pyc, .pyi). Extension modules are matched by a fixed pattern (<name>.so, <name>.pyd, <name>.<tag>.so, <name>.<tag>.pyd), not by the host's EXTENSION_SUFFIXES.
  • Nothing else can be found first for the top-level name: no <top> directory and no <top> module file of any of those kinds in the scan root or in the importer's non-package ancestors, scanned or not. A root shop.py therefore keeps import shop.core off src/shop/core.py.
  • Every directory these checks need could be listed. A listing error counts as unknown and the edge keeps its v8 target.

To check the spelling, _suppress_ambiguous_python_imports leaves the raw module name it already reads on kept import edges as _python_import_spelling. _repoint_python_package_imports pops it from every edge, so it never reaches graph.json.

The stub and its references edges are not touched, and member-call resolution (flask.redirect(...)) is unchanged.

One side effect: Flask's tests had 16 calls edges to an external flask node (flask.redirect(), flask.Flask(), ...), created by the #3793 external-module path. Because import flask now reaches a real file, that path no longer applies and those 16 edges are gone. They pointed at a stub that claimed the in-repo package was external. Resolving those calls through the package is a separate change.

Type of change

  • Bug fix
  • New feature
  • Documentation
  • Tests or CI
  • Refactor
  • Security fix

Verification & Invariants

Invariant: a sourceless stub no longer keeps an exactly spelled import away from the one scanned module it names. The exception applies only when every condition above holds; otherwise the edge keeps its v8 target. It never overrides a real node, a resolver-stamped target_file, an ambiguous module name, a shared destination id, a file that cannot spell the import, a file that loses on disk inside its own tree, a shadowing entry in the scan root or the importer's ancestors, or a directory that cannot be listed.

The checks do not model the whole import system. They do not consult site-packages, PYTHONPATH, .pth files, custom importers, zip imports, or the relative order of the candidate's root and the importer's roots.

Determinism: the result depends only on the corpus on disk. Two tests run one corpus under different host values and get identical graphs: one patches importlib.machinery.EXTENSION_SUFFIXES to the 3.13 and 3.10 lists, the other patches sys.stdlib_module_names and sys.builtin_module_names.

Persisted state: no cache format change. _python_import_spelling is set after the per-file cache is written and popped before output; none of the rebuilt corpus graphs contains it.

  • Read the CONTRIBUTING.md guide.
  • Reproduced the issue and identified the invariant.
  • Made the smallest fix necessary.
  • Added a regression test (if bug fix) or isolated boundary test.
  • Kept the PR description synchronized with the final implementation.
  • Documented any limitations / unsupported cases explicitly.

Limitations:

  • v8's ordinary alias path, with no stub involved, still produces false edges in nine layouts these tests cover, for example an unscanned scan-root shop.py losing to src/shop, src/shop.v2/ matching import shop.v2, and src/shop/ beating a scan-root shop.pyc. This PR leaves that path unchanged, and the branch matches v8 for each layout. The same guards could be applied there in a follow-up.
  • A package with both __init__.py and __init__.pyi does not get the exception, because two nodes hold its id.
  • The git-hook incremental path (_rebuild_code(changed_paths=...)) builds the alias map from changed files only, so after editing just a test file import shop stays unresolved there. v8 diverges from a full rebuild the same way. graphify update matches a clean build.
  • Function-local imports and from pkg import submodule are still not file edges. These are the remaining 7 Flask SCIP import misses.
  • Windows was not tested.

How was this tested?

# new tests against unmodified v8, then with this PR
uv run --offline pytest tests/test_src_layout_import_resolution.py -q
v8:      4 failed, 40 passed
this PR: 44 passed
# The 4 v8 failures are the positive cases (the package import lands on the Shop
# class stub on v8). The 31 boundary cases assert v8's result and pass on both:
# exact spelling, two candidate packages, target_file kept, shared destination id,
# dotted directory, .PY suffix, Kelvin-sign directory, scan-root precedence, a
# shadowing parent module, a .graphifyignore'd package in the candidate's tree,
# .pyc / .so / .pyd competitors, unlistable directories, stdlib names
# (sys, os, time, tomllib, distutils), and the two host-variation tests.

# fixture through the CLI: graphify update <fixture> --no-cluster
before: tests_test_plain --imports--> src_shop_core_shop  (the Shop class)
after:  tests_test_plain --imports--> src_shop_init
graphify affected src/shop/__init__.py --relation imports --relation imports_from --depth 1
before: No affected nodes found.   after: test_alias.py, test_from.py, test_plain.py

# incremental parity: graphify update after editing a test file, then the package
# __init__.py, compared with a clean rebuild: match both times

# real repos, graphify update --no-cluster on fresh copies
flask     2171 nodes / 3679 edges -> 2170 / 3663
fastapi, requests, httpx, rich: every node and link record identical to v8
Flask tests/ import edges by target   before  after
  real source file                        28     64
  external stub                          111     80
  sourceless code stub                     5      0

# scored against scip-python indexes
flask imports  recall 74.37% -> 96.48%, precision 98.67% -> 98.97%
flask calls and inherits unchanged; fastapi, requests, httpx, rich unchanged

# full suite; the same 30 tests fail on unmodified v8 (erlang, r, solidity and
# vbnet extractors plus the ollama retry tests, all from missing optional dependencies)
uv run --offline pytest tests -q
v8:      30 failed, 6452 passed, 105 skipped
this PR: 30 failed, 6487 passed, 105 skipped
uv run --offline ruff check .    All checks passed!
uv run --offline pyright         637 errors on v8 and on this branch

Graphify-specific checklist

  • I updated generated skill artifacts (uv run python -m tools.skillgen --bless) when changing their source fragments. (Not applicable: no skill fragments changed.)
  • I confirmed that AST/structural extraction remains deterministic (no ambient state dependencies like ENV variables).
  • I reviewed changes for security implications (no unsafe interpolation into shell/Python).
  • I confirmed no API keys or local-only graph data are included.
  • (If applicable) I disclosed AI authorship in my commit messages.

Node ids are case-folded, so the sourceless stub that a `flask.Flask`
annotation leaves for the type `Flask` gets the id `flask`, which is
also the dotted-module alias of `src/flask/__init__.py`. The Graphify-Labs#2072
src-layout pass refuses any alias that already exists as a node id, so
`import flask` in Flask's tests never reached the real package.

Keep the refusal for source-backed nodes. Ignore a colliding id only
when every node holding it is sourceless, the alias names exactly one
importable scanned file that the import spells exactly (NFKC), the edge
has no target_file, no other node shares the destination id, the name
is not a stdlib or built-in module (a fixed 333-name list), and the
on-disk checks show nothing else is found first, both inside the
candidate's own tree and in the scan root or the importer's ancestors.
Unlistable directories refuse.

On Flask, tests/ import edges to real source files go from 28 to 64 and
SCIP import recall from 74.37% to 96.48%. FastAPI, requests, httpx and
rich graphs are identical to v8.

Refs Graphify-Labs#2072, Graphify-Labs#1475, Graphify-Labs#3793

Co-Authored-By: Claude <noreply@anthropic.com>
@krishhgg
krishhgg requested a review from safishamsi as a code owner October 6, 2026 00:59
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown

Thanks for the pull request, @krishhgg. A maintainer will review it soon.

Want to talk it through while it is in review? Come join us on our Discord server. For longer-form discussion there is also GitHub Discussions.

A couple of things that speed up review: make sure the test suite passes on Python 3.10 and 3.13, and that the change keeps extraction deterministic.

@graphify-labs graphify-labs Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 1 advisory finding(s) below merit a look before merge.

Formal verification. PR-changed functions: 0/2 verified (0 proven, 0 may-equivalent, 0 distinguished) · 2 not verified (2 vacuous).

Not verified on this run: \_repoint\_python\_package\_imports (vacuous: never exercised), \_suppress\_ambiguous\_python\_imports (vacuous: never exercised).


Graphify review — findings

Lets _repoint_python_package_imports repoint a src/-layout import edge even when a sourceless, case-folded stub holds the alias id (e.g. the Flask type stub left by a flask.Flask annotation), carrying the exact dotted spelling through on kept edges as _python_import_spelling. It does this only when the import spells the module exactly under NFKC, the target is a sole-holder .py/.pyi file, and the top-level name isn't a stdlib or built-in module. On disk, the file must also be what Python's finder would load first from its own root (_wins_own_tree) with nothing earlier on the path shadowing it; any unreadable directory or failed guard leaves the edge dangling and the stub untouched, as before.

Worth a look

  • Scan root is always probed even when it is the candidate's own sys_root, so a stub alias directly under root shadows itself — graphify/extract.py · 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 — 2484 functions depend on the 343 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 783 callers, 50 callees
  • new: _rebuild_code() — 149 callers, 56 callees
  • new: extract_js() — 87 callers, 4 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: main() — 102 callers, 3 callees
  • new: dispatch_command() — 2 callers, 128 callees
  • new: _get_extractor() — 27 callers, 6 callees
  • new: collect_files() — 23 callers, 7 callees
  • …and 39 more — each is listed as a finding

Verification — 2484 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: 2294 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

147 of 334 test file(s) selected (44%) via static blast radius.

  • tests/test_astro_extraction.py — impact
  • tests/test_astro_import_ids.py — impact
  • tests/test_blade_extractor.py — impact
  • tests/test_build.py — impact
  • tests/test_builtin_global_type_refs.py — impact
  • tests/test_cache.py — impact
  • tests/test_case_sensitive_resolution.py — impact
  • tests/test_cjs_module_extension.py — impact
  • tests/test_cobol_extractor.py — impact
  • tests/test_cpp_method_declarations.py — impact
  • tests/test_cpp_nested_and_cli.py — impact
  • tests/test_cpp_objc_cross_file_calls.py — impact
  • tests/test_cross_extension_reexport_self_cycle.py — impact
  • tests/test_cross_language_call_resolution.py — impact
  • tests/test_cross_repo_external_call_guards.py — impact
  • tests/test_cross_repo_member_calls.py — impact
  • tests/test_csharp_call_site_generic_args.py — impact
  • tests/test_csharp_enum_members.py — impact
  • tests/test_csharp_field_generic_args.py — impact
  • tests/test_csharp_generic_callsites.py — impact
  • tests/test_csharp_interface_dispatch.py — impact
  • tests/test_csharp_member_calls.py — impact
  • tests/test_csharp_member_nodes.py — impact
  • tests/test_csharp_object_creation.py — impact
  • tests/test_csharp_partial_classes.py — impact
  • tests/test_csharp_tuple_type_refs.py — impact
  • tests/test_csharp_type_resolution.py — impact
  • tests/test_definition_file_portability.py — impact
  • tests/test_detect.py — impact
  • tests/test_dotnet.py — impact
  • tests/test_duplicate_annotation_edges.py — impact
  • tests/test_elixir_import_resolution.py — impact
  • tests/test_elixir_unqualified_call_scope.py — impact
  • tests/test_erlang_extractor.py — impact
  • tests/test_extract.py — impact
  • tests/test_extract_cache_location.py — impact
  • tests/test_extract_php_closures.py — impact
  • tests/test_file_label_disambiguation.py — impact
  • tests/test_file_node_id_spec.py — impact
  • tests/test_forwarding_review_findings.py — impact
  • tests/test_go_builtin_call_targets.py — impact
  • tests/test_go_import_repoint.py — impact
  • tests/test_go_interface_methods.py — impact
  • tests/test_go_qualified_resolution.py — impact
  • tests/test_import_extension_resolution.py — impact
  • tests/test_import_self_loops.py — impact
  • tests/test_imported_export_forwarding.py — impact
  • tests/test_incremental.py — impact
  • tests/test_indirect_call_arrow_single_param_shadow.py — impact
  • tests/test_indirect_call_block_scoped_shadow.py — impact
  • … and 97 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.

Docs that may be stale (advisory)

Formal verification

Could not verify: Could not verify \_repoint\_python\_package\_imports.

The verifier did not have enough to check \_repoint\_python\_package\_imports, 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: no capturable inputs from the test suite; property tier: not verifiable: all 264 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_suppress\_ambiguous\_python\_imports.

The verifier did not have enough to check \_suppress\_ambiguous\_python\_imports, 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: no capturable inputs from the test suite; property tier: non-vacuity: domain too small (only 2 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous

· 2 grounded finding(s) anchored inline below; 45 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/extract.py
})


def _repoint_python_package_imports(paths, all_nodes, all_edges, root) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — _repoint_python_package_imports()

fans out to 8 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/extract.py
e["target"] = stub_alias_map[tgt][0]


def _repoint_python_sibling_imports(paths, all_nodes, all_edges, root) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — _repoint_python_sibling_imports()

high coupling complexity (Ca·Ce = 12).

Grounded coupling-delta finding (deterministic), not an LLM guess.

@krishhgg

krishhgg commented Oct 6, 2026

Copy link
Copy Markdown
Author

On the advisory about the scan root shadowing a candidate under it: that case doesn't reach the new check. _repoint_python_package_imports already skips any file whose package root is the scan root (len(mod_parts) == len(parts)), because its file id and its alias coincide, so such a file never becomes a stub-exception candidate.

Checked with a flat layout: shop/__init__.py at the scan root, tests/test_x.py with import shop and an app: Shop annotation, and a class Shop in another test file. v8 and this branch both give tests/test_x.py --imports--> shop/__init__.py.

The two coupling notes are on _repoint_python_package_imports, which now calls the on-disk checks (_wins_own_tree, _shadowed), and on the unchanged _repoint_python_sibling_imports.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant