diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index 60d315df5..a683d0152 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -638,13 +638,17 @@ function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null { * is dropped for registry-primary languages in the worker pipeline (issue #1951). * * Scope covers `class_declaration` (`superclass` extends + `interfaces` - * implements clauses) AND `interface_declaration` (`extends_interfaces` → - * interface-to-interface EXTENDS), matching the legacy Java heritage query + * implements clauses), `record_declaration` (`interfaces` implements clauses), + * AND `interface_declaration` (`extends_interfaces` → interface-to-interface + * EXTENDS), matching the legacy Java heritage query * (tree-sitter-queries.ts), which has a dedicated `interface_declaration * (extends_interfaces (type_list …))` arm. Without the interface arm the * registry-primary synth silently dropped every `interface IA extends IB` * edge while the legacy leg emitted it — the exact =0/=N parity break #1951 - * targets. Enum/record heritage stays unemitted (no legacy arm). Generic + * targets. Record heritage is emitted even though the legacy query had no + * matching arm: Record graph nodes are canonical link targets since #2801, so + * dropping their `implements` clause prevents interface dispatch (#2900). Enum + * heritage remains unemitted. Generic * bases (`extends Box`, `implements IFoo`) ARE emitted here: the legacy * heritage query was widened to capture the inner `type_identifier` of a * `generic_type` (tree-sitter-queries.ts), so both paths now agree on SIMPLE @@ -671,7 +675,7 @@ function synthesizeJavaInheritanceReferences(root: SyntaxNode): CaptureMatch[] { const stack: SyntaxNode[] = [root]; while (stack.length > 0) { const node = stack.pop()!; - if (node.type === 'class_declaration') { + if (node.type === 'class_declaration' || node.type === 'record_declaration') { const superclass = node.childForFieldName('superclass'); if (superclass !== null) { for (const base of superclass.namedChildren) emitJavaInheritanceBase(out, base); diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index f6d6f0437..e80ce8ed2 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -1273,6 +1273,40 @@ describe('Java record method resolution (#2564)', () => { fs.rmSync(root, { recursive: true, force: true }); } }, 60000); + + it('links a Record implementor to its canonical Interface node (#2900)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-record-heritage-')); + try { + writeFixtureRepo(root, { + 'RecordHeritage.java': `interface Named { String name(); } + record User(String value) implements Named { + public String name() { return value; } + } + class Reader { + String read(Named value) { return value.name(); } + }`, + }); + + const linked = await runPipelineFromRepo(root, () => {}); + const implementsEdge = getRelationships(linked, 'IMPLEMENTS').find( + (edge) => edge.source === 'User' && edge.target === 'Named', + ); + const fanout = getRelationships(linked, 'CALLS').filter( + (edge) => + edge.source === 'read' && + edge.target === 'name' && + edge.rel.reason === 'interface-dispatch', + ); + + expect(implementsEdge?.sourceLabel).toBe('Record'); + expect(implementsEdge?.targetLabel).toBe('Interface'); + expect(fanout.map((edge) => `${edge.targetLabel}:${edge.targetFilePath}`)).toEqual([ + 'Method:RecordHeritage.java', + ]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 60000); }); // --------------------------------------------------------------------------- diff --git a/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts b/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts index 4032b51c2..d0f6764f0 100644 --- a/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts +++ b/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts @@ -29,6 +29,13 @@ function ctorRefs(src: string) { })); } +/** All synthesized inheritance references in `src`, reduced to lookup names. */ +function inheritanceRefs(src: string): string[] { + return emitJavaScopeCaptures(src, 'C.java') + .filter((m) => m['@reference.inherits'] !== undefined) + .flatMap((m) => m['@reference.name']?.text ?? []); +} + describe('emitJavaScopeCaptures — constructor reference names (F35 #1928)', () => { it('binds the simple name for an unqualified `new User()`', () => { const refs = ctorRefs(wrapExpr('new User()')); @@ -82,6 +89,20 @@ describe('emitJavaScopeCaptures — constructor reference names (F35 #1928)', () }); }); +describe('emitJavaScopeCaptures — record interface heritage (#2900)', () => { + it('captures simple, generic, and qualified record interfaces by lookup name', () => { + const refs = inheritanceRefs( + 'record User(int id) implements Named, Comparable, audit.Auditable {}', + ); + + expect(refs).toEqual(['Named', 'Comparable', 'Auditable']); + }); + + it('does not widen enum heritage while adding record support', () => { + expect(inheritanceRefs('enum Status implements Named { ACTIVE }')).toEqual([]); + }); +}); + describe('emitJavaScopeCaptures — explicit constructor invocations (F38 #1928)', () => { it('captures `super(...)` as a constructor ref to the superclass simple name', () => { const src = 'class C extends pkg.Base { C() { super(1, 2); } }';