diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 6c9191970..70d9412c2 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -766,22 +766,26 @@ export const processCalls = async ( // Extract heritage from query matches to build parentMap for buildTypeEnv. // Heritage-processor runs in PARALLEL, so graph edges don't exist when buildTypeEnv runs. const fileParentMap = new Map(); - for (const match of matches) { - const captureMap: Record = {}; - match.captures.forEach((c) => (captureMap[c.name] = c.node)); - if (captureMap['heritage.class'] && captureMap['heritage.extends']) { - const className: string = captureMap['heritage.class'].text; - const parentName: string = captureMap['heritage.extends'].text; - const extendsNode = captureMap['heritage.extends']; - const fieldDecl = extendsNode.parent; - if (fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName('name')) - continue; - let parents = fileParentMap.get(className); - if (!parents) { - parents = []; - fileParentMap.set(className, parents); + if (provider.heritageExtractor) { + for (const match of matches) { + const captureMap: Record = {}; + match.captures.forEach((c) => (captureMap[c.name] = c.node)); + if (captureMap['heritage.class']) { + const heritageItems = provider.heritageExtractor.extract(captureMap, { + filePath: file.path, + language, + }); + for (const item of heritageItems) { + if (item.kind === 'extends') { + let parents = fileParentMap.get(item.className); + if (!parents) { + parents = []; + fileParentMap.set(item.className, parents); + } + if (!parents.includes(item.parentName)) parents.push(item.parentName); + } + } } - if (!parents.includes(parentName)) parents.push(parentName); } } const parentMap: ReadonlyMap = fileParentMap; @@ -991,6 +995,28 @@ export const processCalls = async ( const calledName = nameNode.text; + // Check heritage extractor for call-based heritage (e.g., Ruby include/extend/prepend) + if (provider.heritageExtractor?.extractFromCall) { + const heritageItems = provider.heritageExtractor.extractFromCall( + calledName, + captureMap['call'], + { filePath: file.path, language }, + ); + if (heritageItems !== null) { + for (const item of heritageItems) { + collectedHeritage.push({ + filePath: file.path, + className: item.className, + parentName: item.parentName, + kind: item.kind, + }); + } + return; + } + } + + // Dispatch: route language-specific calls (properties, imports) + // Heritage routing is handled by heritageExtractor.extractFromCall above. const routed = callRouter?.(calledName, captureMap['call']); if (routed) { switch (routed.kind) { @@ -998,17 +1024,6 @@ export const processCalls = async ( case 'import': return; - case 'heritage': - for (const item of routed.items) { - collectedHeritage.push({ - filePath: file.path, - className: item.enclosingClass, - parentName: item.mixinName, - kind: item.heritageKind, - }); - } - return; - case 'properties': { const fileId = generateId('File', file.path); const propEnclosingClassId = findEnclosingClassId(captureMap['call'], file.path); diff --git a/gitnexus/src/core/ingestion/call-routing.ts b/gitnexus/src/core/ingestion/call-routing.ts index 3922c0c89..f9e60df10 100644 --- a/gitnexus/src/core/ingestion/call-routing.ts +++ b/gitnexus/src/core/ingestion/call-routing.ts @@ -1,10 +1,14 @@ /** * Shared Ruby call routing logic. * - * Ruby expresses imports, heritage (mixins), and property definitions as - * method calls rather than syntax-level constructs. This module provides a - * routing function used by the CLI call-processor, CLI parse-worker, and - * the web call-processor so that the classification logic lives in one place. + * Ruby expresses imports and property definitions as method calls rather + * than syntax-level constructs. This module provides a routing function + * used by the CLI call-processor, CLI parse-worker, and the web + * call-processor so that the classification logic lives in one place. + * + * Heritage (mixins: include/extend/prepend) was previously routed here + * but is now handled by heritageExtractor.extractFromCall before the + * call router runs. The router still returns 'skip' for these calls. * * NOTE: This file is intentionally duplicated in gitnexus-web/ because the * two packages have separate build targets (Node native vs WASM/browser). @@ -30,17 +34,10 @@ export type CallRouter = (calledName: string, callNode: SyntaxNode) => CallRouti export type RubyCallRouting = | { kind: 'import'; importPath: string; isRelative: boolean } - | { kind: 'heritage'; items: RubyHeritageItem[] } | { kind: 'properties'; items: RubyPropertyItem[] } | { kind: 'call' } | { kind: 'skip' }; -export interface RubyHeritageItem { - enclosingClass: string; - mixinName: string; - heritageKind: 'include' | 'extend' | 'prepend'; -} - export type RubyAccessorType = 'attr_accessor' | 'attr_reader' | 'attr_writer'; export interface RubyPropertyItem { @@ -56,9 +53,6 @@ export interface RubyPropertyItem { const CALL_RESULT: RubyCallRouting = { kind: 'call' }; const SKIP_RESULT: RubyCallRouting = { kind: 'skip' }; -/** Max depth for parent-walking loops to prevent pathological AST traversals */ -const MAX_PARENT_DEPTH = 50; - // ── Routing function ──────────────────────────────────────────────────────── /** @@ -88,35 +82,12 @@ export function routeRubyCall(calledName: string, callNode: SyntaxNode): RubyCal return { kind: 'import', importPath, isRelative }; } - // ── include / extend / prepend → heritage (mixin) ────────────────────── + // ── include / extend / prepend — heritage (now handled by heritageExtractor) ─ + // Call-based heritage is intercepted by heritageExtractor.extractFromCall + // before the call router runs. Return SKIP_RESULT so these calls don't + // fall through to normal call processing. if (calledName === 'include' || calledName === 'extend' || calledName === 'prepend') { - let enclosingClass: string | null = null; - let current = callNode.parent; - let depth = 0; - while (current && ++depth <= MAX_PARENT_DEPTH) { - if (current.type === 'class' || current.type === 'module') { - const nameNode = current.childForFieldName?.('name'); - if (nameNode) { - enclosingClass = nameNode.text; - break; - } - } - current = current.parent; - } - if (!enclosingClass) return SKIP_RESULT; - - const items: RubyHeritageItem[] = []; - const argList = callNode.childForFieldName?.('arguments'); - for (const arg of argList?.children ?? []) { - if (arg.type === 'constant' || arg.type === 'scope_resolution') { - items.push({ - enclosingClass, - mixinName: arg.text, - heritageKind: calledName as 'include' | 'extend' | 'prepend', - }); - } - } - return items.length > 0 ? { kind: 'heritage', items } : SKIP_RESULT; + return SKIP_RESULT; } // ── attr_accessor / attr_reader / attr_writer → property definitions ─── diff --git a/gitnexus/src/core/ingestion/heritage-processor.ts b/gitnexus/src/core/ingestion/heritage-processor.ts index d3921a6ec..a2e2ca2ac 100644 --- a/gitnexus/src/core/ingestion/heritage-processor.ts +++ b/gitnexus/src/core/ingestion/heritage-processor.ts @@ -32,6 +32,7 @@ import type { import { resolveExtendsType } from './model/heritage-map.js'; import type { ResolutionContext } from './model/resolution-context.js'; import { TIER_CONFIDENCE } from './model/resolution-context.js'; +import type { HeritageInfo } from './heritage-types.js'; /** * Derive the heritage-resolution strategy for a language from its @@ -83,6 +84,93 @@ const resolveHeritageId = ( }; }; +/** + * Resolve a single HeritageInfo to a graph edge, using the same resolution + * logic as processHeritageFromExtracted. This bridges the heritage extractor + * output format to the graph-resolution side. + */ +const resolveAndAddHeritageEdge = ( + graph: KnowledgeGraph, + item: HeritageInfo, + filePath: string, + language: SupportedLanguages, + ctx: ResolutionContext, +): void => { + if (item.kind === 'extends') { + const { type: relType, idPrefix } = resolveExtendsType( + item.parentName, + filePath, + ctx, + getHeritageStrategyForLanguage(language), + ); + + const child = resolveHeritageId( + item.className, + filePath, + ctx, + 'Class', + `${filePath}:${item.className}`, + ); + const parent = resolveHeritageId(item.parentName, filePath, ctx, idPrefix); + + if (child.id && parent.id && child.id !== parent.id) { + graph.addRelationship({ + id: generateId(relType, `${child.id}->${parent.id}`), + sourceId: child.id, + targetId: parent.id, + type: relType, + confidence: Math.sqrt(child.confidence * parent.confidence), + reason: '', + }); + } + } else if (item.kind === 'implements') { + const cls = resolveHeritageId( + item.className, + filePath, + ctx, + 'Class', + `${filePath}:${item.className}`, + ); + const iface = resolveHeritageId(item.parentName, filePath, ctx, 'Interface'); + + if (cls.id && iface.id) { + graph.addRelationship({ + id: generateId('IMPLEMENTS', `${cls.id}->${iface.id}`), + sourceId: cls.id, + targetId: iface.id, + type: 'IMPLEMENTS', + confidence: Math.sqrt(cls.confidence * iface.confidence), + reason: '', + }); + } + } else if ( + item.kind === 'trait-impl' || + item.kind === 'include' || + item.kind === 'extend' || + item.kind === 'prepend' + ) { + const strct = resolveHeritageId( + item.className, + filePath, + ctx, + 'Struct', + `${filePath}:${item.className}`, + ); + const trait = resolveHeritageId(item.parentName, filePath, ctx, 'Trait'); + + if (strct.id && trait.id) { + graph.addRelationship({ + id: generateId('IMPLEMENTS', `${strct.id}->${trait.id}:${item.kind}`), + sourceId: strct.id, + targetId: trait.id, + type: 'IMPLEMENTS', + confidence: Math.sqrt(strct.confidence * trait.confidence), + reason: item.kind, + }); + } + } +}; + export const processHeritage = async ( graph: KnowledgeGraph, files: { path: string; content: string }[], @@ -143,104 +231,24 @@ export const processHeritage = async ( continue; } - // 4. Process heritage matches + // 4. Process heritage matches via provider heritage extractor + const heritageExtractor = provider.heritageExtractor; matches.forEach((match) => { const captureMap: Record = {}; match.captures.forEach((c) => { captureMap[c.name] = c.node; }); - // EXTENDS or IMPLEMENTS: resolve via symbol table for languages where - // the tree-sitter query can't distinguish classes from interfaces (C#, Java) - if (captureMap['heritage.class'] && captureMap['heritage.extends']) { - // Go struct embedding: skip named fields (only anonymous fields are embedded) - const extendsNode = captureMap['heritage.extends']; - const fieldDecl = extendsNode.parent; - if (fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName('name')) { - return; // Named field, not struct embedding - } + if (!captureMap['heritage.class']) return; + if (!heritageExtractor) return; - const className = captureMap['heritage.class'].text; - const parentClassName = captureMap['heritage.extends'].text; + const heritageItems = heritageExtractor.extract(captureMap, { + filePath: file.path, + language, + }); - const { type: relType, idPrefix } = resolveExtendsType( - parentClassName, - file.path, - ctx, - getHeritageStrategyForLanguage(language), - ); - - const child = resolveHeritageId( - className, - file.path, - ctx, - 'Class', - `${file.path}:${className}`, - ); - const parent = resolveHeritageId(parentClassName, file.path, ctx, idPrefix); - - if (child.id && parent.id && child.id !== parent.id) { - graph.addRelationship({ - id: generateId(relType, `${child.id}->${parent.id}`), - sourceId: child.id, - targetId: parent.id, - type: relType, - confidence: Math.sqrt(child.confidence * parent.confidence), - reason: '', - }); - } - } - - // IMPLEMENTS: Class implements Interface (TypeScript only) - if (captureMap['heritage.class'] && captureMap['heritage.implements']) { - const className = captureMap['heritage.class'].text; - const interfaceName = captureMap['heritage.implements'].text; - - const cls = resolveHeritageId( - className, - file.path, - ctx, - 'Class', - `${file.path}:${className}`, - ); - const iface = resolveHeritageId(interfaceName, file.path, ctx, 'Interface'); - - if (cls.id && iface.id) { - graph.addRelationship({ - id: generateId('IMPLEMENTS', `${cls.id}->${iface.id}`), - sourceId: cls.id, - targetId: iface.id, - type: 'IMPLEMENTS', - confidence: Math.sqrt(cls.confidence * iface.confidence), - reason: '', - }); - } - } - - // IMPLEMENTS (Rust): impl Trait for Struct - if (captureMap['heritage.trait'] && captureMap['heritage.class']) { - const structName = captureMap['heritage.class'].text; - const traitName = captureMap['heritage.trait'].text; - - const strct = resolveHeritageId( - structName, - file.path, - ctx, - 'Struct', - `${file.path}:${structName}`, - ); - const trait = resolveHeritageId(traitName, file.path, ctx, 'Trait'); - - if (strct.id && trait.id) { - graph.addRelationship({ - id: generateId('IMPLEMENTS', `${strct.id}->${trait.id}`), - sourceId: strct.id, - targetId: trait.id, - type: 'IMPLEMENTS', - confidence: Math.sqrt(strct.confidence * trait.confidence), - reason: 'trait-impl', - }); - } + for (const item of heritageItems) { + resolveAndAddHeritageEdge(graph, item, file.path, language, ctx); } }); diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 5b6479111..5bcdc0543 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -183,7 +183,8 @@ interface LanguageProviderConfig { /** Heritage extractor for extracting extends/implements/trait-impl relationships * from tree-sitter @heritage.* captures and call-based heritage (e.g., Ruby * include/extend/prepend). Produced by createHeritageExtractor() with a - * per-language HeritageExtractionConfig. Default: undefined (inline fallback). */ + * per-language HeritageExtractionConfig. + * All tree-sitter providers MUST supply this. */ readonly heritageExtractor?: HeritageExtractor; /** Extract a semantic description for a definition node (e.g., PHP Eloquent * property arrays, relation method descriptions). diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 513969b15..203e027fa 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -1400,33 +1400,36 @@ const processFileGroup = ( // Heritage edges (EXTENDS/IMPLEMENTS) are created by heritage-processor which runs // in PARALLEL with call-processor, so the graph edges don't exist when buildTypeEnv // runs. This pre-pass makes parent class information available for type resolution. + const provider = getProvider(language); const fileParentMap = new Map(); - for (const match of matches) { - const captureMap: Record = {}; - for (const c of match.captures) { - captureMap[c.name] = c.node; - } - if (captureMap['heritage.class'] && captureMap['heritage.extends']) { - const className: string = captureMap['heritage.class'].text; - const parentName: string = captureMap['heritage.extends'].text; - // Skip Go named fields (only anonymous fields are struct embedding) - const extendsNode = captureMap['heritage.extends']; - const fieldDecl = extendsNode.parent; - if (fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName('name')) - continue; - let parents = fileParentMap.get(className); - if (!parents) { - parents = []; - fileParentMap.set(className, parents); + if (provider.heritageExtractor) { + for (const match of matches) { + const captureMap: Record = {}; + for (const c of match.captures) { + captureMap[c.name] = c.node; + } + if (captureMap['heritage.class']) { + const heritageItems = provider.heritageExtractor.extract(captureMap, { + filePath: file.path, + language, + }); + for (const item of heritageItems) { + if (item.kind === 'extends') { + let parents = fileParentMap.get(item.className); + if (!parents) { + parents = []; + fileParentMap.set(item.className, parents); + } + if (!parents.includes(item.parentName)) parents.push(item.parentName); + } + } } - if (!parents.includes(parentName)) parents.push(parentName); } } // Build per-file type environment + constructor bindings in a single AST walk. // Constructor bindings are verified against the SymbolTable in processCallsFromExtracted. const parentMap: ReadonlyMap = fileParentMap; - const provider = getProvider(language); const typeEnv = buildTypeEnv(tree, language, { parentMap, enclosingFunctionFinder: provider?.enclosingFunctionFinder, @@ -1719,7 +1722,8 @@ const processFileGroup = ( } } - // Dispatch: route language-specific calls (heritage, properties, imports) + // Dispatch: route language-specific calls (properties, imports) + // Heritage routing is handled by heritageExtractor.extractFromCall above. const routed = callRouter?.(calledName, captureMap['call']); if (routed) { if (routed.kind === 'skip') continue; @@ -1733,18 +1737,6 @@ const processFileGroup = ( continue; } - if (routed.kind === 'heritage') { - for (const item of routed.items) { - result.heritage.push({ - filePath: file.path, - className: item.enclosingClass, - parentName: item.mixinName, - kind: item.heritageKind, - }); - } - continue; - } - if (routed.kind === 'properties') { const propEnclosingInfo = cachedFindEnclosingClassInfo( captureMap['call'], diff --git a/gitnexus/test/unit/call-routing/ruby.test.ts b/gitnexus/test/unit/call-routing/ruby.test.ts index e5e6858b3..1c602f3aa 100644 --- a/gitnexus/test/unit/call-routing/ruby.test.ts +++ b/gitnexus/test/unit/call-routing/ruby.test.ts @@ -251,197 +251,29 @@ describe('routeRubyCall — require / require_relative', () => { }); // ── include / extend / prepend ─────────────────────────────────────────────── +// Heritage routing (include/extend/prepend) is now handled by +// heritageExtractor.extractFromCall before the call router runs. +// routeRubyCall returns 'skip' so these calls don't fall through +// to normal call processing. -describe('routeRubyCall — include / extend / prepend', () => { - it('include with a single constant arg inside a class returns heritage', () => { +describe('routeRubyCall — include / extend / prepend (now delegated to heritageExtractor)', () => { + it('include returns skip (heritage handled by heritageExtractor)', () => { const node = makeHeritageCallNode([makeConstantArg('Serializable')], 'class', 'User'); - const result = routeRubyCall('include', node); - - expect(result).toEqual({ - kind: 'heritage', - items: [{ enclosingClass: 'User', mixinName: 'Serializable', heritageKind: 'include' }], - }); + expect(routeRubyCall('include', node)).toEqual({ kind: 'skip' }); }); - it('extend with a scope_resolution arg (Foo::Bar) returns heritage', () => { + it('extend returns skip (heritage handled by heritageExtractor)', () => { const node = makeHeritageCallNode( [makeScopeResolutionArg('ActiveSupport::Concern')], 'class', 'Post', ); - const result = routeRubyCall('extend', node); - - expect(result).toEqual({ - kind: 'heritage', - items: [ - { enclosingClass: 'Post', mixinName: 'ActiveSupport::Concern', heritageKind: 'extend' }, - ], - }); + expect(routeRubyCall('extend', node)).toEqual({ kind: 'skip' }); }); - it('prepend records heritageKind as "prepend"', () => { + it('prepend returns skip (heritage handled by heritageExtractor)', () => { const node = makeHeritageCallNode([makeConstantArg('Instrumented')], 'class', 'Service'); - const result = routeRubyCall('prepend', node); - - expect(result).toEqual({ - kind: 'heritage', - items: [{ enclosingClass: 'Service', mixinName: 'Instrumented', heritageKind: 'prepend' }], - }); - }); - - it('include inside a module (not a class) still resolves enclosing name', () => { - const node = makeHeritageCallNode([makeConstantArg('Helpers')], 'module', 'ApplicationHelper'); - const result = routeRubyCall('include', node); - - expect(result).toEqual({ - kind: 'heritage', - items: [ - { enclosingClass: 'ApplicationHelper', mixinName: 'Helpers', heritageKind: 'include' }, - ], - }); - }); - - it('include with multiple constant args produces one item per arg', () => { - const args = [makeConstantArg('Mod1'), makeConstantArg('Mod2'), makeConstantArg('Mod3')]; - const node = makeHeritageCallNode(args, 'class', 'MyClass'); - const result = routeRubyCall('include', node); - - expect(result).toEqual({ - kind: 'heritage', - items: [ - { enclosingClass: 'MyClass', mixinName: 'Mod1', heritageKind: 'include' }, - { enclosingClass: 'MyClass', mixinName: 'Mod2', heritageKind: 'include' }, - { enclosingClass: 'MyClass', mixinName: 'Mod3', heritageKind: 'include' }, - ], - }); - }); - - it('returns skip when no enclosing class or module is found in parent chain', () => { - const node = makeHeritageCallNode([makeConstantArg('Mod')], null, null); - expect(routeRubyCall('include', node)).toEqual({ kind: 'skip' }); - }); - - it('returns skip when enclosing class node has no name child', () => { - // nameNode is undefined — childForFieldName('name') returns undefined - const argList: MockNode = { - type: 'argument_list', - text: '', - children: [makeConstantArg('Mod')], - }; - const classNode: MockNode = { - type: 'class', - text: '', - parent: null, - childForFieldName: (_name: string) => undefined, - }; - const bodyNode: MockNode = { type: 'body', text: '', parent: classNode }; - const callNode: MockNode = { - type: 'call', - text: '', - parent: bodyNode, - childForFieldName: (name: string) => (name === 'arguments' ? argList : undefined), - }; - expect(routeRubyCall('include', callNode)).toEqual({ kind: 'skip' }); - }); - - it('returns skip when arg list contains only non-constant/non-scope_resolution args', () => { - const node = makeHeritageCallNode([makeIdentifierArg('some_var')], 'class', 'Foo'); - expect(routeRubyCall('include', node)).toEqual({ kind: 'skip' }); - }); - - it('returns skip when arg list is empty', () => { - const node = makeHeritageCallNode([], 'class', 'Foo'); - expect(routeRubyCall('include', node)).toEqual({ kind: 'skip' }); - }); - - it('walks nested scopes to find the nearest enclosing class', () => { - // callNode is 5 levels deep inside a class body - const node = makeHeritageCallNode([makeConstantArg('DeepMixin')], 'class', 'DeepClass', 5); - const result = routeRubyCall('include', node); - - expect(result).toEqual({ - kind: 'heritage', - items: [{ enclosingClass: 'DeepClass', mixinName: 'DeepMixin', heritageKind: 'include' }], - }); - }); - - it('returns skip when parent depth exceeds MAX_PARENT_DEPTH (50)', () => { - // Build a chain of 51 intermediate nodes with no class/module in it - const argList: MockNode = { - type: 'argument_list', - text: '', - children: [makeConstantArg('Mod')], - }; - const callNode: MockNode = { - type: 'call', - text: '', - parent: null, - childForFieldName: (name: string) => (name === 'arguments' ? argList : undefined), - }; - - let current: MockNode = callNode; - // Create 51 parents — all plain body nodes, never a class/module - for (let i = 0; i < 51; i++) { - const parent: MockNode = { type: 'body_statement', text: '', parent: null }; - current.parent = parent; - current = parent; - } - - expect(routeRubyCall('include', callNode)).toEqual({ kind: 'skip' }); - }); - - it('finds class at exactly depth 50 (boundary — must succeed)', () => { - // 49 plain wrappers, then the class at depth 50 - const argList: MockNode = { - type: 'argument_list', - text: '', - children: [makeConstantArg('BoundaryMixin')], - }; - const callNode: MockNode = { - type: 'call', - text: '', - parent: null, - childForFieldName: (name: string) => (name === 'arguments' ? argList : undefined), - }; - - let leaf: MockNode = callNode; - for (let i = 0; i < 49; i++) { - const wrapper: MockNode = { type: 'body_statement', text: '', parent: null }; - leaf.parent = wrapper; - leaf = wrapper; - } - - const nameNode: MockNode = { type: 'constant', text: 'BoundaryClass' }; - const classNode: MockNode = { - type: 'class', - text: '', - parent: null, - childForFieldName: (name: string) => (name === 'name' ? nameNode : undefined), - }; - leaf.parent = classNode; - - const result = routeRubyCall('include', callNode); - expect(result).toEqual({ - kind: 'heritage', - items: [ - { enclosingClass: 'BoundaryClass', mixinName: 'BoundaryMixin', heritageKind: 'include' }, - ], - }); - }); - - it('skips non-constant args mixed with constant args, collecting only constants', () => { - const args = [ - makeIdentifierArg('local_var'), - makeConstantArg('ValidMixin'), - makeIdentifierArg('another_var'), - ]; - const node = makeHeritageCallNode(args, 'class', 'Foo'); - const result = routeRubyCall('include', node); - - expect(result).toEqual({ - kind: 'heritage', - items: [{ enclosingClass: 'Foo', mixinName: 'ValidMixin', heritageKind: 'include' }], - }); + expect(routeRubyCall('prepend', node)).toEqual({ kind: 'skip' }); }); }); diff --git a/gitnexus/test/unit/heritage-extraction.test.ts b/gitnexus/test/unit/heritage-extraction.test.ts index b432e383c..08908c3f2 100644 --- a/gitnexus/test/unit/heritage-extraction.test.ts +++ b/gitnexus/test/unit/heritage-extraction.test.ts @@ -26,6 +26,7 @@ import type { } from '../../src/core/ingestion/heritage-types.js'; import type { CaptureMap } from '../../src/core/ingestion/language-provider.js'; import { SupportedLanguages } from 'gitnexus-shared'; +import { getProvider } from '../../src/core/ingestion/languages/index.js'; // --------------------------------------------------------------------------- // Mock AST node helpers @@ -433,3 +434,33 @@ describe('HeritageExtraction language configs', () => { expect(csharpHeritageConfig.language).toBe(SupportedLanguages.CSharp); }); }); + +// --------------------------------------------------------------------------- +// Provider wiring — every tree-sitter provider MUST have heritageExtractor +// --------------------------------------------------------------------------- + +describe('heritageExtractor on LanguageProvider', () => { + it('all tree-sitter providers have heritageExtractor defined', () => { + const languages: SupportedLanguages[] = [ + SupportedLanguages.TypeScript, + SupportedLanguages.JavaScript, + SupportedLanguages.Python, + SupportedLanguages.Java, + SupportedLanguages.Kotlin, + SupportedLanguages.Go, + SupportedLanguages.Rust, + SupportedLanguages.CSharp, + SupportedLanguages.C, + SupportedLanguages.CPlusPlus, + SupportedLanguages.PHP, + SupportedLanguages.Ruby, + SupportedLanguages.Swift, + SupportedLanguages.Dart, + SupportedLanguages.Vue, + ]; + for (const lang of languages) { + const provider = getProvider(lang); + expect(provider.heritageExtractor, `${lang} should have a heritageExtractor`).toBeDefined(); + } + }); +});