Skip to content

feat(python): resolve obj.method() through an annotated parameter or constructor-bound local - #4198

Open
rohit-jsfreaky wants to merge 2 commits into
Graphify-Labs:v8from
rohit-jsfreaky:feat/python-typed-receiver-calls
Open

rohit-jsfreaky wants to merge 2 commits into
Graphify-Labs:v8from
rohit-jsfreaky:feat/python-typed-receiver-calls

Conversation

@rohit-jsfreaky

Copy link
Copy Markdown
Contributor

What does this PR do?

Closes #4197.

Extractor (graphify/extractors/engine.py): for every Python function, _python_local_class_bindings builds {name -> class} from

  • parameter annotations: X, mod.X (last part), "X", Optional[X], typing.Optional[X], X | None;
  • x = X(...) and x: 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 (also with A() as (x, y)), +=, walrus, global/nonlocal, import x, a match capture (case x:), *x / **x parameters. Containers (list[X], Optional[list[X]]) give no type.
Scopes: a nested def is its own caller and is not read, except that a nonlocal x inside it drops x. 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() inside def f(c: Client) gets no edge).
A member call whose receiver has a type gets receiver_type on its raw call (same key Ruby already uses).

Resolver (graphify/extract.py, _resolve_python_member_calls): a new arm before the class arm emits caller -> Class.method only when

  1. the class name is defined exactly once in the corpus (the class arm's single-definition guard),
  2. the class is defined in the caller's file, imported into it by name, or lives in a module it imports (the TS: fabricated EXTRACTED calls edge via name-only type matching — third-party-typed field binds to unrelated same-named local class (v0.9.33/v0.9.35) #2553 origin gate), and
  3. the method is the class's own.

Otherwise no edge. Confidence INFERRED, confidence_score 0.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_SCHEMA 5 -> 6, so a cache written before this re-extracts instead of keeping raw calls without receiver_type.

Complements #4176 (self.attr.method()): different arm in the same function.

Type of change

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

Verification & Invariants

Invariant: a new calls edge 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:

repo real calls found (vs jedi) obj.method() found new edges new edges correct edges lost
httpx 68.9% -> 77.3% 0 -> 37 / 98 +39 39 0
click 74.9% -> 80.3% 2 -> 29 / 85 +102 102 0
flask n/a n/a +12 12 0

"Correct" = jedi goto at 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(...) in httpx/_api.py, jedi cannot follow through Client.__enter__'s TypeVar return; checked by hand, it is Client.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.

  • Read the CONTRIBUTING.md guide.
  • Reproduced the issue and identified the invariant.
  • Made the smallest fix necessary.
  • Added a regression test: 10 tests in 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.
  • Kept the PR description synchronized with the final implementation.
  • Documented any limitations / unsupported cases explicitly.

Limitations (no edge, same as before):

  • Module-qualified annotation through a module import (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.
  • Return-type inference (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 x assumes X.__enter__ returns self. 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:

python -m pytest tests/test_python_typed_receiver_calls.py -q     # 10 passed (on v8: 7 failed, 3 passed)
python -m pytest tests -q                                          # 18 failed, 6682 passed, 27 skipped; the same 18 fail on clean v8 on this machine, 0 new
python -m ruff check graphify tests/test_python_typed_receiver_calls.py   # All checks passed
pyright graphify/extract.py graphify/extractors/engine.py graphify/cache.py tests/test_python_typed_receiver_calls.py   # 158 errors, same 158 as v8, 0 new
bandit -ll graphify/extract.py graphify/extractors/engine.py graphify/cache.py   # same 6 findings as v8, 0 new

Ground truth: jedi 0.20.0 in a separate venv (scripts not part of this PR).

Graphify-specific checklist

  • I updated generated skill artifacts — not needed, no skill fragments touched.
  • I confirmed that AST/structural extraction remains deterministic (pure function of the source).
  • I reviewed changes for security implications (no unsafe interpolation into shell/Python).
  • I confirmed that no API keys or local-only graph data are included.
  • I disclosed AI authorship in my commit messages.

🤖 Generated with Claude Code

…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>
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown

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.

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


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 — 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_charmap_encoding.py — impact
  • tests/test_chunking.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_cli.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_ignore_file_encoding.py — impact
  • tests/test_import_extension_resolution.py — impact
  • tests/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)

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

safishamsi pushed a commit that referenced this pull request Oct 7, 2026
…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>
safishamsi added a commit that referenced this pull request Oct 7, 2026
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>
filipechagas added a commit to lawnstarter/graphify that referenced this pull request Oct 7, 2026
* 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
filipechagas added a commit to lawnstarter/graphify that referenced this pull request Oct 7, 2026
* 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

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.

[Feature]: Python: obj.method() on an annotated parameter or a constructor-bound local produces no calls edge

1 participant