diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 0bce8d926..f4c721389 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -337,7 +337,17 @@ export const findEnclosingClassInfo = ( c.type === 'constant', ); if (nameNode) { - const label = CONTAINER_TYPE_TO_LABEL[current.type] || 'Class'; + let label = CONTAINER_TYPE_TO_LABEL[current.type] || 'Class'; + // Kotlin: class_declaration with an anonymous "interface" keyword child + // is actually an interface, not a class. Refine the label to match the + // node ID generated from the tree-sitter query capture (@definition.interface). + if ( + current.type === 'class_declaration' && + label === 'Class' && + current.children?.some((c: SyntaxNode) => c.type === 'interface') + ) { + label = 'Interface'; + } return { classId: generateId(label, `${filePath}:${nameNode.text}`), className: nameNode.text, diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-interface-dispatch/App.cs b/gitnexus/test/fixtures/lang-resolution/csharp-interface-dispatch/App.cs new file mode 100644 index 000000000..da22577ec --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-dispatch/App.cs @@ -0,0 +1,7 @@ +public class App { + public static void Main() { + IRepository repo = new SqlRepository(); + repo.Find(1); + repo.Save("test"); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-interface-dispatch/IRepository.cs b/gitnexus/test/fixtures/lang-resolution/csharp-interface-dispatch/IRepository.cs new file mode 100644 index 000000000..8fa557421 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-dispatch/IRepository.cs @@ -0,0 +1,4 @@ +public interface IRepository { + string Find(int id); + bool Save(string entity); +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-interface-dispatch/SqlRepository.cs b/gitnexus/test/fixtures/lang-resolution/csharp-interface-dispatch/SqlRepository.cs new file mode 100644 index 000000000..4062ca725 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-interface-dispatch/SqlRepository.cs @@ -0,0 +1,9 @@ +public class SqlRepository : IRepository { + public string Find(int id) { + return "found"; + } + + public bool Save(string entity) { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/dart-interface-dispatch/app.dart b/gitnexus/test/fixtures/lang-resolution/dart-interface-dispatch/app.dart new file mode 100644 index 000000000..edec34da6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-interface-dispatch/app.dart @@ -0,0 +1,7 @@ +import 'sql_repository.dart'; + +void main() { + final repo = SqlRepository(); + repo.find(1); + repo.save("test"); +} diff --git a/gitnexus/test/fixtures/lang-resolution/dart-interface-dispatch/repository.dart b/gitnexus/test/fixtures/lang-resolution/dart-interface-dispatch/repository.dart new file mode 100644 index 000000000..c5edd9557 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-interface-dispatch/repository.dart @@ -0,0 +1,4 @@ +abstract class Repository { + String find(int id); + bool save(String entity); +} diff --git a/gitnexus/test/fixtures/lang-resolution/dart-interface-dispatch/sql_repository.dart b/gitnexus/test/fixtures/lang-resolution/dart-interface-dispatch/sql_repository.dart new file mode 100644 index 000000000..68fd26642 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-interface-dispatch/sql_repository.dart @@ -0,0 +1,13 @@ +import 'repository.dart'; + +class SqlRepository implements Repository { + @override + String find(int id) { + return "found"; + } + + @override + bool save(String entity) { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-interface-dispatch/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-interface-dispatch/App.kt new file mode 100644 index 000000000..588a9c461 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-interface-dispatch/App.kt @@ -0,0 +1,5 @@ +fun main() { + val repo: Repository = SqlRepository() + repo.find(1) + repo.save("test") +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-interface-dispatch/Repository.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-interface-dispatch/Repository.kt new file mode 100644 index 000000000..02be9c8e6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-interface-dispatch/Repository.kt @@ -0,0 +1,4 @@ +interface Repository { + fun find(id: Int): String + fun save(entity: String): Boolean +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-interface-dispatch/SqlRepository.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-interface-dispatch/SqlRepository.kt new file mode 100644 index 000000000..d44fd09bd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-interface-dispatch/SqlRepository.kt @@ -0,0 +1,9 @@ +class SqlRepository : Repository { + override fun find(id: Int): String { + return "found" + } + + override fun save(entity: String): Boolean { + return true + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-interface-dispatch/app.ts b/gitnexus/test/fixtures/lang-resolution/typescript-interface-dispatch/app.ts new file mode 100644 index 000000000..9cfb446c0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-interface-dispatch/app.ts @@ -0,0 +1,5 @@ +import { SqlRepository } from './sql-repository'; + +const repo = new SqlRepository(); +repo.find(1); +repo.save("test"); diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-interface-dispatch/repository.ts b/gitnexus/test/fixtures/lang-resolution/typescript-interface-dispatch/repository.ts new file mode 100644 index 000000000..a86ca660a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-interface-dispatch/repository.ts @@ -0,0 +1,4 @@ +export interface IRepository { + find(id: number): string; + save(entity: string): boolean; +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-interface-dispatch/sql-repository.ts b/gitnexus/test/fixtures/lang-resolution/typescript-interface-dispatch/sql-repository.ts new file mode 100644 index 000000000..b623e6e9c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-interface-dispatch/sql-repository.ts @@ -0,0 +1,11 @@ +import { IRepository } from './repository'; + +export class SqlRepository implements IRepository { + find(id: number): string { + return "found"; + } + + save(entity: string): boolean { + return true; + } +} diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index a363ab502..03cd1d9d9 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -1722,3 +1722,48 @@ describe('C# method enrichment', () => { expect(classifyCall).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// Interface dispatch: METHOD_IMPLEMENTS edges +// --------------------------------------------------------------------------- + +describe('C# interface dispatch (METHOD_IMPLEMENTS)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-interface-dispatch'), () => {}); + }, 60000); + + it('detects IRepository interface and SqlRepository class', () => { + const classes = getNodesByLabel(result, 'Class'); + const ifaces = getNodesByLabel(result, 'Interface'); + expect(classes).toContain('SqlRepository'); + expect(ifaces).toContain('IRepository'); + }); + + it('emits IMPLEMENTS edge SqlRepository → IRepository', () => { + const impl = getRelationships(result, 'IMPLEMENTS'); + const edge = impl.find((e) => e.source === 'SqlRepository' && e.target === 'IRepository'); + expect(edge).toBeDefined(); + }); + + it('emits METHOD_IMPLEMENTS edges for Find and Save', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const findEdge = mi.find( + (e) => + e.source === 'Find' && + e.target === 'Find' && + e.sourceFilePath.includes('SqlRepository') && + e.targetFilePath.includes('IRepository'), + ); + const saveEdge = mi.find( + (e) => + e.source === 'Save' && + e.target === 'Save' && + e.sourceFilePath.includes('SqlRepository') && + e.targetFilePath.includes('IRepository'), + ); + expect(findEdge).toBeDefined(); + expect(saveEdge).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/dart.test.ts b/gitnexus/test/integration/resolvers/dart.test.ts index 8839e49f6..7f995e09d 100644 --- a/gitnexus/test/integration/resolvers/dart.test.ts +++ b/gitnexus/test/integration/resolvers/dart.test.ts @@ -429,3 +429,48 @@ describe.skipIf(!dartAvailable)('Dart async method detection', () => { expect(formatName!.properties.returnType).toBe('String'); }); }); + +// --------------------------------------------------------------------------- +// Interface dispatch: METHOD_IMPLEMENTS edges from concrete → abstract methods +// abstract Repository with find/save, SqlRepository implements them +// --------------------------------------------------------------------------- + +describe.skipIf(!dartAvailable)('Dart interface dispatch (METHOD_IMPLEMENTS)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-interface-dispatch'), () => {}); + }, 60000); + + it('detects Repository class and SqlRepository class', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Repository'); + expect(classes).toContain('SqlRepository'); + }); + + it('emits IMPLEMENTS edge SqlRepository → Repository', () => { + const impl = getRelationships(result, 'IMPLEMENTS'); + const edge = impl.find((e) => e.source === 'SqlRepository' && e.target === 'Repository'); + expect(edge).toBeDefined(); + }); + + it('emits METHOD_IMPLEMENTS edges for find and save', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const findEdge = mi.find( + (e) => + e.source === 'find' && + e.target === 'find' && + e.sourceFilePath.includes('sql_repository') && + e.targetFilePath.includes('repository'), + ); + const saveEdge = mi.find( + (e) => + e.source === 'save' && + e.target === 'save' && + e.sourceFilePath.includes('sql_repository') && + e.targetFilePath.includes('repository'), + ); + expect(findEdge).toBeDefined(); + expect(saveEdge).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/kotlin.test.ts b/gitnexus/test/integration/resolvers/kotlin.test.ts index 5a57731a7..f91fc9eaa 100644 --- a/gitnexus/test/integration/resolvers/kotlin.test.ts +++ b/gitnexus/test/integration/resolvers/kotlin.test.ts @@ -1811,3 +1811,49 @@ describe('Kotlin method enrichment', () => { expect(classifyCall).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// Interface dispatch: METHOD_IMPLEMENTS edges from concrete → interface methods +// Repository interface with find/save, SqlRepository implements them +// --------------------------------------------------------------------------- + +describe('Kotlin interface dispatch (METHOD_IMPLEMENTS)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'kotlin-interface-dispatch'), () => {}); + }, 60000); + + it('detects Repository interface and SqlRepository class', () => { + const classes = getNodesByLabel(result, 'Class'); + const ifaces = getNodesByLabel(result, 'Interface'); + expect(classes).toContain('SqlRepository'); + expect(ifaces).toContain('Repository'); + }); + + it('emits IMPLEMENTS edge SqlRepository → Repository', () => { + const impl = getRelationships(result, 'IMPLEMENTS'); + const edge = impl.find((e) => e.source === 'SqlRepository' && e.target === 'Repository'); + expect(edge).toBeDefined(); + }); + + it('emits METHOD_IMPLEMENTS edges for find and save', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const findEdge = mi.find( + (e) => + e.source === 'find' && + e.target === 'find' && + e.sourceFilePath.includes('SqlRepository') && + e.targetFilePath.includes('Repository'), + ); + const saveEdge = mi.find( + (e) => + e.source === 'save' && + e.target === 'save' && + e.sourceFilePath.includes('SqlRepository') && + e.targetFilePath.includes('Repository'), + ); + expect(findEdge).toBeDefined(); + expect(saveEdge).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index 8d663d1cf..a59538dde 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -2395,3 +2395,51 @@ describe('TypeScript method enrichment', () => { expect(classifyCall).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// Interface dispatch: METHOD_IMPLEMENTS edges +// --------------------------------------------------------------------------- + +describe('TypeScript interface dispatch (METHOD_IMPLEMENTS)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-interface-dispatch'), + () => {}, + ); + }, 60000); + + it('detects IRepository interface and SqlRepository class', () => { + const classes = getNodesByLabel(result, 'Class'); + const ifaces = getNodesByLabel(result, 'Interface'); + expect(classes).toContain('SqlRepository'); + expect(ifaces).toContain('IRepository'); + }); + + it('emits IMPLEMENTS edge SqlRepository → IRepository', () => { + const impl = getRelationships(result, 'IMPLEMENTS'); + const edge = impl.find((e) => e.source === 'SqlRepository' && e.target === 'IRepository'); + expect(edge).toBeDefined(); + }); + + it('emits METHOD_IMPLEMENTS edges for find and save', () => { + const mi = getRelationships(result, 'METHOD_IMPLEMENTS'); + const findEdge = mi.find( + (e) => + e.source === 'find' && + e.target === 'find' && + e.sourceFilePath.includes('sql-repository') && + e.targetFilePath.includes('repository'), + ); + const saveEdge = mi.find( + (e) => + e.source === 'save' && + e.target === 'save' && + e.sourceFilePath.includes('sql-repository') && + e.targetFilePath.includes('repository'), + ); + expect(findEdge).toBeDefined(); + expect(saveEdge).toBeDefined(); + }); +});