fix(python): don't rewire imported-module decorators onto unrelated local defs (#2732) by hementhu · Pull Request #4622 · Graphify-Labs/graphify · GitHub
Skip to content

fix(python): don't rewire imported-module decorators onto unrelated local defs (#2732) - #4622

Open
hementhu wants to merge 2 commits into
Graphify-Labs:v8from
hementhu:fix/python-imported-module-decorators
Open

hementhu wants to merge 2 commits into
Graphify-Labs:v8from
hementhu:fix/python-imported-module-decorators

Conversation

@hementhu

@hementhu hementhu commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

What broke

@pytest.fixture reaches the extractor as the bare head symbol fixture (_python_decorator_name keeps the attribute, not the module), becomes a sourceless fixture stub, and _rewire_unique_stub_nodes collapses that stub onto the corpus's one top-level def fixture(), an unrelated helper in another file.

On a 13-file repro (12 test modules with two @pytest.fixture functions each, plus tests/test_helper.py with a local def fixture()):

v8 this PR
decorator edges → local fixture() 24 0
decorator edges → fixture stub (sourceless) 0 24
god_nodes() top entry fixture(), degree 25 env1(), degree 2

The same happens for from pytest import fixture + @fixture and import pytest as pt + @pt.fixture, and for any third-party decorator whose bare name matches one corpus def.

Fix

  • Extractor (extractors/engine.py): _python_import_bindings reads the file's module-level imports once (import m, import m as a, import a.b, from m import x [as a]; relative and function-local imports are skipped). _python_decorator_module returns the module a decorator's symbol was imported from, and the decorator references edge carries it as metadata.ref_module. Stub ids are unchanged, so test_attribute_decorator_targets_the_symbol_not_the_module (@app.route → route) still holds.
  • Rewire (extract.py): a per-edge guard, _other_module_candidate, alongside _cross_language_candidate and _names_own_builtin_base. An edge with ref_module only moves onto a candidate that lives in that module: its own file (m.py, m/__init__.py) or a file inside the package (the target of a re-export); dotted modules match as consecutive path segments. A from m import x receiver records only m. Edges without the marker follow the existing rules.

Because the guard is per edge, a genuine bare reference to the same name elsewhere in the corpus still rewires onto the local def; only the pytest edges stay on the stub. Sourceless stubs are already excluded from god_nodes() by _is_concept_node, which is why the fake hub disappears.

Relation to #2356

#2356 handles attribute receivers that are locally assigned (app = Flask() → @app.get) and deliberately keeps imported receivers on bare names. This PR handles exactly that imported case, so the two compose; I'll rebase whichever lands second.

Tests

tests/test_python_imported_decorator_rewire.py: 11 tests. 5 fail on v8 (attribute, aliased and from-import pytest decorators; a decorator from one module not binding to a same-named def in another; per-edge behaviour with a genuine local user). 4 guard that corpus decorators still link: import myhelpers → @myhelpers.retry, from myhelpers import retry, import pkg.tools as tools, a decorator with no import evidence, a package re-export (from mypkg import retry → mypkg/decorators.py) and an imported-object receiver (from mylib import api → @api.route). The 13 existing tests in tests/test_python_decorators.py pass unchanged.

  • uv run pytest tests/: 8480 passed, 15 skipped on 3.12 and 3.13; 8481 passed, 14 skipped on 3.10. One failure, tests/test_skill_no_backtick_substitution.py::test_control_a_backticked_unknown_command_is_caught, fails identically on unmodified v8 (b90b9df) and is unrelated to this change.
  • uv run ruff check .: clean
  • uv run pyright: 641 errors, the same as unmodified v8; none in the changed code

Notes

  • Out of scope: using ref_module to disambiguate when several same-named defs exist (today the stub stays ambiguous, which is the safe outcome).

  • Cache: ref_module is set at extraction, so cached AST results for affected files keep the old edges until the AST cache schema is bumped (left to the release bump, as with earlier fixes).

  • Review follow-ups (graphify-labs bot): package re-exports and imported-object receivers keep v8's rewires (c50fd79).

🤖 Generated with Claude Code

…ocal defs (Graphify-Labs#2732)

`@pytest.fixture` reaches the extractor as the bare head symbol `fixture`,
becomes a sourceless stub, and _rewire_unique_stub_nodes collapsed it onto
the corpus's one top-level `def fixture()`: an unrelated helper in another
file, which then topped god_nodes() (24 decorator edges, degree 25, on a
13-file repro). Same for `from pytest import fixture` and `import pytest as pt`.

The extractor now reads each Python file's module-level imports once
(_python_import_bindings) and records on the decorator `references` edge the
module the decorator was imported from (metadata.ref_module). The rewire
gains a per-edge guard, _other_module_candidate, beside
_cross_language_candidate: an edge with ref_module only moves onto a
definition whose file is that module (m.py / m/__init__.py, dotted modules
matched as a path suffix). Stub ids are unchanged, so `@app.route` still
targets `route`; corpus decorators (`import myhelpers` -> `@myhelpers.retry`)
still link; edges with no import evidence follow the existing rules.

Complements Graphify-Labs#2356, which handles locally assigned receivers and deliberately
leaves imported ones on bare names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hementhu
hementhu requested a review from safishamsi as a code owner October 21, 2026 09:56
@github-actions

Copy link
Copy Markdown

Thanks for the pull request, @hementhu. 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 2 advisory finding(s) below merit a look before merge.

Not checked: tests were not run; formal verification proved 0 of 2 changed function(s) (2 not verified).

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

Not verified on this run: \_rewire\_unique\_stub\_nodes (unsupported), \_extract\_generic (unsupported).


Graphify review — findings

Fixes decorators like @pytest.fixture collapsing onto an unrelated same-named local def fixture() during the corpus-wide stub rewire. The extractor now records the imported module on each Python decorator edge as ref_module, using module-level import and from … import bindings, and _other_module_candidate refuses any rewire target whose file isn't that module (m.py or m/__init__.py). Decorators with no import evidence, or reached through relative imports, keep the old unique-definition behaviour. The module match is strict, so a decorator resolved to a package re-export, an imported class or object (via the module.name fallback), or a multi-level attribute can lose its correct rewire or still mis-bind.

Worth a look

  • Package re-exported decorators no longer rewire to their real definition — graphify/extract.py:4064 · Escalate · medium
    • NOT verified (no proof, no reproducing execution) — needs human review
  • Decorator on an imported class/object is given a fake submodule as ref_module, which blocks correct rewires — graphify/extractors/engine.py:10325 · Escalate · medium
    • NOT verified (no proof, no reproducing execution) — needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 3588 functions depend on the 702 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: extract() — 989 callers, 59 callees
  • worse: _extract_generic() — 20 callers, 47 callees
  • worse: _rewire_unique_stub_nodes() — 14 callers, 11 callees
  • worse: walk() — 1 callers, 77 callees

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

Test selection

Test selection

203 of 437 test file(s) selected (46%) via static blast radius.

  • tests/test_affected_overrides.py — impact
  • 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_c_include_resolution.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_cpp_template_type_params.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_delegate_declarations.py — impact
  • tests/test_csharp_enum_members.py — impact
  • tests/test_csharp_field_generic_args.py — impact
  • tests/test_csharp_foreach_loop_variable.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_chain_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_csharp_underscore_collisions.py — impact
  • tests/test_csharp_var_call_return_type.py — impact
  • tests/test_csharp_verbatim_interpolation.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_ejs_extractor.py — impact
  • tests/test_elixir_defdelegate.py — impact
  • tests/test_elixir_import_resolution.py — impact
  • tests/test_elixir_keyword_def_calls.py — impact
  • tests/test_elixir_qualified_calls.py — impact
  • tests/test_elixir_unqualified_call_scope.py — impact
  • tests/test_erlang_extractor.py — impact
  • tests/test_ext_tagged_id_clash.py — impact
  • tests/test_extract.py — impact
  • tests/test_extract_cache_location.py — impact
  • … and 153 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 \_rewire\_unique\_stub\_nodes.

The verifier did not have enough to check \_rewire\_unique\_stub\_nodes, 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: the input domain has 81 values but only 11 distinct were tested — a small finite domain must be EXHAUSTED, not sampled (an untested input could invert the result)

Could not verify: Could not verify \_extract\_generic.

The verifier did not have enough to check \_extract\_generic, 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: parameter `config` is annotated `LanguageConfig` — outside the synthesizable primitive/collection set

· 4 more finding(s) on lines outside this diff (see the check run).

…receivers (Graphify-Labs#2732)

Review follow-ups on the imported-module decorator guard (graphify-labs bot
on Graphify-Labs#4622); both were regressions against v8:

- A decorator re-exported by a package (`from mypkg import retry`, defined in
  mypkg/decorators.py) lost its rewire, because the guard required the
  definition to be in mypkg.py or mypkg/__init__.py. The guard now accepts
  any definition inside the module's package (consecutive path segments).
- `from mylib import api` + `@api.route` recorded a fake `mylib.api`
  module (`api` is an object) and blocked the rewire onto mylib's `route`.
  A from-imported receiver now records only the module it came from.

Two regression tests; both pass here and on unmodified v8. The pytest case
stays fixed: tests/test_helper.py is not inside `pytest`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hementhu

Copy link
Copy Markdown
Contributor Author

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

contributor Pull request from a returning community contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant