diff --git a/gitnexus/test/unit/heritage-extraction.test.ts b/gitnexus/test/unit/heritage-extraction.test.ts new file mode 100644 index 000000000..77ed930a1 --- /dev/null +++ b/gitnexus/test/unit/heritage-extraction.test.ts @@ -0,0 +1,455 @@ +import { describe, it, expect } from 'vitest'; +import { createHeritageExtractor } from '../../src/core/ingestion/heritage-extractors/generic.js'; +import { + javaHeritageConfig, + kotlinHeritageConfig, +} from '../../src/core/ingestion/heritage-extractors/configs/jvm.js'; +import { csharpHeritageConfig } from '../../src/core/ingestion/heritage-extractors/configs/csharp.js'; +import { + typescriptHeritageConfig, + javascriptHeritageConfig, +} from '../../src/core/ingestion/heritage-extractors/configs/typescript-javascript.js'; +import { + cHeritageConfig, + cppHeritageConfig, +} from '../../src/core/ingestion/heritage-extractors/configs/c-cpp.js'; +import { pythonHeritageConfig } from '../../src/core/ingestion/heritage-extractors/configs/python.js'; +import { rubyHeritageConfig } from '../../src/core/ingestion/heritage-extractors/configs/ruby.js'; +import { rustHeritageConfig } from '../../src/core/ingestion/heritage-extractors/configs/rust.js'; +import { dartHeritageConfig } from '../../src/core/ingestion/heritage-extractors/configs/dart.js'; +import { goHeritageConfig } from '../../src/core/ingestion/heritage-extractors/configs/go.js'; +import { phpHeritageConfig } from '../../src/core/ingestion/heritage-extractors/configs/php.js'; +import { swiftHeritageConfig } from '../../src/core/ingestion/heritage-extractors/configs/swift.js'; +import type { + HeritageExtractionConfig, + HeritageExtractorContext, +} from '../../src/core/ingestion/heritage-types.js'; +import type { CaptureMap } from '../../src/core/ingestion/language-provider.js'; +import { SupportedLanguages } from 'gitnexus-shared'; + +// --------------------------------------------------------------------------- +// Mock AST node helpers +// --------------------------------------------------------------------------- + +interface MockNode { + type: string; + text: string; + parent?: MockNode | null; + children?: MockNode[]; + childForFieldName?: (name: string) => MockNode | undefined; +} + +/** Create a minimal mock SyntaxNode for capture map entries. */ +function makeNode(text: string, type = 'identifier', parent?: MockNode | null): MockNode { + return { type, text, parent: parent ?? null }; +} + +/** Create a mock field_declaration node (Go struct fields). */ +function makeGoFieldDecl(opts: { hasName: boolean; typeName: string }): MockNode { + const nameNode = opts.hasName ? makeNode('MyField', 'field_identifier') : undefined; + const fieldDecl: MockNode = { + type: 'field_declaration', + text: '', + childForFieldName: (name: string) => (name === 'name' ? nameNode : undefined), + }; + const typeNode = makeNode(opts.typeName, 'type_identifier', fieldDecl); + return typeNode; +} + +/** Build a CaptureMap from partial entries. */ +function buildCaptureMap(entries: Record): CaptureMap { + return entries as unknown as CaptureMap; +} + +/** Default context for testing. */ +function ctx( + filePath = 'Test.java', + language = SupportedLanguages.Java, +): HeritageExtractorContext { + return { filePath, language }; +} + +// --------------------------------------------------------------------------- +// Factory construction +// --------------------------------------------------------------------------- + +describe('createHeritageExtractor', () => { + it('creates an extractor from a minimal config', () => { + const extractor = createHeritageExtractor(javaHeritageConfig); + expect(extractor).toBeDefined(); + expect(extractor.language).toBe(SupportedLanguages.Java); + expect(typeof extractor.extract).toBe('function'); + }); + + it('accepts all currently registered language configs', () => { + const configs: HeritageExtractionConfig[] = [ + javaHeritageConfig, + kotlinHeritageConfig, + csharpHeritageConfig, + typescriptHeritageConfig, + javascriptHeritageConfig, + cppHeritageConfig, + cHeritageConfig, + pythonHeritageConfig, + rubyHeritageConfig, + rustHeritageConfig, + dartHeritageConfig, + goHeritageConfig, + phpHeritageConfig, + swiftHeritageConfig, + ]; + for (const cfg of configs) { + expect( + () => createHeritageExtractor(cfg), + `config for ${cfg.language} must construct cleanly`, + ).not.toThrow(); + } + }); + + it('sets extractFromCall when callBasedHeritage is configured', () => { + const extractor = createHeritageExtractor(rubyHeritageConfig); + expect(extractor.extractFromCall).toBeDefined(); + expect(typeof extractor.extractFromCall).toBe('function'); + }); + + it('does not set extractFromCall when callBasedHeritage is absent', () => { + const extractor = createHeritageExtractor(javaHeritageConfig); + expect(extractor.extractFromCall).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Generic extraction from captures +// --------------------------------------------------------------------------- + +describe('HeritageExtractor.extract', () => { + const extractor = createHeritageExtractor(javaHeritageConfig); + + it('returns empty array when heritage.class is not present', () => { + const captures = buildCaptureMap({}); + expect(extractor.extract(captures, ctx())).toEqual([]); + }); + + it('returns empty array when heritage.class is present but no extends/implements/trait', () => { + const captures = buildCaptureMap({ + 'heritage.class': makeNode('MyClass'), + }); + expect(extractor.extract(captures, ctx())).toEqual([]); + }); + + it('extracts extends heritage', () => { + const captures = buildCaptureMap({ + 'heritage.class': makeNode('Child'), + 'heritage.extends': makeNode('Parent'), + }); + const result = extractor.extract(captures, ctx()); + expect(result).toEqual([ + { className: 'Child', parentName: 'Parent', kind: 'extends' }, + ]); + }); + + it('extracts implements heritage', () => { + const captures = buildCaptureMap({ + 'heritage.class': makeNode('MyClass'), + 'heritage.implements': makeNode('MyInterface'), + }); + const result = extractor.extract(captures, ctx()); + expect(result).toEqual([ + { className: 'MyClass', parentName: 'MyInterface', kind: 'implements' }, + ]); + }); + + it('extracts trait-impl heritage', () => { + const rustExtractor = createHeritageExtractor(rustHeritageConfig); + const captures = buildCaptureMap({ + 'heritage.class': makeNode('MyStruct'), + 'heritage.trait': makeNode('Display'), + }); + const result = rustExtractor.extract(captures, ctx('main.rs', SupportedLanguages.Rust)); + expect(result).toEqual([ + { className: 'MyStruct', parentName: 'Display', kind: 'trait-impl' }, + ]); + }); + + it('extracts both extends and implements from same match', () => { + const captures = buildCaptureMap({ + 'heritage.class': makeNode('MyClass'), + 'heritage.extends': makeNode('BaseClass'), + 'heritage.implements': makeNode('ISerializable'), + }); + const result = extractor.extract(captures, ctx()); + expect(result).toEqual([ + { className: 'MyClass', parentName: 'BaseClass', kind: 'extends' }, + { className: 'MyClass', parentName: 'ISerializable', kind: 'implements' }, + ]); + }); + + it('extracts all three heritage kinds from same match', () => { + const captures = buildCaptureMap({ + 'heritage.class': makeNode('MyStruct'), + 'heritage.extends': makeNode('Base'), + 'heritage.implements': makeNode('IFace'), + 'heritage.trait': makeNode('Trait'), + }); + const result = extractor.extract(captures, ctx()); + expect(result).toEqual([ + { className: 'MyStruct', parentName: 'Base', kind: 'extends' }, + { className: 'MyStruct', parentName: 'IFace', kind: 'implements' }, + { className: 'MyStruct', parentName: 'Trait', kind: 'trait-impl' }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Go: shouldSkipExtends (named field detection) +// --------------------------------------------------------------------------- + +describe('Go HeritageExtractor — shouldSkipExtends', () => { + const extractor = createHeritageExtractor(goHeritageConfig); + + it('extracts anonymous struct embedding (no field name)', () => { + const typeNode = makeGoFieldDecl({ hasName: false, typeName: 'Animal' }); + const captures = buildCaptureMap({ + 'heritage.class': makeNode('Dog'), + 'heritage.extends': typeNode as unknown as CaptureMap[string], + }); + const result = extractor.extract(captures, ctx('main.go', SupportedLanguages.Go)); + expect(result).toEqual([ + { className: 'Dog', parentName: 'Animal', kind: 'extends' }, + ]); + }); + + it('skips named struct fields (Breed string)', () => { + const typeNode = makeGoFieldDecl({ hasName: true, typeName: 'string' }); + const captures = buildCaptureMap({ + 'heritage.class': makeNode('Dog'), + 'heritage.extends': typeNode as unknown as CaptureMap[string], + }); + const result = extractor.extract(captures, ctx('main.go', SupportedLanguages.Go)); + expect(result).toEqual([]); + }); + + it('extracts heritage when extends node has no parent', () => { + const orphanNode = makeNode('Embedded', 'type_identifier'); + orphanNode.parent = null; + const captures = buildCaptureMap({ + 'heritage.class': makeNode('Foo'), + 'heritage.extends': orphanNode as unknown as CaptureMap[string], + }); + const result = extractor.extract(captures, ctx('main.go', SupportedLanguages.Go)); + expect(result).toEqual([ + { className: 'Foo', parentName: 'Embedded', kind: 'extends' }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Ruby: call-based heritage (include/extend/prepend) +// --------------------------------------------------------------------------- + +describe('Ruby HeritageExtractor — call-based heritage', () => { + const extractor = createHeritageExtractor(rubyHeritageConfig); + + /** Build a mock call node for include/extend/prepend. */ + function makeCallNode( + argNodes: MockNode[], + enclosingType: 'class' | 'module' | null, + enclosingName: string | null, + ): MockNode { + const argList: MockNode = { + type: 'argument_list', + text: '', + children: argNodes, + }; + const classNameNode = enclosingName ? makeNode(enclosingName, 'constant') : undefined; + const enclosingNode: MockNode | null = + enclosingType && enclosingName + ? { + type: enclosingType, + text: '', + parent: null, + childForFieldName: (name: string) => + name === 'name' ? classNameNode : undefined, + } + : null; + + const bodyNode: MockNode = { + type: 'body_statement', + text: '', + parent: enclosingNode, + }; + if (enclosingNode) enclosingNode.children = [bodyNode]; + + const callNode: MockNode = { + type: 'call', + text: '', + parent: bodyNode, + childForFieldName: (name: string) => + name === 'arguments' ? argList : undefined, + }; + return callNode; + } + + function makeConstantArg(name: string): MockNode { + return makeNode(name, 'constant'); + } + + function makeScopeResolutionArg(name: string): MockNode { + return makeNode(name, 'scope_resolution'); + } + + const rubyCtx = ctx('app.rb', SupportedLanguages.Ruby); + + it('returns null for non-heritage call names', () => { + const callNode = makeCallNode([makeConstantArg('Foo')], 'class', 'Bar'); + expect(extractor.extractFromCall!('puts', callNode as any, rubyCtx)).toBeNull(); + }); + + it('extracts include heritage with single constant arg', () => { + const callNode = makeCallNode([makeConstantArg('Serializable')], 'class', 'User'); + const result = extractor.extractFromCall!('include', callNode as any, rubyCtx); + expect(result).toEqual([ + { className: 'User', parentName: 'Serializable', kind: 'include' }, + ]); + }); + + it('extracts extend heritage with scope_resolution arg', () => { + const callNode = makeCallNode( + [makeScopeResolutionArg('ActiveSupport::Concern')], + 'class', + 'Post', + ); + const result = extractor.extractFromCall!('extend', callNode as any, rubyCtx); + expect(result).toEqual([ + { className: 'Post', parentName: 'ActiveSupport::Concern', kind: 'extend' }, + ]); + }); + + it('extracts prepend heritage', () => { + const callNode = makeCallNode([makeConstantArg('Instrumented')], 'class', 'Service'); + const result = extractor.extractFromCall!('prepend', callNode as any, rubyCtx); + expect(result).toEqual([ + { className: 'Service', parentName: 'Instrumented', kind: 'prepend' }, + ]); + }); + + it('extracts include inside a module', () => { + const callNode = makeCallNode([makeConstantArg('Helpers')], 'module', 'AppHelper'); + const result = extractor.extractFromCall!('include', callNode as any, rubyCtx); + expect(result).toEqual([ + { className: 'AppHelper', parentName: 'Helpers', kind: 'include' }, + ]); + }); + + it('extracts multiple constant args as separate heritage items', () => { + const args = [makeConstantArg('Mod1'), makeConstantArg('Mod2'), makeConstantArg('Mod3')]; + const callNode = makeCallNode(args, 'class', 'MyClass'); + const result = extractor.extractFromCall!('include', callNode as any, rubyCtx); + expect(result).toEqual([ + { className: 'MyClass', parentName: 'Mod1', kind: 'include' }, + { className: 'MyClass', parentName: 'Mod2', kind: 'include' }, + { className: 'MyClass', parentName: 'Mod3', kind: 'include' }, + ]); + }); + + it('returns empty array when no enclosing class/module', () => { + const argList: MockNode = { + type: 'argument_list', + text: '', + children: [makeConstantArg('Foo')], + }; + const callNode: MockNode = { + type: 'call', + text: '', + parent: null, + childForFieldName: (name: string) => + name === 'arguments' ? argList : undefined, + }; + const result = extractor.extractFromCall!('include', callNode as any, rubyCtx); + expect(result).toEqual([]); + }); + + it('skips non-constant/non-scope_resolution args', () => { + const args = [ + makeConstantArg('Mod1'), + makeNode('some_var', 'identifier'), // not constant or scope_resolution + makeConstantArg('Mod2'), + ]; + const callNode = makeCallNode(args, 'class', 'MyClass'); + const result = extractor.extractFromCall!('include', callNode as any, rubyCtx); + expect(result).toEqual([ + { className: 'MyClass', parentName: 'Mod1', kind: 'include' }, + { className: 'MyClass', parentName: 'Mod2', kind: 'include' }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Language-specific config validation +// --------------------------------------------------------------------------- + +describe('HeritageExtraction language configs', () => { + it('Java config has correct language', () => { + expect(javaHeritageConfig.language).toBe(SupportedLanguages.Java); + expect(javaHeritageConfig.shouldSkipExtends).toBeUndefined(); + expect(javaHeritageConfig.callBasedHeritage).toBeUndefined(); + }); + + it('Kotlin config has correct language', () => { + expect(kotlinHeritageConfig.language).toBe(SupportedLanguages.Kotlin); + }); + + it('Go config has shouldSkipExtends hook', () => { + expect(goHeritageConfig.language).toBe(SupportedLanguages.Go); + expect(goHeritageConfig.shouldSkipExtends).toBeDefined(); + expect(typeof goHeritageConfig.shouldSkipExtends).toBe('function'); + }); + + it('Ruby config has callBasedHeritage', () => { + expect(rubyHeritageConfig.language).toBe(SupportedLanguages.Ruby); + expect(rubyHeritageConfig.callBasedHeritage).toBeDefined(); + expect(rubyHeritageConfig.callBasedHeritage!.callNames).toEqual( + new Set(['include', 'extend', 'prepend']), + ); + }); + + it('Swift config has correct language', () => { + expect(swiftHeritageConfig.language).toBe(SupportedLanguages.Swift); + }); + + it('Rust config has correct language', () => { + expect(rustHeritageConfig.language).toBe(SupportedLanguages.Rust); + }); + + it('TypeScript config has correct language', () => { + expect(typescriptHeritageConfig.language).toBe(SupportedLanguages.TypeScript); + }); + + it('JavaScript config has correct language', () => { + expect(javascriptHeritageConfig.language).toBe(SupportedLanguages.JavaScript); + }); + + it('C config has correct language', () => { + expect(cHeritageConfig.language).toBe(SupportedLanguages.C); + }); + + it('C++ config has correct language', () => { + expect(cppHeritageConfig.language).toBe(SupportedLanguages.CPlusPlus); + }); + + it('Python config has correct language', () => { + expect(pythonHeritageConfig.language).toBe(SupportedLanguages.Python); + }); + + it('PHP config has correct language', () => { + expect(phpHeritageConfig.language).toBe(SupportedLanguages.PHP); + }); + + it('Dart config has correct language', () => { + expect(dartHeritageConfig.language).toBe(SupportedLanguages.Dart); + }); + + it('C# config has correct language', () => { + expect(csharpHeritageConfig.language).toBe(SupportedLanguages.CSharp); + }); +});