mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-13 23:14:20 +00:00
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:
parent
e30f1a7fab
commit
ee03d78be4
7 changed files with 220 additions and 370 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ───
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue