Repository navigation
feat(python): resolve obj.method() through an annotated parameter or constructor-bound local - #4198
rohit-jsfreaky wants to merge 2 commits into
Conversation
…constructor-bound local `_resolve_python_member_calls` resolved `ClassName.method()` and `module.func()`, but a plain local receiver never got a calls edge, even when the function itself names its class: `def f(req: Request): req.read()`, `c = Client(); c.send()`, `with Client() as c: c.get()`. The Python extractor now builds a per-function `name -> class` table from parameter annotations (`X`, `mod.X`, `"X"`, `Optional[X]`, `X | None`), `x = X(...)` / `x: X`, and `with X(...) as x`, and stamps `receiver_type` on the member call's raw call (the key Ruby already uses). Any binding that makes the name unsure drops it: a second class, a factory call, loops, unpacking, except/match captures, imports, walrus, global/nonlocal, lambda parameters and nested class bodies (their calls are attributed to the enclosing function). The resolver emits an INFERRED (0.85) edge only when the class name is defined once and is defined in, imported into, or in a module imported by the caller's file (the Graphify-Labs#2553 gate), and the method is the class's own. AST cache schema 5 -> 6 so warm caches re-extract. Against jedi on httpx / click / flask: 153 new edges, 153 correct, 0 edges lost. Call edges found: httpx 68.9% -> 77.3%, click 74.9% -> 80.3%. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the pull request, @rohit-jsfreaky. 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. |
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 1 advisory finding(s) below merit a look before merge.
Graphify review — findings
Adds typed-receiver resolution for Python member calls. _python_local_class_bindings types a parameter or local from a class annotation, a constructor call, or a with Client() as c binding, and poisons any name bound ambiguously, so it is never resolved. _resolve_python_member_calls then turns request.read() into an INFERRED calls edge (score 0.85) only when that class is the unique class of its name and is defined in, imported by name into, or reachable through an import of the caller's file; otherwise it emits nothing. Bumping _AST_CACHE_SCHEMA to 6 invalidates cached raw calls so they pick up receiver_type.
Worth a look
- Python raw_calls gain receiver_type without an AST cache schema bump —
graphify/extractors/engine.py:7762· 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 — 3145 functions depend on the 643 functions this change touches.
Health — this change adds coupling hotspots:
- new:
extract()— 787 callers, 50 callees - new:
_rebuild_code()— 151 callers, 56 callees - new:
detect()— 121 callers, 16 callees - new:
_extract_generic()— 18 callers, 33 callees - new:
save_semantic_cache()— 65 callers, 9 callees - new:
extract_js()— 87 callers, 5 callees - new:
load_cached()— 56 callers, 7 callees - new:
file_hash()— 54 callers, 6 callees - …and 63 more — each is listed as a finding
Verification — 3145 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: 2952 function(s) in the blast radius were not formally verified this run
Test selection
Test selection
166 of 338 test file(s) selected (49%) via static blast radius.
tests/test_astro_extraction.py— impacttests/test_astro_import_ids.py— impacttests/test_blade_extractor.py— impacttests/test_build.py— impacttests/test_builtin_global_type_refs.py— impacttests/test_cache.py— impacttests/test_case_sensitive_resolution.py— impacttests/test_charmap_encoding.py— impacttests/test_chunking.py— impacttests/test_cjs_module_extension.py— impacttests/test_cobol_extractor.py— impacttests/test_cpp_method_declarations.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_external_call_guards.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_tuple_type_refs.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_elixir_import_resolution.py— impacttests/test_elixir_unqualified_call_scope.py— impacttests/test_erlang_extractor.py— impacttests/test_extract.py— impacttests/test_extract_cache_location.py— impacttests/test_extract_cli.py— impacttests/test_extract_php_closures.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_import_repoint.py— impacttests/test_go_interface_methods.py— impacttests/test_go_qualified_resolution.py— impacttests/test_ignore_file_encoding.py— impacttests/test_import_extension_resolution.py— impacttests/test_import_self_loops.py— impact- … and 116 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)
CHANGELOG.md§ 0.4.10 (2026-04-13) (lines 1958-1977): references changed symbolsbind
· 71 more finding(s) on lines outside this diff (see the check run).
…ound local (#4198) Resolves a call on a local or parameter whose class is known from an annotation (c: Client), an Optional/union/string annotation, or a constructor/with binding, to the owning class's method - fail-closed to a single in-file-or-imported class that owns the method. Complements the self.<attr> arm from #4176. Rebased onto the landed #4176: reuses its _emit_call(inferred=True) INFERRED/0.85 path instead of the PR's own confidence= parameter. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…receiver_type A cache entry written under AST schema 5 has no receiver_type on its raw calls, so replaying it would silently drop the typed-receiver edge. Save such an entry under schema 5, switch to the current schema, and assert the edge is there. Fails when _AST_CACHE_SCHEMA is left at 5, passes with the bump to 6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* upstream/v8: (184 commits) release: 0.9.80 — include Graphify-Labs#4195, Graphify-Labs#4198 feat(python): resolve obj.method() through an annotated/constructor-bound local (Graphify-Labs#4198) perf(extract): resolve and parse each path once in the id remap and call tie-break release: 0.9.80 fix(deps): raise tree-sitter runtime floor to >=0.25 for ABI 15 grammars (Graphify-Labs#4148) fix(build): give --no-cluster graph.json the same endpoint rules as build_from_json fix(watch): keep extract()'s run-only keys out of --no-cluster graph.json fix(watch): drop an external import stub once no edge references it fix(detect): drop a previous checkout's absolute manifest keys (Graphify-Labs#4175) fix(python): keep TYPE_CHECKING-only imports out of import cycles (Graphify-Labs#3159) fix(python): resolve self.<attr>.<method>() calls through the attribute's constructor type (Graphify-Labs#2860) fix(cache): treat an AST hit whose import target is gone as a miss fix(extract): preserve cross-drive syntax warnings fix(extract): let the Windows console script use the extraction pool fix(resolution): keep re_exports from both files whose ids collide fix(markdown): resolve links to file names that contain a space fix(java): capture methods and calls inside anonymous class bodies test: skip optional-extra tests when the extra is not installed (Graphify-Labs#4190) fix(serve): show the graph's build commit in graph_stats perf(serve): build the MCP traversal view and shortest-path graphs once per graph ... # Conflicts: # CHANGELOG.md # graphify/extract.py # graphify/extractors/engine.py # graphify/serve.py # pyproject.toml # uv.lock
* upstream/v8: (184 commits) release: 0.9.80 — include Graphify-Labs#4195, Graphify-Labs#4198 feat(python): resolve obj.method() through an annotated/constructor-bound local (Graphify-Labs#4198) perf(extract): resolve and parse each path once in the id remap and call tie-break release: 0.9.80 fix(deps): raise tree-sitter runtime floor to >=0.25 for ABI 15 grammars (Graphify-Labs#4148) fix(build): give --no-cluster graph.json the same endpoint rules as build_from_json fix(watch): keep extract()'s run-only keys out of --no-cluster graph.json fix(watch): drop an external import stub once no edge references it fix(detect): drop a previous checkout's absolute manifest keys (Graphify-Labs#4175) fix(python): keep TYPE_CHECKING-only imports out of import cycles (Graphify-Labs#3159) fix(python): resolve self.<attr>.<method>() calls through the attribute's constructor type (Graphify-Labs#2860) fix(cache): treat an AST hit whose import target is gone as a miss fix(extract): preserve cross-drive syntax warnings fix(extract): let the Windows console script use the extraction pool fix(resolution): keep re_exports from both files whose ids collide fix(markdown): resolve links to file names that contain a space fix(java): capture methods and calls inside anonymous class bodies test: skip optional-extra tests when the extra is not installed (Graphify-Labs#4190) fix(serve): show the graph's build commit in graph_stats perf(serve): build the MCP traversal view and shortest-path graphs once per graph ... # Conflicts: # CHANGELOG.md # graphify/extract.py # graphify/extractors/engine.py # graphify/serve.py # pyproject.toml # uv.lock
What does this PR do?
Closes #4197.
Extractor (
graphify/extractors/engine.py): for every Python function,_python_local_class_bindingsbuilds{name -> class}fromX,mod.X(last part),"X",Optional[X],typing.Optional[X],X | None;x = X(...)andx: X = ...;with X(...) as x.A name gets no type as soon as anything makes it unsure: a second, different class; a rebind from a non-constructor (
x = make());for x in,except ... as x, tuple unpacking (alsowith A() as (x, y)),+=, walrus,global/nonlocal,import x, amatchcapture (case x:),*x/**xparameters. Containers (list[X],Optional[list[X]]) give no type.Scopes: a nested
defis its own caller and is not read, except that anonlocal xinside it dropsx. Calls inside a lambda or a nested class body are attributed to the enclosing function, so every name a lambda or class body binds is dropped (lambda c: c.send()insidedef f(c: Client)gets no edge).A member call whose receiver has a type gets
receiver_typeon its raw call (same key Ruby already uses).Resolver (
graphify/extract.py,_resolve_python_member_calls): a new arm before the class arm emitscaller -> Class.methodonly whenOtherwise no edge. Confidence
INFERRED,confidence_score0.85 (the rubric value the shared call pass uses), because the type comes from an annotation or a constructor, not from the call. A typed receiver never falls through to the module arm.Cache (
graphify/cache.py):_AST_CACHE_SCHEMA5 -> 6, so a cache written before this re-extracts instead of keeping raw calls withoutreceiver_type.Complements #4176 (
self.attr.method()): different arm in the same function.Type of change
Verification & Invariants
Invariant: a new
callsedge is emitted only when the receiver's class is certain from the function's own code and the class is unique and visible from the caller. Every other case emits nothing, as before (fail-closed).Persisted state it could invalidate: AST cache entries (schema bumped, so they re-extract).
Measured on three real repos, v8 vs this branch, cold
graphify update <repo> --no-cluster --force:obj.method()found"Correct" = jedi
gotoat the exact call site lands on the same file, class and method. 152 of 153 confirmed that way. The other one,with Client(...) as client: client.request(...)inhttpx/_api.py, jedi cannot follow throughClient.__enter__'s TypeVar return; checked by hand, it isClient.request.Node counts identical. Every other edge identical (a few ids built from the checkout's absolute path differ only because the two copies sit in different folders).
Build time, cold, median of interleaved runs: httpx 3.58 -> 3.67 s (7 runs), click 4.51 -> 4.57 s (3), flask 4.01 -> 4.02 s (7). Same within noise.
tests/test_python_typed_receiver_calls.py. On v8, 7 fail (every case that should link); the 3 "must not link" guards pass on both.Limitations (no edge, same as before):
from . import models+m: models.Request): that import edge points at the package / module id, not the class's file, so the origin gate cannot prove the class is visible.x = make_client()), attribute receivers (self.x.m(), fix(python): resolve self.<attr>.<method>() calls through the attribute's class #4176), inherited methods.with X(...) as xassumesX.__enter__returnsself. True for every with-binding edge in the three repos above (all checked).How was this tested?
Project venv (Python 3.12.12, graphifyy 0.9.79 from source), Windows 11:
Ground truth: jedi 0.20.0 in a separate venv (scripts not part of this PR).
Graphify-specific checklist
🤖 Generated with Claude Code