Repository navigation
feat(python): resolve self/cls/super() calls to a base-class method in another file - #4222
rohit-jsfreaky wants to merge 1 commit into
Conversation
…n another file The in-file pass walks only bases declared in the same file, so `Flask.__init__` -> `App.__init__` (flask/app.py -> flask/sansio/app.py) and every other inherited call across modules had no calls edge. _resolve_python_member_calls now walks the caller's class up a single-inheritance chain and binds the first class that owns the method, as the MRO does. It stops with no edge at multiple inheritance, a base the inherits edges cannot show (marked `python_opaque_bases` by the extractor: `mod.Base`, `Generic[T]`), a base outside the corpus or a self-loop, a caller base its file neither defines nor imports, and `super(Cls, obj)` with arguments. On a changed-files rebuild an unchanged base is still a name stub, so the walk rewires copies of the inherits edges the way the full build does; the opaque-base marker is forwarded with the other context metadata. AST cache schema 6 -> 7. flask: 66.7% -> 71.6% of jedi's call pairs found; 16 new edges on httpx/click/flask, 16 confirmed by jedi, 0 lost. Changed-files rebuilds match full builds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
|
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.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Formal verification. PR-changed functions: 0/4 verified (0 proven, 0 may-equivalent, 0 distinguished) · 4 not verified (2 vacuous, 2 unsupported).
Not verified on this run: dispatch\_command (vacuous: never exercised), \_resolve\_python\_member\_calls (vacuous: never exercised), \_extract\_generic (unsupported), \_rebuild\_code (unsupported).
Graphify review — findings
Adds cross-file resolution for Python self.m(), cls.m(), and bare super().m() calls: _resolve_python_member_calls now walks the caller's class up a single-inheritance chain into other files and emits a calls edge to the first class that defines m. The walk emits no edge when it hits a class with multiple bases, a base it can't see (mod.Base, Generic[T], now tagged python_opaque_bases), a base outside the corpus, or a first-hop base the file neither defines nor imports; super(Cls, obj) with arguments is skipped too. It rewires copies of stubbed inherits edges so changed-files rebuilds walk the same chain as full builds, and it bumps _AST_CACHE_SCHEMA to 7, which invalidates existing AST caches.
No blocking issues surfaced. 2 lower-confidence candidates did not survive cross-model review.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 3417 functions depend on the 799 functions this change touches.
Health — this change adds coupling hotspots:
- new:
extract()— 801 callers, 51 callees - new:
_rebuild_code()— 160 callers, 56 callees - new:
detect()— 121 callers, 16 callees - new:
_extract_generic()— 18 callers, 34 callees - new:
save_semantic_cache()— 65 callers, 9 callees - new:
load_cached()— 60 callers, 8 callees - new:
extract_js()— 87 callers, 5 callees - new:
file_hash()— 54 callers, 6 callees - …and 67 more — each is listed as a finding
Verification — 3417 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
200 of 345 test file(s) selected (58%) via static blast radius.
tests/test_affected_cli.py— impacttests/test_agents_platform.py— impacttests/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_cache_stale_import_target.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_codebuddy.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_dedup_shrink_refuses_force_write.py— impacttests/test_definition_file_portability.py— impacttests/test_detect.py— impacttests/test_devin.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_explain_cli.py— impacttests/test_export_direction.py— impacttests/test_external_stub_endpoints.py— impacttests/test_extract.py— impacttests/test_extract_cache_location.py— impacttests/test_extract_cli.py— impacttests/test_extract_path_memo.py— impacttests/test_extract_php_closures.py— impact- … and 150 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)
.github/PULL_REQUEST_TEMPLATE.md§ Graphify-specific checklist (lines 33-39): references changed symbolsrunBENCHMARKS.md§ Reproducing (lines 190-203): references changed symbolsrunCHANGELOG.md§ 0.9.80 (2026-10-07) (lines 5-24): references changed symbolsrunCHANGELOG.md§ 0.9.68 (2026-09-25) (lines 185-197): references changed symbolsrunCHANGELOG.md§ 0.9.59 (2026-09-12) (lines 289-296): references changed symbolsrunCHANGELOG.md§ 0.9.53 (2026-08-30) (lines 365-376): references changed symbolsrunCHANGELOG.md§ 0.9.47 (2026-08-19) (lines 453-469): references changed symbolsrunCHANGELOG.md§ 0.9.45 (2026-08-16) (lines 487-493): references changed symbolsrunCHANGELOG.md§ 0.9.39 (2026-08-10) (lines 566-573): references changed symbolsrunCHANGELOG.md§ 0.9.35 (2026-08-06) (lines 598-606): references changed symbolsrun
…and 10 more.
Formal verification
Could not verify: Could not verify dispatch\_command.
The verifier did not have enough to check dispatch\_command, 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 40 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)
Could not verify: Could not verify \_resolve\_python\_member\_calls.
The verifier did not have enough to check \_resolve\_python\_member\_calls, 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 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
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
Could not verify: Could not verify \_rebuild\_code.
The verifier did not have enough to check \_rebuild\_code, 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: nondeterministic output (both versions disagreed with themselves on the witness) — not a behaviour change; a function whose output is not a function of its inputs (random / time / uuid / os.urandom, or state carried across calls) cannot be soundly distinguished by execution, so this tier abstains for the whole function
· 75 more finding(s) on lines outside this diff (see the check run).
|
Landed in v0.9.81 via an authorship-preserving cherry-pick, so your commit is on |
What does this PR do?
Closes #4221.
Resolver (
graphify/extract.py,_resolve_python_member_calls): a new arm forself.m(),cls.m()and baresuper().m()raw calls (the ones the in-file_self_call_targetwalk could not bind)._inherited_methodstarts at the caller's class (skipping it forsuper), and walks upinheritsedges, binding the first class whose own methods includem. It returns no edge when:metadata.python_opaque_bases(it has a base that is not aninheritsedge),source_file) or is the class itself,The arm is additive: when it finds nothing, the raw call continues exactly as before. Confidence is EXTRACTED, the same as the in-file base walk it extends across files.
Changed-files rebuilds: there, a base class in an unchanged file is still a name stub when the resolver runs (the full build has already rewired it). The arm runs
_rewire_unique_stub_nodeson copies of the inherits edges, so both builds walk the same chain; the real edges are not touched. Hops after the first trust theinheritsedges, because a changed-files rebuild carries an unchanged class'sinheritsedges but not its imports.Extractor (
graphify/extractors/engine.py):mod.Base,Generic[T], a call,*bases) getsmetadata.python_opaque_bases = True(keyword arguments likemetaclass=do not count);super(...)call with arguments gets_python_super_argson its raw call.Forwarding (
graphify/cli.py,graphify/watch.py):python_opaque_basesjoins the metadata keys already forwarded for unchanged context nodes (next toruby_lookup_unsafe).Cache (
graphify/cache.py):_AST_CACHE_SCHEMA6 -> 7.Complements #2737 (resolving the
inheritsedges themselves): with it, more chains have real edges to walk; classes with a module-qualified or generic base stay excluded here until their MRO position is modelled.Type of change
Verification & Invariants
Invariant: a new
callsedge is emitted only when a single-inheritance chain makes the target certain; everything else emits nothing, as before. A changed-files rebuild gives the samecallsedges as a full build.Persisted state it could invalidate: AST cache entries (schema bumped) and class-node metadata (new key, forwarded on changed-files rebuilds).
Measured on three repos, 0.9.80 vs this branch, cold
graphify update <repo> --no-cluster --force:"Correct" = jedi
gotoat the exact call site lands on the same file, class and method: 16 / 16.Every refused call on click and flask was traced to its stop rule; the large groups are a
module.Classbase (class AliasedGroup(click.Group), which has noinheritsedge at all), class attributes that hold a class (self.response_class(...), not a method), and bases outside the corpus (dict,ABC).Changed-files rebuild vs full build on flask (
watch._rebuild_code, changing one file):app.py,sansio/app.py,sansio/scaffold.pyeach give the same inherited-call edges as a fresh full build.Build time, cold, median of 5 interleaved runs: flask 6.15 -> 6.10 s, httpx 6.47 -> 5.98 s, click 7.67 -> 7.89 s. Same within noise.
tests/test_python_inherited_method_calls.py. On 0.9.80, the 5 that expect an edge fail; the 3 "no edge" guards pass on both. The two changed-files tests also fail ifpython_opaque_basesis not forwarded, and the cache test fails if the schema stays at 6.Limitations (no edge, same as before): multiple inheritance; bases written as
mod.Base/Generic[T]; methods reached through a nested function'sself(a closure); class attributes holding a callable;super(Cls, obj)with arguments. A subclass that assignsself.m = ...would shadow an inherited method at runtime; like the in-file walk, this is not modelled.How was this tested?
Project venv (Python 3.12.12, graphify from this branch via PYTHONPATH), 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
https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf