From 8706a8f808927d171c172e75e73136657ccf6e33 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 18 May 2026 00:52:58 -0600 Subject: [PATCH 1/2] fix(groovy): emit ClassRelation for interface inheritance in both engines `interface X extends Y, Z` produced no class-graph edges because both extractors pushed only a Definition for the interface and never inspected the unnamed `extends_interfaces` child. The Rust handler now reuses `collect_interfaces` (which already recurses into `type_list`); the TS handler gains an equivalent helper that mirrors the same traversal. Closes #1115 --- .../codegraph-core/src/extractors/groovy.rs | 34 ++++++++++++++- src/extractors/groovy.ts | 42 ++++++++++++++++++- tests/parsers/groovy.test.ts | 14 +++++++ 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/crates/codegraph-core/src/extractors/groovy.rs b/crates/codegraph-core/src/extractors/groovy.rs index 9ef620216..94b9ffb4c 100644 --- a/crates/codegraph-core/src/extractors/groovy.rs +++ b/crates/codegraph-core/src/extractors/groovy.rs @@ -158,8 +158,9 @@ fn collect_interfaces( fn handle_interface_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { let Some(name_node) = node.child_by_field_name("name") else { return }; + let iface_name = node_text(&name_node, source).to_string(); symbols.definitions.push(Definition { - name: node_text(&name_node, source).to_string(), + name: iface_name.clone(), kind: "interface".to_string(), line: start_line(node), end_line: Some(end_line(node)), @@ -168,6 +169,18 @@ fn handle_interface_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) cfg: None, children: None, }); + + // `interface X extends Y, Z` — tree-sitter-groovy 0.1.x exposes parent + // interfaces as an unnamed `extends_interfaces` child wrapping a `type_list`. + // collect_interfaces already recurses into `type_list`, so passing the + // wrapper node works without a dedicated helper. + for i in 0..node.child_count() { + let Some(child) = node.child(i) else { continue }; + if child.kind() == "extends_interfaces" { + collect_interfaces(&child, &iface_name, source, symbols); + break; + } + } } fn handle_enum_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { @@ -523,4 +536,23 @@ mod tests { assert!(rels.iter().any(|c| c.implements.as_deref() == Some("I1"))); assert!(rels.iter().any(|c| c.implements.as_deref() == Some("I2"))); } + + #[test] + fn extracts_interface_inheritance() { + // `interface X extends Y, Z` — the grammar exposes parent interfaces + // via an unnamed `extends_interfaces` child (not a field), distinct + // from class declarations which use the `interfaces` field. + let s = parse_groovy("interface Serializable extends Comparable, Cloneable {}"); + let rels: Vec<_> = s.classes.iter().filter(|c| c.name == "Serializable").collect(); + assert!( + rels.iter().any(|c| c.implements.as_deref() == Some("Comparable")), + "missing implements=Comparable, got: {:?}", + rels + ); + assert!( + rels.iter().any(|c| c.implements.as_deref() == Some("Cloneable")), + "missing implements=Cloneable, got: {:?}", + rels + ); + } } diff --git a/src/extractors/groovy.ts b/src/extractors/groovy.ts index 64b695940..01a5dc782 100644 --- a/src/extractors/groovy.ts +++ b/src/extractors/groovy.ts @@ -141,14 +141,54 @@ function handleGroovyClassDecl(node: TreeSitterNode, ctx: ExtractorOutput): void function handleGroovyInterfaceDecl(node: TreeSitterNode, ctx: ExtractorOutput): void { const nameNode = node.childForFieldName('name'); if (!nameNode) return; + const ifaceName = nameNode.text; ctx.definitions.push({ - name: nameNode.text, + name: ifaceName, kind: 'interface', line: node.startPosition.row + 1, endLine: nodeEndLine(node), visibility: extractModifierVisibility(node), }); + + // `interface X extends Y, Z` — tree-sitter-groovy 0.1.x exposes parent + // interfaces via an unnamed `extends_interfaces` child (not a field), which + // wraps a `type_list` of `_type` nodes. Mirrors the Rust extractor. + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && child.type === 'extends_interfaces') { + collectGroovyParentInterfaces(child, ifaceName, node.startPosition.row + 1, ctx); + break; + } + } +} + +function collectGroovyParentInterfaces( + parent: TreeSitterNode, + name: string, + line: number, + ctx: ExtractorOutput, +): void { + for (let i = 0; i < parent.childCount; i++) { + const child = parent.child(i); + if (!child) continue; + switch (child.type) { + case 'type_identifier': + case 'identifier': + case 'scoped_type_identifier': { + ctx.classes.push({ name, implements: child.text, line }); + break; + } + case 'generic_type': { + const inner = child.child(0)?.text; + if (inner) ctx.classes.push({ name, implements: inner, line }); + break; + } + case 'type_list': + collectGroovyParentInterfaces(child, name, line, ctx); + break; + } + } } function handleGroovyEnumDecl(node: TreeSitterNode, ctx: ExtractorOutput): void { diff --git a/tests/parsers/groovy.test.ts b/tests/parsers/groovy.test.ts index 60c86919a..afa6bbd77 100644 --- a/tests/parsers/groovy.test.ts +++ b/tests/parsers/groovy.test.ts @@ -50,6 +50,20 @@ describe('Groovy parser', () => { ); }); + it('extracts interface inheritance (extends_interfaces)', () => { + // `interface X extends Y, Z` — the grammar exposes parent interfaces via + // an unnamed `extends_interfaces` child (not a field), distinct from class + // declarations which use the `interfaces` field. + const symbols = parseGroovy(`interface Serializable extends Comparable, Cloneable {}`); + const rels = symbols.classes.filter((c) => c.name === 'Serializable'); + expect(rels).toContainEqual( + expect.objectContaining({ name: 'Serializable', implements: 'Comparable' }), + ); + expect(rels).toContainEqual( + expect.objectContaining({ name: 'Serializable', implements: 'Cloneable' }), + ); + }); + it('extracts enum declarations', () => { const symbols = parseGroovy(`enum Color { RED, GREEN, BLUE From 732c82dd05ecac9343506d5a6d078cb2fb147ced Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 18 May 2026 19:01:13 -0600 Subject: [PATCH 2/2] fix(groovy): use current node's start line in WASM interface helper (#1158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align the TypeScript collectGroovyParentInterfaces helper with the Rust collect_interfaces helper, which re-evaluates start_line(interfaces) at each recursive invocation. Previously the WASM extractor forwarded the interface declaration's start line through every recursion level, so a multi-line interface (e.g. interface X\n extends Y, Z {}) produced line=1 in WASM but line=2 in native — the two engines must produce identical results. Add a multi-line line-parity test in both engines to guard against future regressions. --- crates/codegraph-core/src/extractors/groovy.rs | 14 ++++++++++++++ src/extractors/groovy.ts | 9 ++++++--- tests/parsers/groovy.test.ts | 14 ++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/codegraph-core/src/extractors/groovy.rs b/crates/codegraph-core/src/extractors/groovy.rs index 94b9ffb4c..00fa489df 100644 --- a/crates/codegraph-core/src/extractors/groovy.rs +++ b/crates/codegraph-core/src/extractors/groovy.rs @@ -555,4 +555,18 @@ mod tests { rels ); } + + #[test] + fn interface_inheritance_line_tracks_extends_clause() { + // Engine-parity guard: the relation line should match the + // `extends_interfaces` node's start line, not the `interface_declaration`'s + // — `collect_interfaces` re-evaluates `start_line(interfaces)` on every + // recursive call, and the WASM extractor must match. + let s = parse_groovy("interface Serializable\n extends Comparable, Cloneable {}"); + let rels: Vec<_> = s.classes.iter().filter(|c| c.name == "Serializable").collect(); + assert!(!rels.is_empty(), "expected at least one ClassRelation"); + for rel in &rels { + assert_eq!(rel.line, 2, "line should track the extends clause, got: {:?}", rel); + } + } } diff --git a/src/extractors/groovy.ts b/src/extractors/groovy.ts index 01a5dc782..8e85db909 100644 --- a/src/extractors/groovy.ts +++ b/src/extractors/groovy.ts @@ -157,7 +157,7 @@ function handleGroovyInterfaceDecl(node: TreeSitterNode, ctx: ExtractorOutput): for (let i = 0; i < node.childCount; i++) { const child = node.child(i); if (child && child.type === 'extends_interfaces') { - collectGroovyParentInterfaces(child, ifaceName, node.startPosition.row + 1, ctx); + collectGroovyParentInterfaces(child, ifaceName, ctx); break; } } @@ -166,9 +166,12 @@ function handleGroovyInterfaceDecl(node: TreeSitterNode, ctx: ExtractorOutput): function collectGroovyParentInterfaces( parent: TreeSitterNode, name: string, - line: number, ctx: ExtractorOutput, ): void { + // Use the current node's start line at each recursion level — matches the + // Rust `collect_interfaces` helper, which re-evaluates `start_line(interfaces)` + // for whatever node (`extends_interfaces` → `type_list`) is being processed. + const line = parent.startPosition.row + 1; for (let i = 0; i < parent.childCount; i++) { const child = parent.child(i); if (!child) continue; @@ -185,7 +188,7 @@ function collectGroovyParentInterfaces( break; } case 'type_list': - collectGroovyParentInterfaces(child, name, line, ctx); + collectGroovyParentInterfaces(child, name, ctx); break; } } diff --git a/tests/parsers/groovy.test.ts b/tests/parsers/groovy.test.ts index afa6bbd77..42d85753e 100644 --- a/tests/parsers/groovy.test.ts +++ b/tests/parsers/groovy.test.ts @@ -64,6 +64,20 @@ describe('Groovy parser', () => { ); }); + it('reports line of extends_interfaces clause for multi-line declarations', () => { + // Engine-parity guard: the line should match the `extends_interfaces` + // node's start line, not the `interface_declaration`'s start line, so the + // WASM extractor stays consistent with the Rust `collect_interfaces` + // helper which re-evaluates the current node's `start_line` at each + // recursion level. + const symbols = parseGroovy(`interface Serializable\n extends Comparable, Cloneable {}`); + const rels = symbols.classes.filter((c) => c.name === 'Serializable'); + expect(rels.length).toBeGreaterThan(0); + for (const rel of rels) { + expect(rel.line).toBe(2); + } + }); + it('extracts enum declarations', () => { const symbols = parseGroovy(`enum Color { RED, GREEN, BLUE