Skip to content

fix: exclude bodiless tags-path declarations from call targets - #7

Merged
stephane-segning merged 1 commit into
mainfrom
fix/tags-declaration-call-targets
Aug 8, 2026
Merged

fix: exclude bodiless tags-path declarations from call targets#7
stephane-segning merged 1 commit into
mainfrom
fix/tags-declaration-call-targets

Conversation

@stephane-segning

Copy link
Copy Markdown
Contributor

1. Summary

This PR changes:

  • Classifier::is_call_target's Tagged arm (src/graph/emit.rs) now excludes a captured
    definition from the resolver's call-target candidate set when its node kind is a verified
    bodiless-declaration shape (Java method_declaration, TypeScript method_signature /
    abstract_method_signature / function_signature) AND it has no body child.
  • Two new fixtures, tests/fixtures/java-interface-repo/ and
    tests/fixtures/typescript-interface-repo/, with committed goldens, proving a
    single-implementation interface method now resolves end-to-end.
  • New unit tests in src/graph/emit.rs and src/graph/tests.rs mirroring the Rust fix's test
    suite from fix: extract bodiless trait methods and stop binary content reaching chunks #4, plus one documenting a related, un-fixed limitation (see Scope below).

It solves:


2. Intent

The intent of this PR is:

Closes #5 (#5). This is the tags-path twin of
the Rust fix already merged in #4 (#4): a
declaration is a definition (node + method edge + chunk) but never a call target — a call
dispatches to an implementation, never to the declaration itself. #4 established this rule for
Rust, keyed off the function_signature_item vs function_item node-kind distinction. #5 asks
for the same rule on the tags-driven path (Python/JS/TS/Java), where the signal isn't a distinct
node kind but the presence/absence of a body child — verified directly against
tree-sitter-java's and tree-sitter-typescript's bundled node-types.json rather than
assumed.


3. Scope

In Scope

  • Java method_declaration (interface members, abstract methods) — the reported case.
  • TypeScript method_signature (interface members), abstract_method_signature (abstract-class
    members), and function_signature (ambient/overload declarations) — verified to have the exact
    same bodiless-node-kind shape against tree-sitter-typescript's node-types.json, so included
    per the issue's "worth checking in the same pass" note.
  • A defensive narrowing this PR also required: JS's several ways of tagging a
    const/let-assigned arrow function (variable_declarator, assignment_expression, pair
    wrapper nodes) anchor @definition.function on a node with no body field concept at all.
    A blind "no body child ⇒ declaration" rule misclassified those as bodiless declarations and
    dropped them as call targets — caught by the javascript-repo golden gaining a spurious
    Button()-call drop during development, before the check was scoped to a verified node-kind
    allowlist. Not a new fixture (the drift showed up directly against the existing golden), but
    worth flagging as in-scope collateral this PR had to get right.

Out of Scope

  • Python — abstract methods there have real bodies (pass/...), so function_definition.body
    is a required field and there is no bodiless shape to detect. Confirmed via
    tree-sitter-python's node-types.json, not assumed.
  • Any change to resolve::pick's ambiguity policy. This surfaced a real, separate, pre-existing
    finding during development (see Verification below) that is deliberately left alone and
    documented with a test, not fixed, per this PR's explicit scope boundary.

4. Verification

I verified this change by:

  • Running automated tests
  • Checking logs

Commands run:

cargo test --no-fail-fast
cargo fmt --all
cargo clippy --all-targets --all-features --locked -- -D warnings
cargo llvm-cov --locked --all-targets --summary-only
cargo test --features container-tests --no-fail-fast
UPDATE_GOLDEN=1 cargo test --test language_goldens
git status --short tests/golden/   # confirm which goldens actually changed

Results:

$ cargo test --no-fail-fast
test result: ok. 144 passed; 0 failed; 0 ignored   (unit tests, incl. 5 new Java Tagged-path tests)
test result: ok. 3 passed; 0 failed; 0 ignored     (determinism)
test result: ok. 2 passed; 0 failed; 0 ignored     (env_config)
test result: ok. 8 passed; 0 failed; 0 ignored     (ignore_layers)
test result: ok. 17 passed; 0 failed; 0 ignored    (language_goldens, incl. 4 new interface-repo tests)
test result: ok. 2 passed; 0 failed; 0 ignored     (parity — untouched Rust sample-repo golden)
test result: ok. 5 passed; 0 failed; 0 ignored     (pdf_extraction)
test result: ok. 10 passed; 0 failed; 0 ignored    (robustness)
test result: ok. 4 passed; 0 failed; 0 ignored     (walk_options)
test result: ok. 1 passed; 0 failed; 0 ignored     (walk_stats)
196 total, 0 failed, 18 new tests added

$ cargo fmt --all && git diff --exit-code -- src   # clean, no drift after fmt
$ cargo clippy --all-targets --all-features --locked -- -D warnings
Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.14s   # zero warnings

$ cargo llvm-cov --locked --all-targets --summary-only
TOTAL   3879 regions, 172 missed, 95.57% region coverage (CI floor: 85%; prior baseline ~95.4%)
graph/emit.rs: 99.84% region, 100% function, 99.74% line coverage

$ cargo test --features container-tests --no-fail-fast
test crate_builds_and_extracts_the_fixture_graph_on_glibc ... ok
test crate_builds_and_extracts_the_fixture_graph_on_musl ... ok
test graph_round_trips_through_neo4j_and_serves_correct_retrieval_queries ... ok
test rewriting_the_same_commit_is_idempotent_merge_not_insert ... ok
test extractor_holds_its_invariants_over_real_world_repos ... ok
  -> log: 659 nodes, 821 edges (175 calls)
  -> itsdangerous: 159 nodes, 212 edges (68 calls)
  -> is: 332 nodes, 651 edges (324 calls)
  -> json-java: 1372 nodes, 1987 edges (700 calls)
test same_checkout_walked_twice_is_byte_identical ... ok
test graph_does_not_depend_on_filesystem_iteration_order ... ok
test determinism_holds_across_all_committed_language_goldens ... ok
All container tests pass — real-world Java/TS repos extract with no dangling edges.

$ UPDATE_GOLDEN=1 cargo test --test language_goldens && git status --short tests/golden/
?? tests/golden/java-interface-repo.graph.json
?? tests/golden/typescript-interface-repo.graph.json

Golden changes, hand-verified against fixture source (not just regenerated): the ONLY
goldens that changed are the two brand-new ones (java-interface-repo,
typescript-interface-repo) — every one of the six pre-existing per-language goldens
(java-repo, python-repo, javascript-repo, typescript-repo, tsx-repo, polyglot-repo)
came back byte-identical to main, confirmed via git status --short after the
UPDATE_GOLDEN=1 regeneration (not merely re-running the diff assertion). This means the fix is
additive-only for every already-committed fixture: no previously-resolving calls edge changed
or disappeared.

For the two new goldens, I read every node/edge against the fixture source line by line:

  • java-interface-repo: Greeter.java#2:greet (the interface declaration, line 2 of
    interface Greeter { String greet(); }) has a method edge from Greeter but is the target
    of zero calls edges. EnglishGreeter.java#2:greet (the implementation, line 2 of
    class EnglishGreeter implements Greeter { public String greet() {...} }) is the target of
    BOTH Main.java#3:run (qualified call EnglishGreeter.greet()) and Main.java#11:runBare
    (bare call greet()) — this is Main.run -> EnglishGreeter.greet, the exact edge the issue
    says should exist.
  • typescript-interface-repo: identical shape — src/greeter.ts#2:greet (declaration) has a
    method edge and zero incoming calls; src/english-greeter.ts#2:greet (implementation) is
    the target of both run()'s qualified call and runBare()'s bare call.

Negative case verified not to regress: java_repo_run_bare_ambiguous_build_is_dropped
(the existing runBare() ambiguity in tests/fixtures/java-repo, where build is defined on
BOTH Widget and Gadget) still passes — confirmed by the java-repo golden being
byte-identical to before this change. Two real implementations remain genuinely ambiguous; this
fix narrows the candidate set only by removing bodiless declarations, never by guessing between
multiple real implementations.

A genuine finding, deliberately not fixed here (see Scope): the issue's own literal
reproduction — void run(Greeter g) { g.greet(); }, calling through an interface-typed
variable rather than a class name — still produces no calls edge after this fix. I traced
this to resolve::pick's single-candidate branch: it rejects a candidate whose scope doesn't
textually equal the call's qualifier, and the tags path's qualifier_from_callee_node sets that
qualifier to the raw receiver identifier (here g, the parameter name — not EnglishGreeter,
the implementing type). There is no type inference on the tags path, so a receiver variable can
essentially never textually match the type that defines the method it calls — this is a
separate, pre-existing gap unrelated to the declaration/call-target distinction this PR fixes,
and touching resolve::pick is explicitly out of scope for #5. I added
java_call_through_an_interface_typed_variable_needs_a_qualifier_match in
src/graph/tests.rs to document this precisely (asserting the CURRENT, unresolved behaviour,
not desired behaviour) rather than leave it as a silent gap. Happy to file this as a follow-up
issue if desired.


5. Screenshots / Evidence

Not applicable — this is a library-internal graph-extraction fix with no UI surface. Evidence is
the command output above, plus the two new committed golden files
(tests/golden/java-interface-repo.graph.json, tests/golden/typescript-interface-repo.graph.json).


6. Risk Assessment

Risk level:

  • Low

Potential risks:

  • This PR ADDS calls edges that were previously (incorrectly) dropped — any downstream
    consumer relying on the absence of these edges would see new data. This is the intended fix,
    not a side effect.
  • The node-kind allowlist in is_call_target's Tagged arm is deliberately narrow (Java
    method_declaration; TS method_signature/abstract_method_signature/function_signature)
    rather than a blanket "no body ⇒ declaration" rule, specifically because the blanket version
    was proven wrong during development (see Scope) — a future grammar/language addition with a
    genuinely bodiless definition shape will need its own explicit entry in this list, not silent
    coverage.

Mitigation:

  • All 6 pre-existing per-language goldens are byte-identical (verified via git status, not
    just the comparison assertion) — zero regression to already-resolving calls.
  • container_repos (real-world Java/TS repo extraction) and
    determinism_holds_across_all_committed_language_goldens both pass, exercising the change
    against code this repo doesn't control.
  • Coverage held at 95.57% (graph/emit.rs itself at 99.84% region / 100% function coverage),
    well above the 85% CI floor.

7. AI Usage Declaration

AI was used for:

  • Understanding existing code
  • Generating code
  • Generating tests
  • Reviewing the diff

Human verification:

  • I understand every meaningful change in this PR
  • I checked generated code manually
  • I checked generated tests manually
  • I removed unsupported AI assumptions
  • I accept responsibility for this PR

8. Reviewer Focus

Please focus your review on:

  • Correctness
  • Tests
  • Edge cases
  • Maintainability

Closes #5 — the tags-path twin of the Rust fix in #4. A Java interface method
(or abstract method) and a TypeScript interface/abstract-class member both
parse as an ordinary tagged definition, so they registered as call targets
under the same bare name as their implementation. A single-implementation
interface therefore had TWO same-named candidates and no qualifier to
disambiguate them, so the precision-favouring resolver dropped every call to
it as ambiguous.

`Classifier::is_call_target`'s `Tagged` arm now excludes a definition when
BOTH (a) its node kind is one of the specific shapes verified (via each
grammar's `node-types.json`) to be legitimately bodiless — Java
`method_declaration`, TypeScript `method_signature` /
`abstract_method_signature` / `function_signature` — AND (b) it has no
`body` child. The check is scoped to that node-kind allowlist rather than
applied to every `Tagged` definition, because several JS `tags.scm` patterns
anchor `@definition.function` on a wrapper node with no `body` field concept
of its own (`variable_declarator` for `const f = () => {}`,
`assignment_expression`, `pair`) even though the function value they wrap
always has a body — treating those as bodiless would have wrongly dropped
every const-arrow-function call target (caught by the `javascript-repo`
golden gaining a spurious drop before the list was narrowed).

Python is unaffected: `function_definition.body` is a required field even
for a `pass`/`...`-bodied abstract method, so there is no bodiless shape to
detect there.

Two new fixtures (`java-interface-repo`, `typescript-interface-repo`) prove
the fix end-to-end: a single-implementation interface method now resolves,
both via a qualified call (matching this suite's existing `Widget.build()`
house style) and a bare call. All six pre-existing per-language goldens are
byte-identical to before this change — confirmed via `git status` after
`UPDATE_GOLDEN=1`, not just re-running the comparison — so this is additive
only, no regression to already-resolving calls.

FINDING, not fixed here (out of scope per the resolver-ambiguity-policy
exclusion): the issue's own literal reproduction — calling through an
interface-typed variable, `g.greet()` — still does not resolve after this
fix. `resolve::pick`'s single-candidate branch rejects a candidate whose
`scope` doesn't textually equal the call's qualifier, and the tags path sets
that qualifier to the raw receiver identifier (`g`, the parameter name) —
there is no type inference, so a receiver variable can never textually match
the type that defines the method it calls. Documented with a dedicated test
(`java_call_through_an_interface_typed_variable_needs_a_qualifier_match`)
rather than silently worked around.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 8, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: b9f59d7

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@stephane-segning

Copy link
Copy Markdown
Contributor Author

Scope note, so this PR's claim is precisely bounded.

This correctly closes #5's diagnosis (declaration and implementation competing as two candidates
for one bare name), but not the reproduction I originally wrote in #5. That example called
through an interface-typed variable (g.greet()), which independently hits a second, more general
bug: a call through any variable receiver never resolves, because the qualifier is captured as the
receiver identifier and compared textually against the declaring type name.

I verified that separately and it is not interface-specific, not framework-specific, and not
Java-specific:

Java: a.helper()  via variable   -> 0 calls edges
Java: A.helper()  type-qualified -> 1
Java: helper()    bare           -> 1
TS  : instance call via variable -> 0      TS  : bare call -> 1
PY  : instance call via variable -> 0      PY  : bare call -> 1

Now tracked as #8, with the reproduction and a suggested direction. #5 has been corrected to explain
the split — the badly chosen example was mine, not this PR's problem.

Two independent reviews found #8 the same day from opposite directions (an adversarial review of
this fix, and a Spring-support design spike that got zero calls edges over a realistic Spring
fixture set and then isolated it to a plain two-class fixture). This PR deliberately does not touch
resolve::pick, which is where #8 lives.

Worth recording why the suite was blind to #8: every Java call site in tests/fixtures/java-repo/
is type-qualified, this-qualified, or bare — not one is an instance call through a variable. The
new fixture here follows that same house style, so it does not close the gap either. #8 notes that
fixtures need an instance-call case in every tags language.

@stephane-segning
stephane-segning merged commit b88280e into main Aug 8, 2026
8 checks passed
@stephane-segning
stephane-segning deleted the fix/tags-declaration-call-targets branch August 8, 2026 03:19
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.

Java interface methods: single-impl calls are dropped as ambiguous (the tags-path twin of #1)

1 participant