refactor(ingestion): remove legacy heritage paths, wire extractor end-to-end

- heritage-processor.ts: Replace inline captureMap heritage checks
  (extends/implements/trait-impl with Go named field skip) with
  provider.heritageExtractor.extract() + resolveAndAddHeritageEdge helper
  that bridges HeritageInfo to graph edge resolution.

- parse-worker.ts: Pre-pass heritage extraction for buildTypeEnv now uses
  provider.heritageExtractor.extract() instead of inline captureMap checks
  with hardcoded Go field_declaration skip. Remove heritage case from
  callRouter dispatch (extractFromCall handles it first).

- call-processor.ts: Pre-pass heritage extraction for buildTypeEnv now
  uses provider.heritageExtractor.extract(). Add extractFromCall check
  before callRouter dispatch (mirrors parse-worker pattern). Remove
  heritage case from callRouter switch.

- call-routing.ts: Remove heritage routing from routeRubyCall (was
  include/extend/prepend → {kind:'heritage'}). Remove RubyHeritageItem
  interface and heritage variant from RubyCallRouting union. Router now
  returns 'skip' for these calls since heritageExtractor handles them.

- language-provider.ts: Update heritageExtractor doc comment to clarify
  all providers MUST supply it (no inline fallback exists).

- Tests: Update call-routing ruby tests for new skip behavior. Add
  provider wiring test ensuring all 15 tree-sitter providers have
  heritageExtractor defined.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/0ee9bca0-06b1-4398-a2df-1d13ec743d2f
This commit is contained in:
copilot-swe-agent[bot] 2026-04-17 05:54:09 +00:00 committed by GitHub
parent e30f1a7fab
commit ee03d78be4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 220 additions and 370 deletions

View file

@ -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<string, string[]>();
for (const match of matches) {
const captureMap: Record<string, any> = {};
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<string, any> = {};
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<string, readonly string[]> = 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);

View file

@ -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 ───

View file

@ -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<string, any> = {};
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);
}
});

View file

@ -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).

View file

@ -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<string, string[]>();
for (const match of matches) {
const captureMap: Record<string, SyntaxNode> = {};
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<string, SyntaxNode> = {};
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<string, readonly string[]> = 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'],

View file

@ -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' });
});
});

View file

@ -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();
}
});
});