Skip to content

fix: a call to an inherited method finds the ancestor that declares it (#984) - #1185

Merged
3 commits merged into
stagingfrom
fix/984-inherited-calls
Nov 9, 2026
Merged

3 commits merged into
stagingfrom
fix/984-inherited-calls

Conversation

@tirth8205

Copy link
Copy Markdown
Owner

Part of #984.

Builds on #1171 and must merge after it. This branch is #1171 plus one commit (da8f6ba), so until #1171 lands the diff here shows both. Please review only that commit.

Credit

The ideas come from #1036 by Anthony Ala (@aala-conga): an INHERITS walk for inherited self-calls, keeping a receiver's declared type, Java packages as evidence, and a call on another object not landing on the caller's own method (parts 1, 2, 4 and 5 of that PR). This is a fresh write on top of #1171's resolver, not a merge of #1036, with every problem found in the review of #1036 fixed (table below). The commit carries Co-Authored-By: Anthony Ala. Nothing was pushed to #1036.

What was wrong

After #1171 a call to an inherited method through a subclass is an honest UNRESOLVED edge, but callers_of and tests_for on the real method still find nothing:

  • this.handle() in Leaf extends Alpha is written <leaf>::Leaf.handle, which names no node.
  • $this->dispatch() in a PHP test case, and Java's this.getName(), only match a method of the same name nearby.
  • pet.getName() on a Java Pet in the same package keeps no type at all, so it is a bare name guess, and pet.addVisit(v) inside Owner.addVisit was bound to Owner.addVisit itself.

What this does

  • inheritance.py (new): a class is known by its qualified name, never its short name. A parent in extends, or a receiver type, means a class only when the file says which: the same file, a file it imports, Java's java_type or a same-package class (read from the java_package the parser already stores on Class nodes, never from disk), a full Java or PHP import name, or a PHP class in the same namespace (new php_namespaces on PHP File nodes). An inherited method is the nearest one up the superclass chain; interfaces and traits hand down no body (php_kind marks them). Where a class has two parents that can hold code (Python), the method must be the only declaration among all ancestors, so no method order can pick another. A parent the graph cannot name stops the walk.
  • The walk (_resolve_inherited_endpoints) runs at the start of refresh_target_resolution, before fix: an edge that names no node says so, and confidence means something (#984) #1171's settle pass, on fresh <path>::Class.method names, on edges settled earlier (claimed_endpoint) and on edges it moved before. A moved edge keeps the parser's name in extra.inherited_endpoint, so every pass checks it again from scratch: if an update adds the method nearer, removes it, or leaves the answer unsure, the edge goes where a fresh parse and this pass put it. TESTED_BY mirrors take the same path. One transaction.
  • The bare-name resolver: $this, this, self and super mean the caller's own class (or its parent), then its ancestor. A declared receiver type (Pet pet) means that class or its ancestor, and nothing else, even when the class is not imported; a type from outside the graph binds nothing. A Java class name receiver (EntityUtils.getById()) and a constructor (new Pet()) may use the same package. A call on another, untyped object is not bound to the caller's own method on that alone (Java, Kotlin, C#, TypeScript only: the parser reads no PHP or JavaScript parameter types, so there $other in equals(Uuid $other) looks untyped but is the caller's own class).
  • Parser: keeps receiver_type with receiver_resolution: typed_receiver_unresolved when the type's file is unknown (Java, Kotlin, JS, TS), but not for a type parameter (T) or Java's var; records receiver: "this" for Java this.m().
  • find_dead_code still counts Util.make() as use of Util once the edge moves to Base.make.

The review points on #1036

Point Here Test
Classes matched by short name (homonym Widget, a/base vs b/base) qualified names; parents need file or import evidence test_a_homonym_class_in_another_file_does_not_bind
PHP $this treated as another object $this in both receiver lists test_php_this_binds_to_its_own_method_and_to_an_inherited_one, test_php_this_still_reaches_a_method_of_its_own_class
Package evidence for unqualified calls (ping() landing on same-package B.ping) package evidence only for a receiver type or class target test_same_package_class_that_is_not_an_ancestor_does_not_bind
Java package read from disk read from parse-time java_package (no file reads in inheritance.py)
Type filter skipped when empty (h.equals() back to Widget.equals) a known type never falls back to name matches test_a_typed_receiver_whose_class_lacks_the_method_does_not_loop_back
No revalidation after updates walk runs before settle, re-runs on settled and moved edges test_an_update_moves_the_call_as_a_rebuild_would, test_a_settled_call_is_taken_up_once_the_ancestor_appears
BFS is not Python's MRO decline when another ancestor could win test_python_two_parents_that_both_declare_it_do_not_bind, test_python_parent_outside_the_graph_stops_the_walk
One autocommit per edge one transaction test_the_walk_writes_once_and_a_second_pass_moves_nothing
TESTED_BY mirror not moved mirrors move too test_a_test_through_a_subclass_is_a_test_of_the_ancestor
Ruff E701/E702 clean CI lint

The inherited self-call probe (test_inherited_self_call_through_a_subclass_binds) binds. 15 of the 21 new tests fail on #1171; the other 6 are the guards above, which #1171 also passes.

Proof on real repositories

Shallow clones, each built with #1171 (0a9c0a6) and with this branch, edges compared row by row: spring-projects/spring-petclinic 500158f, CodelyTV/typescript-ddd-example 94717b0 (TypeScript, extends AggregateRoot, extends MongoRepository<T>), CodelyTV/php-ddd-example 9271c46 (PHP, test case and controller chains).

"Inherited calls" are CALLS edges bound to a method the call's class inherits.

inherited calls bound, #1171 → this TESTED_BY the same way invented targets (path name, no node) correct links lost
spring-petclinic 2 → 86 (9 methods, callers 4 → 92) 0 → 44 0 → 0 0
TypeScript DDD 0 → 18 (9 methods, callers 6 → 24) 0 → 0 0 → 0 0
PHP DDD 97 → 134 (42 methods, callers 120 → 158) 46 → 71 0 → 0 0

On PHP, #1171 already found 97 of these by name, because a test file uses its parent test case.

Other changes, every one read:

CALLS tiers, #1171 → this: petclinic INFERRED 17 → 253, UNRESOLVED 1,570 → 1,334; TypeScript INFERRED 66 → 75; PHP INFERRED 531 → 571, AMBIGUOUS 26 → 23. EXTRACTED is unchanged on all three.

Checked against the source (all of the new inherited links were read; these are the ones worth showing):

  1. petclinic OwnerController.java:86 owner.getId() → BaseEntity.getId: Owner extends Person extends BaseEntity, getId at BaseEntity.java:39, two levels up, different package.
  2. petclinic PetValidator.java:41 pet.getName() (Pet pet = (Pet) obj; at line 40) → NamedEntity.getName (NamedEntity.java:37).
  3. TypeScript MongoCourseRepository.ts:15 this.persist(...) → MongoRepository.persist: extends MongoRepository<Course>, persist at MongoRepository.ts:23.
  4. TypeScript CoursesCounter.ts:31 this.record(...) → AggregateRoot.record (AggregateRoot.ts:17).
  5. PHP CoursesGetWebController.php:19 $this->ask(...) → ApiController.ask: extends WebController extends ApiController, the second in the same namespace with no use, ask at ApiController.php:30.
  6. PHP CreateCourseCommandHandlerTest.php:35 $this->dispatch(...) → UnitTestCase.dispatch through CoursesModuleUnitTestCase (UnitTestCase.php:70), and its TESTED_BY mirror too.

Update equals rebuild. On a copy of the TypeScript repo, renaming MongoRepository.persist and running update gave the same CALLS, TESTED_BY and REFERENCES rows as a fresh build (2,001 rows, 0 differ); the 4 moved calls went back to UNRESOLVED with their claim.

Cost. Steady refresh_target_resolution on a 76,000-edge graph: 0.115 s on #1171, 0.156 s here.

Left out

  • A Java call with no object (ping()) is not read as this.ping(): the parser records no receiver for getOwner().getName() either, so the two cannot be told apart.
  • PHP and JavaScript parameter types are not read, so the "not the caller's own method" rule does not apply there.
  • A Java for-each variable (for (Pet pet : pets)) still carries no type, and Java interface default methods are not followed.
  • super.m() inside an override m() is still bound to the override by the parser, as before.

Schema

No change. New extra keys: inherited_endpoint, inherited_through; new receiver_resolution value typed_receiver_unresolved; php_namespaces on PHP File nodes and php_kind on PHP interfaces and traits. The next build puts an existing graph right.

Checks

  • uv run ruff check code_review_graph/: clean.
  • uv run --extra dev python -m mypy code_review_graph/ --ignore-missing-imports --no-strict-optional: clean.
  • uv run pytest tests/ --tb=short -q: 6,444 passed, 900 skipped, 3 xfailed, 4 xpassed. -m "surface or e2e": 40 passed, 2 skipped.
  • New tests/test_inherited_calls.py (21 tests). One fix: an edge that names no node says so, and confidence means something (#984) #1171 test now expects self.shared() to reach Base.shared, still never Other.shared.

🤖 Generated with Claude Code

tirth8205 and others added 3 commits November 9, 2026 19:57
The parser put a file path in front of names it could not check, so
h.hashCode() became One.java::Helper.hashCode, self.shared() named the
subclass when shared was inherited, and Vec::new() became src/lib.rs::new.
None names a node, yet each passed every check a real name does, and every
edge said EXTRACTED and 1.0 whether it was bound or not.

- refresh_target_resolution first settles endpoints: a path-qualified
  CALLS/REFERENCES target or TESTED_BY source with no node goes back to the
  plain name, keeps the qualified form in extra.claimed_endpoint, and
  returns to it if the node appears, so update and rebuild agree.
- A settled edge is skipped by the bare resolver, and a name match on it
  only counts for a method of the class it named.
- Tiers are EXTRACTED 1.0, INFERRED 0.8 or the resolver's score,
  AMBIGUOUS 0.4, UNRESOLVED 0.2. No schema change.
- callers_of, callees_of and references_to say in the existing confidence
  field when some call sites are name matches only.
- Rust: an unanchored single name is not an item of the crate root, an
  inline module still binds, and module files must match case.
- tests_for no longer follows a callee with no node; one query per hop.

On ripgrep 1,071 invented endpoints went to 0 and real call links rose
from 4,064 to 4,296 with none lost; petclinic and gin lost no links.

Fixes #984

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-ups for #984:

- A rename's name-match fallback skips a settled call whose claimed class
  is not the renamed method's class, so h.build() on a Helper is no longer
  a high-confidence edit when renaming One.build.
- Owner scopes are compared from the innermost name out, as far as both
  go: Alpha.Inner no longer matches Beta.Inner, while a claim that knew
  only Inner still matches Outer.Inner.
- A cross-file C++ scoped call (cpp_scoped_target) and a ReScript call bound
  through open/include (new rescript_open_module) are INFERRED, not
  EXTRACTED.
- The HCL resolver reads the Terraform name from claimed_endpoint, so an
  update that adds a declaration in a sibling file links the reference as a
  rebuild does.
- The Rust inline-module shortcut needs the item defined in that module; a
  name it only re-exports stays unresolved instead of landing on a
  same-named function in the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#984)

Builds on #1171 and must merge after it. The ideas come from #1036 by
Anthony Ala: an INHERITS walk for inherited self-calls, a receiver's
declared type kept when its class needs no import, Java packages as
evidence, and a call on another object not landing on the caller's own
method. Written fresh on top of #1171's settle pass, with each defect the
review of #1036 found fixed:

- A class is known by its qualified name. A parent or a receiver type
  counts only when the file, its imports, its Java package or its PHP
  namespace says which class it is, so a second Widget elsewhere is never
  used.
- PHP $this is the calling object, in both receiver lists.
- Package evidence names a receiver type or a class target only, never a
  plain ping().
- The Java package comes from the parse-time node data, not from disk.
- The walk runs before the settle pass and takes up settled edges again;
  a moved edge keeps the name the parser wrote, so each pass checks it
  again and an update matches a rebuild.
- One transaction; TESTED_BY mirrors move with their calls.
- Python: decline when another ancestor could win under the MRO, or when
  a parent is outside the graph.

Co-Authored-By: Anthony Ala <aala@conga.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown

code-review-graph review

Overall risk: 0.85 (CRITICAL) — 125 changed function(s)/class(es), 72 affected flow(s), 47 test gap(s) (16 with no tested caller found, 31 reached only through a caller)

Risk-scored changes

Risk Level Symbol Location Tested
0.85 critical code_review_graph/graph.py::_certainty_tier_sql code_review_graph/graph.py:497 indirect
0.75 high code_review_graph/tools/query.py::query_graph code_review_graph/tools/query.py:489 yes
0.65 medium code_review_graph/graph.py::_edge_extra_is_unresolved code_review_graph/graph.py:379 indirect
0.65 medium code_review_graph/graph.py::_is_path_qualified code_review_graph/graph.py:447 indirect
0.65 medium code_review_graph/graph.py::_load_edge_extra code_review_graph/graph.py:488 indirect
0.65 medium code_review_graph/graph.py::GraphStore._receiver_evidence_admits_candidate code_review_graph/graph.py:2180 indirect
0.65 medium code_review_graph/graph.py::GraphStore._resolve_inherited_endpoints code_review_graph/graph.py:2300 yes
0.65 medium code_review_graph/graph.py::GraphStore._node_exists code_review_graph/graph.py:2524 indirect
0.65 medium code_review_graph/graph.py::GraphStore._import_evidence code_review_graph/graph.py:2803 indirect
0.65 medium code_review_graph/graph.py::GraphStore._resolve_bare_endpoints code_review_graph/graph.py:2921 indirect

Affected execution flows

  • process — criticality 0.74, 682 node(s) across 19 file(s)
  • _run — criticality 0.74, 715 node(s) across 21 file(s)
  • build_or_update_graph_tool — criticality 0.73, 779 node(s) across 28 file(s)
  • refactor_tool — criticality 0.72, 170 node(s) across 12 file(s)
  • main — criticality 0.72, 1125 node(s) across 46 file(s)
  • ...and 67 more affected flow(s)

Test gaps

  • code_review_graph/inheritance.py::_load_extra (code_review_graph/inheritance.py:42)
  • code_review_graph/inheritance.py::_is_interface (code_review_graph/inheritance.py:50)
  • code_review_graph/inheritance.py::_strip_type (code_review_graph/inheritance.py:63)
  • code_review_graph/inheritance.py::InheritanceIndex.has_parents (code_review_graph/inheritance.py:166)
  • code_review_graph/inheritance.py::InheritanceIndex.resolve_type (code_review_graph/inheritance.py:177)
  • ...and 11 more with no direct test

Reached only through a caller

No test names these directly, but a tested caller reaches them along the call path shown. That is a path in the call graph, not a record of execution -- the caller's tests may never take this branch. Treat it as where to look, not as coverage: these count as gaps and are scored as untested.

  • code_review_graph/graph.py::_edge_extra_is_unresolved (code_review_graph/graph.py:379) — via code_review_graph/graph.py::GraphStore.get_transitive_tests, 1 hop(s)
  • code_review_graph/graph.py::_is_path_qualified (code_review_graph/graph.py:447) — via code_review_graph/graph.py::GraphStore._resolve_inherited_endpoints, 1 hop(s)
  • code_review_graph/graph.py::_plain_symbol_name (code_review_graph/graph.py:457) — via code_review_graph/graph.py::GraphStore.refresh_target_resolution, 2 hop(s)
  • code_review_graph/graph.py::_claimed_owner (code_review_graph/graph.py:463) — via code_review_graph/graph.py::GraphStore.get_transitive_tests, 2 hop(s)
  • code_review_graph/graph.py::_same_owner (code_review_graph/graph.py:475) — via code_review_graph/graph.py::GraphStore.get_transitive_tests, 2 hop(s)
  • ...and 26 more reached only through a caller

Powered by code-review-graph — local-first analysis; no code leaves the CI runner.

@tirth8205 tirth8205 closed this pull request by merging all changes into staging in d01c1fd Nov 9, 2026
HengYangDS pushed a commit to HengYangDS/code-review-graph that referenced this pull request Nov 9, 2026
…rge train)

tirth8205#1154 lets the parser bind a receiver typed by a class in the same Java
package, so pet.getName() is written as Pet.getName and the tirth8205#1185 inherited
pass moves it to NamedEntity.getName. The target is the one tirth8205#1185 expects;
the edge records the move under inherited_endpoint, not inherited_through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant