From b9f59d78e5728ac2a104408a29d35310abd2d2f6 Mon Sep 17 00:00:00 2001 From: Stephane Segning Lambou Date: Sat, 8 Aug 2026 05:05:26 +0200 Subject: [PATCH] fix: exclude bodiless tags-path declarations from call targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/graph/emit.rs | 142 ++++++++++++++- src/graph/tests.rs | 165 ++++++++++++++++++ .../java-interface-repo/EnglishGreeter.java | 5 + .../fixtures/java-interface-repo/Greeter.java | 3 + tests/fixtures/java-interface-repo/Main.java | 14 ++ .../src/english-greeter.ts | 5 + .../typescript-interface-repo/src/greeter.ts | 3 + .../typescript-interface-repo/src/main.ts | 14 ++ tests/golden/java-interface-repo.graph.json | 111 ++++++++++++ .../typescript-interface-repo.graph.json | 100 +++++++++++ tests/language_goldens.rs | 112 ++++++++++++ 11 files changed, 669 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/java-interface-repo/EnglishGreeter.java create mode 100644 tests/fixtures/java-interface-repo/Greeter.java create mode 100644 tests/fixtures/java-interface-repo/Main.java create mode 100644 tests/fixtures/typescript-interface-repo/src/english-greeter.ts create mode 100644 tests/fixtures/typescript-interface-repo/src/greeter.ts create mode 100644 tests/fixtures/typescript-interface-repo/src/main.ts create mode 100644 tests/golden/java-interface-repo.graph.json create mode 100644 tests/golden/typescript-interface-repo.graph.json diff --git a/src/graph/emit.rs b/src/graph/emit.rs index e8b7e1f..1a3e764 100644 --- a/src/graph/emit.rs +++ b/src/graph/emit.rs @@ -223,11 +223,36 @@ impl Classifier<'_> { match self { // The one Rust node kind that is callable-shaped but bodiless. Classifier::Rust => node.kind() != "function_signature_item", - // Tags-driven languages capture definitions, and an abstract/interface method that the - // grammar's `tags.scm` reports is still reported as a definition; leave them as targets - // rather than guess per-grammar. Revisit if a language shows the same duplicate-name - // regression Rust would have had. - Classifier::Tagged(_) => true, + // A captured definition of one of these node KINDS with no `body` child is a + // declaration, not an implementation — a call dispatches to an implementation, never to + // the declaration itself (issue #5, the tags-path twin of the Rust case above). Verified + // directly against each grammar's `node-types.json`: + // - Java `method_declaration`: `body` is an OPTIONAL field, present only when the + // method has one — an interface/`abstract` method has none. + // - TypeScript `method_signature` / `abstract_method_signature` (interface and + // abstract-class members) / `function_signature` (ambient/overload declarations): + // these node kinds have no `body` field at all — they are inherently bodiless. + // + // The check is scoped to these specific node kinds rather than applied to every `Tagged` + // definition unconditionally, because `child_by_field_name("body")` can't distinguish "no + // body field on this node kind at all" from "the field exists and is absent" — and 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` for `x.f = function(){}`, `pair` for `{ f() {} }`-style object + // methods) even though the function VALUE they wrap always has one. 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 this list was narrowed. + // Every other tagged definition shape (JS/TS bodied methods/functions via any capture + // pattern, and Python's always-bodied `function_definition` — deliberately unaffected, + // since even a `pass`/`...`-bodied abstract method has a body child) has no + // bodiless-declaration concept: leave it as a target, matching the pre-fix behaviour. + Classifier::Tagged(_) => match node.kind() { + "method_declaration" + | "method_signature" + | "abstract_method_signature" + | "function_signature" => node.child_by_field_name("body").is_some(), + _ => true, + }, } } @@ -456,6 +481,113 @@ mod tests { ); } + #[test] + fn tagged_bodiless_interface_method_is_a_node_with_a_method_edge() { + // REGRESSION (issue #5, the tags-path twin of the Rust + // `signature_only_trait_method_is_a_node_with_a_method_edge` case above): a Java interface + // method (`method_declaration` with no `body` field) used to be treated as a call target + // regardless — it must still be a definition (a node + `method` edge), just not a call + // target (see the next test). + let src = "interface Greeter {\n String greet();\n}\n"; + let facts = facts_for("java", "Greeter.java", src); + let iface = facts.nodes.iter().find(|n| n.label == "Greeter").unwrap(); + let greet = facts + .nodes + .iter() + .find(|n| n.label == "greet()") + .expect("bodiless interface method must produce a node"); + assert_eq!(greet.start_line, 2); + assert!( + facts.contains.iter().any(|e| e.relation == "method" + && e.source == iface.node_id + && e.target == greet.node_id), + "Greeter → greet must be a `method` edge; got {:?}", + facts.contains + ); + } + + #[test] + fn tagged_bodiless_interface_method_is_not_a_call_target() { + // It is callable-SHAPED (the node + `method` edge above) but must not enter the resolver's + // candidate set: a call dispatches to an implementation, never to the declaration. See + // `Classifier::is_call_target`. + let src = "interface Greeter {\n String greet();\n}\n"; + let facts = facts_for("java", "Greeter.java", src); + assert!( + facts.callables.is_empty(), + "a bodiless interface method must not be a call target; got {:?}", + facts.callables + ); + } + + #[test] + fn tagged_bodied_method_next_to_its_interface_declaration_is_still_the_only_call_target() { + // The tags-path twin of the Rust + // `a_bodied_method_next_to_its_declaration_is_still_the_only_call_target` case above: a + // single implementation must remain the ONLY call target even though its declaration is + // also indexed as a node — otherwise a single-impl interface would go from one candidate to + // two the moment declarations were indexed, and the precision-favouring resolver would drop + // every call to it (issue #5). + let src = "\ +interface Greeter { + String greet(); +} + +class EnglishGreeter implements Greeter { + public String greet() { return \"hello\"; } +} +"; + let facts = facts_for("java", "Greeter.java", src); + assert_eq!( + facts.callables.len(), + 1, + "exactly one call target (the impl) must survive; got {:?}", + facts.callables + ); + let surviving_line = facts + .nodes + .iter() + .find(|n| n.node_id == facts.callables[0].node_id) + .expect("callable node id must match an emitted node") + .start_line; + let impl_method_line = facts + .nodes + .iter() + .filter(|n| n.label == "greet()") + .map(|n| n.start_line) + .max() + .expect("both declaration and impl must be nodes"); + assert_eq!( + surviving_line, impl_method_line, + "the surviving call target must be the later-declared impl, not the interface \ + declaration; got {:?}", + facts.callables + ); + // Both defs still exist as nodes — the declaration is indexed, just not dispatched to. + assert_eq!( + facts.nodes.iter().filter(|n| n.label == "greet()").count(), + 2, + "declaration and implementation must BOTH be nodes; got {:?}", + facts.nodes + ); + } + + #[test] + fn tagged_bodied_method_is_still_a_call_target() { + // Sanity check paired with the bodiless tests above: a normal, bodied Java method (`{ ... }`) + // must remain a call target exactly as before this fix — the `body`-field check must not + // exclude ordinary definitions. + let src = "class C {\n void m() {}\n}\n"; + let facts = facts_for("java", "C.java", src); + assert_eq!( + facts.callables.len(), + 1, + "a bodied method must still be a call target; got {:?}", + facts.callables + ); + assert_eq!(facts.callables[0].name, "m"); + } + #[test] fn def_line_numbers_are_one_based() { let facts = facts_for("rust", "src/m.rs", "\n\nfn add() {}\n"); // add on source line 3 diff --git a/src/graph/tests.rs b/src/graph/tests.rs index dc755fd..1bebd62 100644 --- a/src/graph/tests.rs +++ b/src/graph/tests.rs @@ -701,3 +701,168 @@ fn a_call_to_a_single_impl_trait_method_still_resolves_to_the_impl() { "must resolve to the impl in circle.rs, not the declaration; got {targets:?}" ); } + +// ── Tags path: declaration vs. call target (issue #5, the tags-path twin of the Rust case above) ── + +#[test] +fn java_call_to_a_single_impl_interface_method_resolves_to_the_implementation() { + // The actual bug (issue #5): before the fix, the interface declaration and its sole + // implementation both registered as call targets under the bare name `greet`, so the + // precision-favouring resolver saw two same-named candidates with no disambiguating qualifier + // and dropped the call as ambiguous. A BARE call is used deliberately (mirroring the Rust + // twin's `c.describe()`, which also carries no qualifier): it isolates the exact mechanism this + // PR fixes — one candidate instead of two — from the tags path's separate qualifier heuristic + // (see `java_call_through_an_interface_typed_variable_needs_a_qualifier_match`, below, for why + // the issue's own `g.greet()` receiver-variable phrasing needs a different fixture). + let greeter = ( + "Greeter.java", + "interface Greeter {\n String greet();\n}\n", + ); + let english_greeter = ( + "EnglishGreeter.java", + "class EnglishGreeter implements Greeter {\n public String greet() { return \"hello\"; }\n}\n", + ); + let main = ( + "Main.java", + "class Main {\n String run() {\n return greet();\n }\n}\n", + ); + let g = graph_of_lang("java", &[greeter, english_greeter, main]); + + // Both the declaration and the implementation are indexed as nodes. + let greets: Vec<&GraphNode> = g.nodes.iter().filter(|n| n.label == "greet()").collect(); + assert_eq!( + greets.len(), + 2, + "declaration and implementation must both be indexed as nodes; got {greets:?}" + ); + + // ...but the call resolves, and resolves to the IMPLEMENTATION, not the declaration. + let run = node(&g, "run()"); + let calls: Vec<_> = g + .edges + .iter() + .filter(|e| e.relation == "calls" && e.source == run.node_id) + .collect(); + assert_eq!( + calls.len(), + 1, + "the call must resolve to exactly one target; got {calls:?}" + ); + let target = g + .nodes + .iter() + .find(|n| n.node_id == calls[0].target) + .expect("call target must be an emitted node"); + assert_eq!( + target.source_file, "EnglishGreeter.java", + "must resolve to the implementation, not the interface declaration; edges = {:?}", + g.edges + ); +} + +#[test] +fn java_call_through_an_interface_typed_variable_needs_a_qualifier_match() { + // FINDING, not fixed here (out of scope — "any change to the resolver's ambiguity policy" is + // excluded from issue #5): the issue's own literal reproduction, `g.greet()` where + // `g: Greeter`, 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 + // `qualifier_from_callee_node` sets the qualifier to the raw receiver identifier — here `g`, + // the PARAMETER name, not `EnglishGreeter`, the implementing type. There is no type inference in + // the tags path, so a receiver variable can never textually match the type that defines the + // method it calls. This test documents the boundary of this PR's fix, not a regression: it + // passed (found nothing) before this change too, for the SAME underlying reason plus the + // declaration-vs-target ambiguity this PR removes — dropping from "ambiguous" to "unresolved" + // is not a functional improvement for this exact call shape. Calling through the class name + // (`EnglishGreeter.greet()`, next test, and the `java-interface-repo` golden fixture) is the + // shape that already works, matching this codebase's existing `Widget.build()`-style qualified + // calls in `tests/fixtures/java-repo`. + let greeter = ( + "Greeter.java", + "interface Greeter {\n String greet();\n}\n", + ); + let english_greeter = ( + "EnglishGreeter.java", + "class EnglishGreeter implements Greeter {\n public String greet() { return \"hello\"; }\n}\n", + ); + let main = ( + "Main.java", + "class Main {\n void run(Greeter g) {\n g.greet();\n }\n}\n", + ); + let g = graph_of_lang("java", &[greeter, english_greeter, main]); + assert!( + !g.edges.iter().any(|e| e.relation == "calls"), + "documents a known, pre-existing, separate limitation — not asserting desired behaviour; \ + edges = {:?}", + g.edges + ); +} + +#[test] +fn java_call_to_an_interface_method_with_no_implementation_does_not_resolve() { + // The direct converse of the fix: a declaration alone — no implementation anywhere — must never + // be treated as a call target. Without the fix this would have been the sole (and therefore + // "successfully resolving") candidate, silently dispatching a call to a declaration that has no + // body to run. + let g = graph_of_lang( + "java", + &[( + "Greeter.java", + "interface Greeter {\n String greet();\n}\nclass Main {\n String run() {\n return greet();\n }\n}\n", + )], + ); + assert!( + !g.edges.iter().any(|e| e.relation == "calls"), + "a call to a declaration-only method must not resolve; edges = {:?}", + g.edges + ); +} + +#[test] +fn typescript_call_to_a_single_impl_interface_method_resolves_to_the_implementation() { + // The TypeScript twin of the Java case above. Verified against `tree-sitter-typescript`'s + // `node-types.json`: `method_signature` (interface members) and `abstract_method_signature` + // (abstract-class members) have no `body` field at all, while `method_definition` requires one + // — the same bodiless-declaration shape as Java, captured by the SAME `is_call_target` check. + let greeter = ( + "greeter.ts", + "export interface Greeter {\n greet(): string;\n}\n", + ); + let english_greeter = ( + "english-greeter.ts", + "export class EnglishGreeter implements Greeter {\n greet(): string {\n return 'hello';\n }\n}\n", + ); + let main = ( + "main.ts", + "function run(): string {\n return greet();\n}\n", + ); + let g = graph_of_lang("typescript", &[greeter, english_greeter, main]); + + let greets: Vec<&GraphNode> = g.nodes.iter().filter(|n| n.label == "greet()").collect(); + assert_eq!( + greets.len(), + 2, + "declaration and implementation must both be indexed as nodes; got {greets:?}" + ); + + let run = node(&g, "run()"); + let calls: Vec<_> = g + .edges + .iter() + .filter(|e| e.relation == "calls" && e.source == run.node_id) + .collect(); + assert_eq!( + calls.len(), + 1, + "the call must resolve to exactly one target; got {calls:?}" + ); + let target = g + .nodes + .iter() + .find(|n| n.node_id == calls[0].target) + .expect("call target must be an emitted node"); + assert_eq!( + target.source_file, "english-greeter.ts", + "must resolve to the implementation, not the interface declaration; edges = {:?}", + g.edges + ); +} diff --git a/tests/fixtures/java-interface-repo/EnglishGreeter.java b/tests/fixtures/java-interface-repo/EnglishGreeter.java new file mode 100644 index 0000000..69c1b54 --- /dev/null +++ b/tests/fixtures/java-interface-repo/EnglishGreeter.java @@ -0,0 +1,5 @@ +class EnglishGreeter implements Greeter { + public String greet() { + return "hello"; + } +} diff --git a/tests/fixtures/java-interface-repo/Greeter.java b/tests/fixtures/java-interface-repo/Greeter.java new file mode 100644 index 0000000..ab29534 --- /dev/null +++ b/tests/fixtures/java-interface-repo/Greeter.java @@ -0,0 +1,3 @@ +interface Greeter { + String greet(); +} diff --git a/tests/fixtures/java-interface-repo/Main.java b/tests/fixtures/java-interface-repo/Main.java new file mode 100644 index 0000000..d669680 --- /dev/null +++ b/tests/fixtures/java-interface-repo/Main.java @@ -0,0 +1,14 @@ +class Main { + // Qualified call, matching this fixture set's house style (see java-repo's `Widget.build()`). + String run() { + return EnglishGreeter.greet(); + } + + // Bare call to the SAME single-implementation interface method. Before the fix, `greet` had two + // candidates (the `Greeter` declaration and the `EnglishGreeter` implementation) and no qualifier + // to disambiguate them, so this was dropped as ambiguous — issue #5. A declaration is now excluded + // from the candidate set, leaving exactly one, so this resolves. + String runBare() { + return greet(); + } +} diff --git a/tests/fixtures/typescript-interface-repo/src/english-greeter.ts b/tests/fixtures/typescript-interface-repo/src/english-greeter.ts new file mode 100644 index 0000000..2c2ab41 --- /dev/null +++ b/tests/fixtures/typescript-interface-repo/src/english-greeter.ts @@ -0,0 +1,5 @@ +export class EnglishGreeter implements Greeter { + greet(): string { + return 'hello'; + } +} diff --git a/tests/fixtures/typescript-interface-repo/src/greeter.ts b/tests/fixtures/typescript-interface-repo/src/greeter.ts new file mode 100644 index 0000000..841bd9a --- /dev/null +++ b/tests/fixtures/typescript-interface-repo/src/greeter.ts @@ -0,0 +1,3 @@ +export interface Greeter { + greet(): string; +} diff --git a/tests/fixtures/typescript-interface-repo/src/main.ts b/tests/fixtures/typescript-interface-repo/src/main.ts new file mode 100644 index 0000000..3ca5845 --- /dev/null +++ b/tests/fixtures/typescript-interface-repo/src/main.ts @@ -0,0 +1,14 @@ +import { EnglishGreeter } from './english-greeter'; + +// Qualified call, matching this fixture set's house style (see typescript-repo's `Widget.build()`). +function run(): string { + return EnglishGreeter.greet(); +} + +// Bare call to the SAME single-implementation interface method. Before the fix, `greet` had two +// candidates (the `Greeter` declaration and the `EnglishGreeter` implementation) and no qualifier to +// disambiguate them, so this was dropped as ambiguous — issue #5. A declaration is now excluded from +// the candidate set, leaving exactly one, so this resolves. +function runBare(): string { + return greet(); +} diff --git a/tests/golden/java-interface-repo.graph.json b/tests/golden/java-interface-repo.graph.json new file mode 100644 index 0000000..50b78fd --- /dev/null +++ b/tests/golden/java-interface-repo.graph.json @@ -0,0 +1,111 @@ +{ + "nodes": [ + { + "node_id": "EnglishGreeter.java", + "label": "EnglishGreeter.java", + "source_file": "EnglishGreeter.java", + "start_line": 1 + }, + { + "node_id": "EnglishGreeter.java#1:EnglishGreeter", + "label": "EnglishGreeter", + "source_file": "EnglishGreeter.java", + "start_line": 1 + }, + { + "node_id": "EnglishGreeter.java#2:greet", + "label": "greet()", + "source_file": "EnglishGreeter.java", + "start_line": 2 + }, + { + "node_id": "Greeter.java", + "label": "Greeter.java", + "source_file": "Greeter.java", + "start_line": 1 + }, + { + "node_id": "Greeter.java#1:Greeter", + "label": "Greeter", + "source_file": "Greeter.java", + "start_line": 1 + }, + { + "node_id": "Greeter.java#2:greet", + "label": "greet()", + "source_file": "Greeter.java", + "start_line": 2 + }, + { + "node_id": "Main.java", + "label": "Main.java", + "source_file": "Main.java", + "start_line": 1 + }, + { + "node_id": "Main.java#11:runBare", + "label": "runBare()", + "source_file": "Main.java", + "start_line": 11 + }, + { + "node_id": "Main.java#1:Main", + "label": "Main", + "source_file": "Main.java", + "start_line": 1 + }, + { + "node_id": "Main.java#3:run", + "label": "run()", + "source_file": "Main.java", + "start_line": 3 + } + ], + "edges": [ + { + "source": "EnglishGreeter.java", + "target": "EnglishGreeter.java#1:EnglishGreeter", + "relation": "contains" + }, + { + "source": "EnglishGreeter.java#1:EnglishGreeter", + "target": "EnglishGreeter.java#2:greet", + "relation": "method" + }, + { + "source": "Greeter.java", + "target": "Greeter.java#1:Greeter", + "relation": "contains" + }, + { + "source": "Greeter.java#1:Greeter", + "target": "Greeter.java#2:greet", + "relation": "method" + }, + { + "source": "Main.java", + "target": "Main.java#1:Main", + "relation": "contains" + }, + { + "source": "Main.java#11:runBare", + "target": "EnglishGreeter.java#2:greet", + "relation": "calls" + }, + { + "source": "Main.java#1:Main", + "target": "Main.java#11:runBare", + "relation": "method" + }, + { + "source": "Main.java#1:Main", + "target": "Main.java#3:run", + "relation": "method" + }, + { + "source": "Main.java#3:run", + "target": "EnglishGreeter.java#2:greet", + "relation": "calls" + } + ] +} diff --git a/tests/golden/typescript-interface-repo.graph.json b/tests/golden/typescript-interface-repo.graph.json new file mode 100644 index 0000000..2f11df0 --- /dev/null +++ b/tests/golden/typescript-interface-repo.graph.json @@ -0,0 +1,100 @@ +{ + "nodes": [ + { + "node_id": "src/english-greeter.ts", + "label": "english-greeter.ts", + "source_file": "src/english-greeter.ts", + "start_line": 1 + }, + { + "node_id": "src/english-greeter.ts#1:EnglishGreeter", + "label": "EnglishGreeter", + "source_file": "src/english-greeter.ts", + "start_line": 1 + }, + { + "node_id": "src/english-greeter.ts#2:greet", + "label": "greet()", + "source_file": "src/english-greeter.ts", + "start_line": 2 + }, + { + "node_id": "src/greeter.ts", + "label": "greeter.ts", + "source_file": "src/greeter.ts", + "start_line": 1 + }, + { + "node_id": "src/greeter.ts#1:Greeter", + "label": "Greeter", + "source_file": "src/greeter.ts", + "start_line": 1 + }, + { + "node_id": "src/greeter.ts#2:greet", + "label": "greet()", + "source_file": "src/greeter.ts", + "start_line": 2 + }, + { + "node_id": "src/main.ts", + "label": "main.ts", + "source_file": "src/main.ts", + "start_line": 1 + }, + { + "node_id": "src/main.ts#12:runBare", + "label": "runBare()", + "source_file": "src/main.ts", + "start_line": 12 + }, + { + "node_id": "src/main.ts#4:run", + "label": "run()", + "source_file": "src/main.ts", + "start_line": 4 + } + ], + "edges": [ + { + "source": "src/english-greeter.ts", + "target": "src/english-greeter.ts#1:EnglishGreeter", + "relation": "contains" + }, + { + "source": "src/english-greeter.ts#1:EnglishGreeter", + "target": "src/english-greeter.ts#2:greet", + "relation": "method" + }, + { + "source": "src/greeter.ts", + "target": "src/greeter.ts#1:Greeter", + "relation": "contains" + }, + { + "source": "src/greeter.ts#1:Greeter", + "target": "src/greeter.ts#2:greet", + "relation": "method" + }, + { + "source": "src/main.ts", + "target": "src/main.ts#12:runBare", + "relation": "contains" + }, + { + "source": "src/main.ts", + "target": "src/main.ts#4:run", + "relation": "contains" + }, + { + "source": "src/main.ts#12:runBare", + "target": "src/english-greeter.ts#2:greet", + "relation": "calls" + }, + { + "source": "src/main.ts#4:run", + "target": "src/english-greeter.ts#2:greet", + "relation": "calls" + } + ] +} diff --git a/tests/language_goldens.rs b/tests/language_goldens.rs index 0b9f461..7a4e574 100644 --- a/tests/language_goldens.rs +++ b/tests/language_goldens.rs @@ -228,6 +228,116 @@ fn java_repo_run_bare_ambiguous_build_is_dropped() { ); } +// ── TypeScript: single-impl interface method (issue #5, the tags-path twin of #1) ──────────────── + +#[test] +fn typescript_interface_repo_graph_matches_committed_golden() { + let g = assert_matches_golden("typescript-interface-repo"); + assert!(has_cross_file_call(&g), "edges = {:?}", g.edges); + assert!(has_method_nesting(&g), "edges = {:?}", g.edges); +} + +#[test] +fn typescript_interface_repo_declaration_and_impl_are_both_nodes_but_only_one_is_a_call_target() { + let g = assert_matches_golden("typescript-interface-repo"); + let greets: Vec<_> = g.nodes.iter().filter(|n| n.label == "greet()").collect(); + assert_eq!( + greets.len(), + 2, + "the interface declaration and the implementation must both be nodes; got {greets:?}" + ); + let run = g + .nodes + .iter() + .find(|n| n.label == "run()") + .expect("run node"); + let run_bare = g + .nodes + .iter() + .find(|n| n.label == "runBare()") + .expect("runBare node"); + for caller in [run, run_bare] { + let targets: Vec<_> = g + .edges + .iter() + .filter(|e| e.relation == "calls" && e.source == caller.node_id) + .collect(); + assert_eq!( + targets.len(), + 1, + "{} must resolve to exactly one target; got {targets:?}", + caller.label + ); + let dst = g + .nodes + .iter() + .find(|n| n.node_id == targets[0].target) + .expect("call target must be an emitted node"); + assert_eq!( + dst.source_file, "src/english-greeter.ts", + "{} must resolve to the implementation, not the interface declaration; edges = {:?}", + caller.label, g.edges + ); + } +} + +// ── Java: single-impl interface method (issue #5, the tags-path twin of #1) ────────────────────── + +#[test] +fn java_interface_repo_graph_matches_committed_golden() { + let g = assert_matches_golden("java-interface-repo"); + assert!(has_cross_file_call(&g), "edges = {:?}", g.edges); + assert!(has_method_nesting(&g), "edges = {:?}", g.edges); +} + +#[test] +fn java_interface_repo_declaration_and_impl_are_both_nodes_but_only_one_is_a_call_target() { + // The actual bug (issue #5): a Java interface method with a single implementation used to + // produce no `calls` edge at all — the declaration and the implementation both registered as + // call targets under the bare name `greet`, so the resolver saw two candidates with no + // disambiguating qualifier and dropped the call as ambiguous. + let g = assert_matches_golden("java-interface-repo"); + let greets: Vec<_> = g.nodes.iter().filter(|n| n.label == "greet()").collect(); + assert_eq!( + greets.len(), + 2, + "the interface declaration and the implementation must both be nodes; got {greets:?}" + ); + let run = g + .nodes + .iter() + .find(|n| n.label == "run()") + .expect("run node"); + let run_bare = g + .nodes + .iter() + .find(|n| n.label == "runBare()") + .expect("runBare node"); + for caller in [run, run_bare] { + let targets: Vec<_> = g + .edges + .iter() + .filter(|e| e.relation == "calls" && e.source == caller.node_id) + .collect(); + assert_eq!( + targets.len(), + 1, + "{} must resolve to exactly one target; got {targets:?}", + caller.label + ); + let dst = g + .nodes + .iter() + .find(|n| n.node_id == targets[0].target) + .expect("call target must be an emitted node"); + assert_eq!( + dst.source_file, "EnglishGreeter.java", + "{} must resolve to the implementation, not the interface declaration; edges = {:?}", + caller.label, g.edges + ); + } +} + // ── Polyglot (Python + TypeScript + Rust + Java in one checkout) ────────────────────────────────── #[test] @@ -273,6 +383,8 @@ fn fixtures_have_no_embedded_git_dir() { "javascript-repo", "tsx-repo", "java-repo", + "typescript-interface-repo", + "java-interface-repo", "polyglot-repo", ] { let root = fixture_root(name);