Skip to content

feat(python): resolve self/cls/super() calls to a base-class method in another file - #4222

Closed
rohit-jsfreaky wants to merge 1 commit into
Graphify-Labs:v8from
rohit-jsfreaky:feat/python-inherited-method-calls
Closed

rohit-jsfreaky wants to merge 1 commit into
Graphify-Labs:v8from
rohit-jsfreaky:feat/python-inherited-method-calls

Conversation

@rohit-jsfreaky

Copy link
Copy Markdown
Contributor

What does this PR do?

Closes #4221.

Resolver (graphify/extract.py, _resolve_python_member_calls): a new arm for self.m(), cls.m() and bare super().m() raw calls (the ones the in-file _self_call_target walk could not bind). _inherited_method starts at the caller's class (skipping it for super), and walks up inherits edges, binding the first class whose own methods include m. It returns no edge when:

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_nodes on copies of the inherits edges, so both builds walk the same chain; the real edges are not touched. Hops after the first trust the inherits edges, because a changed-files rebuild carries an unchanged class's inherits edges but not its imports.

Extractor (graphify/extractors/engine.py):

  • a Python class whose bases include a non-name (mod.Base, Generic[T], a call, *bases) gets metadata.python_opaque_bases = True (keyword arguments like metaclass= do not count);
  • a super(...) call with arguments gets _python_super_args on its raw call.

Forwarding (graphify/cli.py, graphify/watch.py): python_opaque_bases joins the metadata keys already forwarded for unchanged context nodes (next to ruby_lookup_unsafe).

Cache (graphify/cache.py): _AST_CACHE_SCHEMA 6 -> 7.

Complements #2737 (resolving the inherits edges 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

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

Verification & Invariants

Invariant: a new calls edge is emitted only when a single-inheritance chain makes the target certain; everything else emits nothing, as before. A changed-files rebuild gives the same calls edges 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:

repo real calls found (vs jedi) new edges new edges correct (jedi) edges lost
flask 66.7% -> 71.6% (162 -> 174 / 243) +12 12 0
httpx 78.5% -> 78.9% +2 2 0
click 80.3% -> 80.7% +2 2 0

"Correct" = jedi goto at 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.Class base (class AliasedGroup(click.Group), which has no inherits edge 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.py each 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.

  • Read the CONTRIBUTING.md guide.
  • Reproduced the issue and identified the invariant.
  • Made the smallest fix necessary.
  • Added a regression test: 8 tests in 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 if python_opaque_bases is not forwarded, and the cache test fails if the schema stays at 6.
  • Kept the PR description synchronized with the final implementation.
  • Documented any limitations / unsupported cases explicitly.

Limitations (no edge, same as before): multiple inheritance; bases written as mod.Base / Generic[T]; methods reached through a nested function's self (a closure); class attributes holding a callable; super(Cls, obj) with arguments. A subclass that assigns self.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:

python -m pytest tests/test_python_inherited_method_calls.py -q     # 8 passed (on 0.9.80: 5 failed, 3 passed)
python -m pytest tests -q                                            # 18 failed, 6771 passed, 27 skipped; the same 18 fail on clean 0.9.80 on this machine, 0 new
python -m ruff check graphify tests/test_python_inherited_method_calls.py   # All checks passed
pyright <5 touched files> tests/test_python_inherited_method_calls.py      # 184 errors, same 184 as 0.9.80, 0 new
bandit -ll -r graphify                                               # 12 findings, same 12 as 0.9.80, 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

https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf

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

github-actions Bot commented Oct 8, 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.

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 — impact
  • tests/test_agents_platform.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_cache.py — impact
  • tests/test_cache_stale_import_target.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_codebuddy.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_dedup_shrink_refuses_force_write.py — impact
  • tests/test_definition_file_portability.py — impact
  • tests/test_detect.py — impact
  • tests/test_devin.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_explain_cli.py — impact
  • tests/test_export_direction.py — impact
  • tests/test_external_stub_endpoints.py — impact
  • tests/test_extract.py — impact
  • tests/test_extract_cache_location.py — impact
  • tests/test_extract_cli.py — impact
  • tests/test_extract_path_memo.py — impact
  • tests/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)

…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).

@safishamsi

Copy link
Copy Markdown
Member

Landed in v0.9.81 via an authorship-preserving cherry-pick, so your commit is on v8 with you credited as the author. Closing as shipped — thanks @rohit-jsfreaky!

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: self.m() / super().m() / cls.m() get no calls edge when m is defined on a base class in another file

2 participants