Skip to content

fix(extract): stop attribute-form Python decorators binding to an unrelated def (#2315) - #2356

Open
Rishet11 wants to merge 1 commit into
Graphify-Labs:v8from
Rishet11:fix/2315-attr-decorator-misbind
Open

fix(extract): stop attribute-form Python decorators binding to an unrelated def (#2315)#2356
Rishet11 wants to merge 1 commit into
Graphify-Labs:v8from
Rishet11:fix/2315-attr-decorator-misbind

Conversation

@Rishet11

Copy link
Copy Markdown
Contributor

Closes #2315

I wrote the original Python decorator path (#2154 / 794cf9e), so this is mine to clean up.

The bug

@app.get("/health") emitted a references edge to an unrelated module-level def get in a different file. The edge was wrong, not merely missing — it pointed at a real node that has nothing to do with the decorator.

before:
  EDGE references routes_health -> settings_store_get  context=decorator
  EDGE references routes_status -> settings_store_get  context=decorator
  NODE routes_app_get      | .get() | routes.py        <- the correct method, never referenced
  NODE settings_store_get  | get()  | settings_store.py

after:
  EDGE references routes_health -> routes_app_get      (node exists)
  EDGE references routes_status -> routes_app_get      (node exists)

Two steps caused it:

  1. _python_decorator_name (graphify/extractors/engine.py:4725, dotted/call branch :4739-4741) returned only the last attribute segment ("get"), discarding app.
  2. The emission site (engine.py:3784) called ensure_named_node("get", line). In ensure_named_node (:2393-2415) the same-file scoped id cannot match a plain module-level function id, so it fell through to _make_id(name) — a bare global id with no file or class scoping — and silently bound to whatever was already in seen_ids. For names like get, post, run, send that collision is common.

The fix

  • _python_decorator_receiver recovers the receiver: "app" for @app.get(...), the dotted text for @a.b.c, None for non-attribute forms (which have no collision risk).
  • _python_local_instance_classes collects module-level name = LocalClass() assignments, computed once per Python file and only for Python. It is deliberately scoped to simple top-level assignments — no control flow, no attribute targets — so this is not a new type-inference subsystem.
  • At the emission site: a receiver tracing to a locally instantiated local class targets that class's method node; a receiver that is locally assigned but untraced, or a dotted chain, gets a receiver-scoped stub (app.get) that cannot collide with a single-token definition since dots cannot appear in a Python identifier; anything else keeps the existing bare-name path.

The imported-receiver behavior locked by test_attribute_decorator_targets_the_symbol_not_the_module (tests/test_python_decorators.py:94) is unchanged, and I did not touch that test.

Worth flagging one behavior change I consider an improvement but you may not: for app = Flask(__name__), app is locally assigned but Flask is not a local class, so it now takes the scoped-stub branch (app.get) instead of the bare get. Two files that both name a variable app produce colliding sourceless stubs, and the pre-existing _disambiguate_colliding_node_ids pass (resolution.py:655) salts them apart by source file — I verified that on a two-file Flask-shaped repro. So this is strictly better than the old bare-name binding for that case, but it is a change in what the stub is called.

Cases that degrade to the old stub behavior rather than to a wrong edge: a class or assignment inside if/try (not top level), and an imported receiver class. A class defined after the assignment resolves fine, since local class names are collected in a full pass first.

Verification

A fresh-eyes review pass verified the node-id formula against the real class-node (engine.py:2479) and method-node (:3344) construction and confirmed they are provably identical for every case this branch can reach (the receiver scan is top-level only, so namespace_stack is always empty), and confirmed the helper runs once per file rather than per decorator.

…elated def (Graphify-Labs#2315)

_python_decorator_name returned only the last attribute segment of an
attribute-form decorator, discarding the receiver, so `@app.get("/health")`
looked up the bare name "get". ensure_named_node's same-file scoped id cannot
match a plain module-level function, so it fell through to the bare global id
_make_id(name) and silently bound to whatever was already in seen_ids under
that name — a real, unrelated def in another file. The edge was wrong, not
merely missing.

Add _python_decorator_receiver to recover the receiver text, and
_python_local_instance_classes to collect module-level `name = LocalClass()`
assignments once per Python file. A receiver that traces to a locally
instantiated local class now targets that class's method node; a receiver
that is locally assigned but untraced, or a dotted chain, gets a
receiver-scoped stub that cannot collide with a single-token definition.
A receiver that is neither (i.e. imported) keeps its existing bare-name
behavior, which test_attribute_decorator_targets_the_symbol_not_the_module
locks.
Copilot AI review requested due to automatic review settings July 31, 2026 21:22

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@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.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).

Graphify reviewed this change.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).


Graphify review — findings

This PR changes how attribute-form Python decorators (e.g. @app.get(...)) resolve their reference target in the graph extraction engine. It adds two helpers—one to extract a decorator's receiver text and one to identify module-level variables assigned to locally-defined class instances—so the decorated-definition branch can route the edge to a local class method, a receiver-scoped stub, or the existing bare-name target depending on what the receiver resolves to. The surface area is the Python decorator handling in graphify/extractors/engine.py plus four new tests in tests/test_python_decorators.py covering local-receiver class methods, same-file unrelated defs, plain identifier decorators, and compound attribute chains.

No blocking issues surfaced. 4 lower-confidence candidates did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 592 functions depend on the 200 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: _extract_generic() — 18 callers, 16 callees

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

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

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

Labels

None yet

Projects

None yet

2 participants