Merge remote-tracking branch 'upstream/main' into codex/fix-large-graph-streaming

This commit is contained in:
gfwangjie 2026-04-09 11:27:53 +08:00
commit e7254ddece
86 changed files with 4189 additions and 241 deletions

View file

@ -30,7 +30,8 @@ import {
} from './utils/call-analysis.js';
import { buildTypeEnv, isSubclassOf } from './type-env.js';
import type { ConstructorBinding, TypeEnvironment } from './type-env.js';
import { resolveExtendsType } from './heritage-processor.js';
import type { HeritageMap } from './heritage-map.js';
import { c3Linearize } from './mro-processor.js';
import { getTreeSitterBufferSize } from './constants.js';
import type {
ExtractedCall,
@ -51,6 +52,9 @@ import { extractParsedCallSite } from './call-sites/extract-language-call-site.j
* Populated during call processing, consumed by Phase 14 re-resolution pass. */
export type ExportedTypeMap = Map<string, Map<string, string>>;
/** Types that represent class-like declarations (used for receiver/owner resolution). */
const CLASS_LIKE_TYPES = new Set(['Class', 'Struct', 'Interface', 'Enum', 'Record', 'Impl']);
const MAX_EXPORTS_PER_FILE = 500;
const MAX_TYPE_NAME_LENGTH = 256;
@ -512,65 +516,6 @@ interface ResolveResult {
returnType?: string;
}
/** Maps interface/abstract-class name → set of file paths of direct implementors. */
export type ImplementorMap = ReadonlyMap<string, ReadonlySet<string>>;
/**
* Build an ImplementorMap from extracted heritage data.
* Only direct `implements` relationships are tracked (transitive not needed for
* the common Java/Kotlin/C# interface dispatch pattern).
* `extends` is ignored dispatch keyed on abstract class bases is not modeled here.
*/
/**
* Maps interface name file paths of classes that implement it (direct only).
* When `ctx` is set, `kind: 'extends'` rows are classified like heritage-processor
* (C#/Java base_list: class vs interface parents share one capture name).
*/
export const buildImplementorMap = (
heritage: readonly ExtractedHeritage[],
ctx?: ResolutionContext,
): Map<string, Set<string>> => {
const map = new Map<string, Set<string>>();
for (const h of heritage) {
let record = false;
if (h.kind === 'implements') {
record = true;
} else if (h.kind === 'extends' && ctx) {
const lang = getLanguageFromFilename(h.filePath);
if (lang) {
const { type } = resolveExtendsType(h.parentName, h.filePath, ctx, lang);
record = type === 'IMPLEMENTS';
}
}
if (record) {
let files = map.get(h.parentName);
if (!files) {
files = new Set();
map.set(h.parentName, files);
}
files.add(h.filePath);
}
}
return map;
};
/**
* Merge a chunk's implementor map into the global accumulator.
*/
export const mergeImplementorMaps = (
target: Map<string, Set<string>>,
source: ReadonlyMap<string, ReadonlySet<string>>,
): void => {
for (const [name, files] of source) {
let existing = target.get(name);
if (!existing) {
existing = new Set();
target.set(name, existing);
}
for (const f of files) existing.add(f);
}
};
/**
* After resolving a call to an interface method, find additional targets
* in classes implementing that interface. Returns implementation method
@ -581,11 +526,11 @@ function findInterfaceDispatchTargets(
receiverTypeName: string,
currentFile: string,
ctx: ResolutionContext,
implementorMap: ImplementorMap,
heritageMap: HeritageMap,
primaryNodeId: string,
): ResolveResult[] {
const implFiles = implementorMap.get(receiverTypeName);
if (!implFiles || implFiles.size === 0) return [];
const implFiles = heritageMap.getImplementorFiles(receiverTypeName);
if (implFiles.size === 0) return [];
const typeResolved = ctx.resolve(receiverTypeName, currentFile);
if (!typeResolved) return [];
@ -621,7 +566,7 @@ export const processCalls = async (
importedReturnTypesMap?: ReadonlyMap<string, ReadonlyMap<string, string>>,
/** Phase 14 E3: cross-file RAW return types for for-loop element extraction. Keyed by filePath → Map<calleeName, rawReturnType>. */
importedRawReturnTypesMap?: ReadonlyMap<string, ReadonlyMap<string, string>>,
implementorMap?: ImplementorMap,
heritageMap?: HeritageMap,
): Promise<ExtractedHeritage[]> => {
const parser = await loadParser();
const collectedHeritage: ExtractedHeritage[] = [];
@ -784,17 +729,7 @@ export const processCalls = async (
}
if (!receiverTypeName && receiverText) {
const resolved = ctx.resolve(receiverText, file.path);
if (
resolved?.candidates.some(
(d) =>
d.type === 'Class' ||
d.type === 'Struct' ||
d.type === 'Interface' ||
d.type === 'Enum' ||
d.type === 'Record' ||
d.type === 'Impl',
)
) {
if (resolved?.candidates.some((d) => CLASS_LIKE_TYPES.has(d.type))) {
receiverTypeName = receiverText;
}
}
@ -852,6 +787,8 @@ export const processCalls = async (
ctx,
undefined,
widenCache,
undefined,
heritageMap,
);
if (!resolved) return;
@ -864,13 +801,13 @@ export const processCalls = async (
reason: resolved.reason,
});
if (implementorMap && languageSeed.callForm === 'member' && receiverTypeName) {
if (heritageMap && languageSeed.callForm === 'member' && receiverTypeName) {
const implTargets = findInterfaceDispatchTargets(
languageSeed.calledName,
receiverTypeName,
file.path,
ctx,
implementorMap,
heritageMap,
resolved.nodeId,
);
for (const impl of implTargets) {
@ -1000,12 +937,8 @@ export const processCalls = async (
if (
isSubclassOf(ctorType, receiverTypeName, parentMap) ||
isSubclassOf(ctorType, receiverTypeName, globalParentMap) ||
(ctx.symbols
.lookupFuzzy(ctorType)
.some((d) => d.type === 'Class' || d.type === 'Struct') &&
ctx.symbols
.lookupFuzzy(receiverTypeName)
.some((d) => d.type === 'Class' || d.type === 'Struct' || d.type === 'Interface'))
(ctx.symbols.lookupClassByName(ctorType).length > 0 &&
ctx.symbols.lookupClassByName(receiverTypeName).length > 0)
) {
receiverTypeName = ctorType;
}
@ -1076,6 +1009,7 @@ export const processCalls = async (
file.path,
ctx,
makeAccessEmitter(graph, sourceId),
heritageMap,
);
}
}
@ -1101,6 +1035,8 @@ export const processCalls = async (
ctx,
hints,
widenCache,
undefined,
heritageMap,
);
if (!resolved) return;
@ -1115,13 +1051,13 @@ export const processCalls = async (
reason: resolved.reason,
});
if (implementorMap && callForm === 'member' && receiverTypeName) {
if (heritageMap && callForm === 'member' && receiverTypeName) {
const implTargets = findInterfaceDispatchTargets(
calledName,
receiverTypeName,
file.path,
ctx,
implementorMap,
heritageMap,
resolved.nodeId,
);
for (const impl of implTargets) {
@ -1353,6 +1289,7 @@ const resolveCallTarget = (
overloadHints?: OverloadHints,
widenCache?: WidenCache,
preComputedArgTypes?: (string | undefined)[],
heritageMap?: HeritageMap,
): ResolveResult | null => {
const tiered = ctx.resolve(call.calledName, currentFile);
if (!tiered) return null;
@ -1428,6 +1365,35 @@ const resolveCallTarget = (
// belong to the wrong class (e.g. super.save() should hit the parent's save,
// not the child's own save method in the same file).
if (call.callForm === 'member' && call.receiverTypeName) {
// D0. MRO fast path: when heritageMap is available, try owner-scoped + MRO
// lookup before falling back to the expensive D2 fuzzy widening.
// This short-circuits the lookupFuzzy call for every cross-file member call.
// Skip conditions:
// (a) overloadHints or preComputedArgTypes present — the MRO lookup may
// pick the wrong overload for same-return-type overloads since it
// does not consider argument types. D2-D4+E handles those correctly.
// (b) A module alias on call.receiverName is active for this file — the
// alias block above already narrowed `filteredCandidates` to a
// specific file (e.g. Python `import auth; auth.user.save()`).
// resolveMethodByOwner re-resolves `receiverTypeName` from scratch
// via `ctx.resolve`, which ignores that narrowing and could pick a
// homonymous class from the wrong file. Fall through to D1-D4 which
// respects the alias-filtered candidate pool.
const hasActiveModuleAlias =
!!call.receiverName && ctx.moduleAliasMap?.get(currentFile)?.has(call.receiverName) === true;
if (!overloadHints && !preComputedArgTypes && !hasActiveModuleAlias) {
const mroResult = resolveMethodByOwner(
call.receiverTypeName,
call.calledName,
currentFile,
ctx,
heritageMap,
);
if (mroResult) {
return toResolveResult(mroResult, tiered.tier);
}
}
// D1. Resolve the receiver type
const typeResolved = ctx.resolve(call.receiverTypeName, currentFile);
if (typeResolved && typeResolved.candidates.length > 0) {
@ -1656,15 +1622,7 @@ const resolveFieldOwnership = (
): { nodeId: string; declaredType?: string } | undefined => {
const typeResolved = ctx.resolve(receiverName, filePath);
if (!typeResolved) return undefined;
const classDef = typeResolved.candidates.find(
(d) =>
d.type === 'Class' ||
d.type === 'Struct' ||
d.type === 'Interface' ||
d.type === 'Enum' ||
d.type === 'Record' ||
d.type === 'Impl',
);
const classDef = typeResolved.candidates.find((d) => CLASS_LIKE_TYPES.has(d.type));
if (!classDef) return undefined;
return ctx.symbols.lookupFieldByOwner(classDef.nodeId, fieldName) ?? undefined;
@ -1674,29 +1632,173 @@ const resolveFieldOwnership = (
* Resolve a method by owner type name using the eagerly-populated methodByOwner index.
* Returns the SymbolDefinition if an unambiguous method is found, undefined otherwise.
* Falls through to undefined for: unknown type, no class-like candidates, ambiguous overloads.
* When heritageMap is provided, falls back to MRO-aware parent chain walking.
*/
const resolveMethodByOwner = (
receiverTypeName: string,
methodName: string,
filePath: string,
ctx: ResolutionContext,
heritageMap?: HeritageMap,
): SymbolDefinition | undefined => {
const typeResolved = ctx.resolve(receiverTypeName, filePath);
if (!typeResolved) return undefined;
const classDef = typeResolved.candidates.find(
(d) =>
d.type === 'Class' ||
d.type === 'Struct' ||
d.type === 'Interface' ||
d.type === 'Enum' ||
d.type === 'Record' ||
d.type === 'Impl',
);
const classDef = typeResolved.candidates.find((d) => CLASS_LIKE_TYPES.has(d.type));
if (!classDef) return undefined;
// When HeritageMap is available, delegate to MRO-aware lookup which performs
// the direct owner lookup itself before walking ancestors — avoids a double
// direct lookup on the hot path.
if (heritageMap) {
const language = getLanguageFromFilename(filePath);
if (language) {
return lookupMethodByOwnerWithMRO(
classDef.nodeId,
methodName,
heritageMap,
ctx.symbols,
language,
);
}
}
// Fallback when no HeritageMap (or the file extension is unrecognized by
// `getLanguageFromFilename`, e.g. a synthetic path or an extension that is
// not registered in supported-languages.ts): plain direct lookup with no
// ancestor walk. All primary languages register their extensions, so this
// branch is only reached for edge cases where the MRO walk would not be
// applicable anyway. D1-D4 in resolveCallTarget still runs on D0 miss.
return ctx.symbols.lookupMethodByOwner(classDef.nodeId, methodName);
};
// ---------------------------------------------------------------------------
// MRO-aware method resolution via HeritageMap (SM-9)
// ---------------------------------------------------------------------------
/**
* Per-HeritageMap cache of C3 linearization results keyed by owner nodeId.
*
* HeritageMap instances are immutable after construction, so C3 output is
* stable for the lifetime of a HeritageMap. WeakMap lets the cache auto-drain
* when the HeritageMap is garbage collected (end of ingestion run), so we
* never need to manually invalidate it.
*
* `null` is a sentinel for "C3 failed for this owner" (cyclic or inconsistent
* hierarchy) so we don't re-run the expensive linearization repeatedly.
*/
const c3LinearizationCache = new WeakMap<HeritageMap, Map<string, readonly string[] | null>>();
const getCachedC3Linearization = (
ownerNodeId: string,
heritageMap: HeritageMap,
): readonly string[] | null => {
let perHmCache = c3LinearizationCache.get(heritageMap);
if (!perHmCache) {
perHmCache = new Map();
c3LinearizationCache.set(heritageMap, perHmCache);
}
const cached = perHmCache.get(ownerNodeId);
if (cached !== undefined) return cached;
const parentMap = buildParentMapFromHeritage(ownerNodeId, heritageMap);
const result = c3Linearize(ownerNodeId, parentMap, new Map()) ?? null;
perHmCache.set(ownerNodeId, result);
return result;
};
/**
* Build a parentMap from HeritageMap for use with c3Linearize.
* Traverses the parent chain starting from startNodeId, collecting all
* parentchildren relationships into a Map<string, string[]>.
*/
const buildParentMapFromHeritage = (
startNodeId: string,
heritageMap: HeritageMap,
): Map<string, string[]> => {
const parentMap = new Map<string, string[]>();
const visited = new Set<string>();
const queue = [startNodeId];
while (queue.length > 0) {
const nodeId = queue.shift()!;
if (visited.has(nodeId)) continue;
visited.add(nodeId);
const parents = heritageMap.getParents(nodeId);
if (parents.length > 0) {
parentMap.set(nodeId, parents);
for (const p of parents) {
if (!visited.has(p)) queue.push(p);
}
}
}
return parentMap;
};
/**
* Look up a method on an owner class, walking the parent chain via HeritageMap
* when the method isn't found on the direct owner.
*
* Respects the 5 per-language MRO strategies:
* - `first-wins`: BFS ancestor walk, first match wins (default)
* - `leftmost-base`: BFS ancestor walk, leftmost base in declaration order wins (C++);
* HeritageMap preserves insertion order matching source declaration,
* so BFS order is equivalent to leftmost-base semantics
* - `c3`: C3-linearized ancestor order, first match wins (Python)
* - `implements-split`: BFS ancestor walk, first match wins (Java/C#)
* full ambiguity detection for multiple interface defaults
* is handled by computeMRO at graph level
* - `qualified-syntax`: No auto-resolution (Rust) returns undefined
*
* Delegates to mro-processor.ts c3Linearize for C3 strategy.
*
* @internal Exported only to enable unit testing in isolation. The proper
* entry point for callers outside this module is {@link resolveMethodByOwner},
* which handles receiver-type resolution before delegating here.
*/
export const lookupMethodByOwnerWithMRO = (
ownerNodeId: string,
methodName: string,
heritageMap: HeritageMap,
symbols: SymbolTable,
language: SupportedLanguages,
): SymbolDefinition | undefined => {
// Direct lookup first (child override — no walk needed)
const direct = symbols.lookupMethodByOwner(ownerNodeId, methodName);
if (direct) return direct;
const strategy = getProvider(language).mroStrategy;
// Rust: requires qualified syntax (<Type as Trait>::method), no auto-resolution
if (strategy === 'qualified-syntax') return undefined;
// Determine ancestor walk order based on MRO strategy.
// readonly to accept the cached (frozen) c3 linearization without copying.
let ancestors: readonly string[];
if (strategy === 'c3') {
// Delegate to mro-processor.ts C3 linearization (memoized per HeritageMap
// so repeated calls for the same owner within an ingestion run reuse the
// linearization instead of rebuilding the parent map and re-running C3).
// c3Linearize returns ancestors only (excludes the owner itself),
// matching heritageMap.getAncestors() semantics.
const c3Result = getCachedC3Linearization(ownerNodeId, heritageMap);
// Fall back to BFS order if C3 fails (cyclic or inconsistent hierarchy).
// Note: BFS order may not preserve Python MRO semantics in these edge
// cases, but cyclic/inconsistent hierarchies are invalid in Python anyway.
ancestors = c3Result ?? heritageMap.getAncestors(ownerNodeId);
} else {
// first-wins, leftmost-base, implements-split: BFS order via HeritageMap
ancestors = heritageMap.getAncestors(ownerNodeId);
}
// Walk ancestors in MRO order — first match wins
for (const ancestorId of ancestors) {
const method = symbols.lookupMethodByOwner(ancestorId, methodName);
if (method) return method;
}
return undefined;
};
/**
* Create a deduplicated ACCESSES edge emitter for a single source node.
* Each (sourceId, fieldNodeId) pair is emitted at most once per source.
@ -1735,6 +1837,7 @@ const walkMixedChain = (
filePath: string,
ctx: ResolutionContext,
onFieldResolved?: OnFieldResolved,
heritageMap?: HeritageMap,
): string | undefined => {
let currentType: string | undefined = startType;
for (const step of chain) {
@ -1761,7 +1864,7 @@ const walkMixedChain = (
// Avoids fuzzy lookup when the owner type is known and the method is unambiguous.
// Note: CALLS edges for intermediate chain steps are NOT emitted here — walkMixedChain
// only threads types. CALLS edges come from the outer per-call-expression loop in processCalls.
const methodDef = resolveMethodByOwner(currentType, step.name, filePath, ctx);
const methodDef = resolveMethodByOwner(currentType, step.name, filePath, ctx, heritageMap);
if (methodDef?.returnType) {
const fastRetType = extractReturnTypeName(methodDef.returnType);
if (fastRetType) {
@ -1774,6 +1877,10 @@ const walkMixedChain = (
{ calledName: step.name, callForm: 'member', receiverTypeName: currentType },
filePath,
ctx,
undefined,
undefined,
undefined,
heritageMap,
);
if (!resolved) {
// Stdlib passthrough: unwrap(), clone(), etc. preserve the receiver type
@ -1806,7 +1913,7 @@ export const processCallsFromExtracted = async (
ctx: ResolutionContext,
onProgress?: (current: number, total: number) => void,
constructorBindings?: FileConstructorBindings[],
implementorMap?: ImplementorMap,
heritageMap?: HeritageMap,
) => {
// Scope-aware receiver types: keyed by filePath → "funcName\0varName" → typeName.
// The scope dimension prevents collisions when two functions in the same file
@ -1908,6 +2015,7 @@ export const processCallsFromExtracted = async (
effectiveCall.filePath,
ctx,
makeAccessEmitter(graph, effectiveCall.sourceId),
heritageMap,
);
if (walkedType) {
effectiveCall = { ...effectiveCall, receiverTypeName: walkedType };
@ -1922,6 +2030,7 @@ export const processCallsFromExtracted = async (
undefined,
widenCache,
effectiveCall.argTypes,
heritageMap,
);
if (!resolved) {
// Vue template component fallback: match calledName against imported .vue basenames
@ -1969,13 +2078,13 @@ export const processCallsFromExtracted = async (
reason: resolved.reason,
});
if (implementorMap && effectiveCall.callForm === 'member' && effectiveCall.receiverTypeName) {
if (heritageMap && effectiveCall.callForm === 'member' && effectiveCall.receiverTypeName) {
const implTargets = findInterfaceDispatchTargets(
effectiveCall.calledName,
effectiveCall.receiverTypeName,
effectiveCall.filePath,
ctx,
implementorMap,
heritageMap,
resolved.nodeId,
);
for (const impl of implTargets) {
@ -2036,17 +2145,7 @@ export const processAssignmentsFromExtracted = (
// Tier 3: static class-as-receiver fallback
if (!receiverTypeName) {
const resolved = ctx.resolve(asn.receiverText, asn.filePath);
if (
resolved?.candidates.some(
(d) =>
d.type === 'Class' ||
d.type === 'Struct' ||
d.type === 'Interface' ||
d.type === 'Enum' ||
d.type === 'Record' ||
d.type === 'Impl',
)
) {
if (resolved?.candidates.some((d) => CLASS_LIKE_TYPES.has(d.type))) {
receiverTypeName = asn.receiverText;
}
}

View file

@ -0,0 +1,177 @@
import type { SyntaxNode } from '../utils/ast-helpers.js';
import type { NodeLabel } from 'gitnexus-shared';
import type {
ClassExtractionConfig,
ClassExtractor,
ClassLikeNodeLabel,
ExtractedClassSymbol,
} from '../class-types.js';
const DEFAULT_SCOPE_NAME_NODE_TYPES = new Set([
'nested_namespace_specifier',
'scoped_identifier',
'scoped_type_identifier',
'qualified_name',
'namespace_name',
'namespace_identifier',
'package_identifier',
'type_identifier',
'identifier',
'name',
'constant',
]);
const DEFAULT_TYPE_NAME_NODE_TYPES = new Set([
'type_identifier',
'identifier',
'simple_identifier',
'namespace_identifier',
'constant',
'name',
]);
const DEFAULT_LABEL_BY_NODE_TYPE: Record<string, ClassLikeNodeLabel> = {
class_declaration: 'Class',
abstract_class_declaration: 'Class',
interface_declaration: 'Interface',
struct_declaration: 'Struct',
record_declaration: 'Record',
enum_declaration: 'Enum',
class_definition: 'Class',
struct_specifier: 'Struct',
class_specifier: 'Class',
enum_specifier: 'Enum',
struct_item: 'Struct',
enum_item: 'Enum',
class: 'Class',
object_declaration: 'Class',
companion_object: 'Class',
protocol_declaration: 'Interface',
extension_declaration: 'Class',
};
const CLASS_LIKE_LABELS = new Set<ClassLikeNodeLabel>([
'Class',
'Struct',
'Interface',
'Enum',
'Record',
]);
const normalizeQualifiedName = (value: string): string =>
value
.replace(/\s+/g, '')
.replace(/^::/, '')
.replace(/::/g, '.')
.replace(/\\/g, '.')
.replace(/\.+/g, '.')
.replace(/^\.+|\.+$/g, '');
const splitQualifiedName = (value: string): string[] => {
const normalized = normalizeQualifiedName(value);
return normalized ? normalized.split('.').filter(Boolean) : [];
};
const extractScopeSegmentsFromNode = (
scopeNode: SyntaxNode,
scopeNameNodeTypes: ReadonlySet<string>,
): string[] => {
const nameNode =
scopeNode.childForFieldName?.('name') ??
scopeNode.namedChildren?.find((child) => scopeNameNodeTypes.has(child.type));
return nameNode ? splitQualifiedName(nameNode.text) : [];
};
const extractTypeNameFromNode = (node: SyntaxNode): string | undefined => {
const nameField = node.childForFieldName?.('name');
if (nameField) return nameField.text;
const nameChild = node.namedChildren?.find((child) =>
DEFAULT_TYPE_NAME_NODE_TYPES.has(child.type),
);
return nameChild?.text;
};
const isClassLikeLabel = (label: NodeLabel | null | undefined): label is ClassLikeNodeLabel =>
label !== undefined && label !== null && CLASS_LIKE_LABELS.has(label as ClassLikeNodeLabel);
export function createClassExtractor(config: ClassExtractionConfig): ClassExtractor {
const typeDeclarationSet = new Set(config.typeDeclarationNodes);
const fileScopeSet = new Set(config.fileScopeNodeTypes ?? []);
const ancestorScopeSet = new Set(config.ancestorScopeNodeTypes ?? []);
const scopeNameNodeTypes = new Set([
...DEFAULT_SCOPE_NAME_NODE_TYPES,
...(config.scopeNameNodeTypes ?? []),
]);
const buildQualifiedName = (node: SyntaxNode, simpleName: string): string => {
let root = node;
while (root.parent) root = root.parent;
const readScopeSegments = (scopeNode: SyntaxNode): string[] =>
config.extractScopeSegments?.(scopeNode) ??
extractScopeSegmentsFromNode(scopeNode, scopeNameNodeTypes);
const fileScopeSegments: string[] = [];
for (const child of root.namedChildren ?? []) {
if (fileScopeSet.has(child.type)) {
fileScopeSegments.push(...readScopeSegments(child));
}
}
const ancestorScopes: string[][] = [];
let current = node.parent;
while (current) {
if (ancestorScopeSet.has(current.type)) {
const segments = readScopeSegments(current);
if (segments.length > 0) ancestorScopes.push(segments);
}
current = current.parent;
}
return [
...fileScopeSegments,
...ancestorScopes.reverse().flat(),
...splitQualifiedName(simpleName),
]
.filter(Boolean)
.join('.');
};
const extract = (
node: SyntaxNode,
fallback?: {
name?: string;
type?: NodeLabel | null;
},
): ExtractedClassSymbol | null => {
if (!typeDeclarationSet.has(node.type)) return null;
const name = config.extractName?.(node) ?? extractTypeNameFromNode(node) ?? fallback?.name;
const type =
config.extractType?.(node) ??
DEFAULT_LABEL_BY_NODE_TYPE[node.type] ??
(isClassLikeLabel(fallback?.type) ? fallback.type : undefined);
if (!name || !type) return null;
return {
name,
type,
qualifiedName: buildQualifiedName(node, name) || name,
};
};
return {
language: config.language,
isTypeDeclaration(node: SyntaxNode): boolean {
return typeDeclarationSet.has(node.type);
},
extract,
extractQualifiedName(node: SyntaxNode, simpleName: string): string | null {
return extract(node, { name: simpleName })?.qualifiedName ?? null;
},
};
}

View file

@ -0,0 +1,44 @@
import type { NodeLabel, SupportedLanguages } from 'gitnexus-shared';
import type { SyntaxNode } from './utils/ast-helpers.js';
export type ClassLikeNodeLabel = Extract<
NodeLabel,
'Class' | 'Struct' | 'Interface' | 'Enum' | 'Record'
>;
export interface ExtractedClassSymbol {
name: string;
type: ClassLikeNodeLabel;
qualifiedName: string;
}
/**
* Cross-language qualified type names are normalized to dot-separated scope
* segments:
* - file/package scope contributes leading segments when the language has one
* - lexical namespace/module/type scope contributes enclosing segments
* - the simple type name is always the trailing segment
*/
export interface ClassExtractor {
language: SupportedLanguages;
isTypeDeclaration(node: SyntaxNode): boolean;
extract(
node: SyntaxNode,
fallback?: {
name?: string;
type?: NodeLabel | null;
},
): ExtractedClassSymbol | null;
extractQualifiedName(node: SyntaxNode, simpleName: string): string | null;
}
export interface ClassExtractionConfig {
language: SupportedLanguages;
typeDeclarationNodes: string[];
fileScopeNodeTypes?: string[];
ancestorScopeNodeTypes?: string[];
scopeNameNodeTypes?: string[];
extractName?: (node: SyntaxNode) => string | undefined;
extractType?: (node: SyntaxNode) => ClassLikeNodeLabel | undefined;
extractScopeSegments?: (node: SyntaxNode) => string[] | null | undefined;
}

View file

@ -0,0 +1,167 @@
/**
* Heritage Map
*
* Unified inheritance data structure built from accumulated
* {@link ExtractedHeritage} records **after all chunks complete** (between
* chunk processing and call resolution). Consumes `ExtractedHeritage[]` and
* resolves type names to nodeIds via `lookupClassByName`, NOT graph-edge
* queries.
*
* Combines two previously separate concerns:
* 1. **Parent/ancestor lookup** (MRO-aware method resolution)
* 2. **Implementor lookup** (interface dispatch which files contain
* classes implementing a given interface)
*/
import type { ExtractedHeritage } from './workers/parse-worker.js';
import type { ResolutionContext } from './resolution-context.js';
import { getLanguageFromFilename } from 'gitnexus-shared';
import { resolveExtendsType } from './heritage-processor.js';
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/** Maximum ancestor chain depth to prevent runaway traversal. */
const MAX_ANCESTOR_DEPTH = 32;
export interface HeritageMap {
/** Direct parents of `childNodeId` (extends + implements + trait-impl). */
getParents(childNodeId: string): string[];
/** Full ancestor chain (BFS, bounded depth, cycle-safe). */
getAncestors(childNodeId: string): string[];
/**
* File paths of classes that directly implement or extend-as-interface the
* given interface/abstract-class **name**. Replaces the standalone
* `ImplementorMap` used by interface-dispatch in call resolution.
*/
getImplementorFiles(interfaceName: string): ReadonlySet<string>;
}
/** Shared empty set returned when no implementors are found. */
const EMPTY_SET: ReadonlySet<string> = new Set();
// ---------------------------------------------------------------------------
// Builder
// ---------------------------------------------------------------------------
/**
* Build a HeritageMap from accumulated ExtractedHeritage records.
*
* Resolves class/interface/struct/trait names to nodeIds via
* `ctx.symbols.lookupClassByName`. When a name resolves to multiple
* candidates, all are recorded (partial-class / cross-file scenario).
* Unresolvable names are silently skipped a missing parent is better
* than a wrong edge.
*
* Also builds the implementor index (interface name implementing file
* paths) that was previously maintained by `buildImplementorMap` in
* call-processor.ts.
*/
export const buildHeritageMap = (
heritage: readonly ExtractedHeritage[],
ctx: ResolutionContext,
): HeritageMap => {
// childNodeId → Set<parentNodeId> (Set to deduplicate cross-chunk duplicates)
const directParents = new Map<string, Set<string>>();
// interfaceName → Set<filePath> (implementor lookup for interface dispatch)
const implementorFiles = new Map<string, Set<string>>();
for (const h of heritage) {
// ── Parent lookup (nodeId-based) ────────────────────────────────
const childDefs = ctx.symbols.lookupClassByName(h.className);
const parentDefs = ctx.symbols.lookupClassByName(h.parentName);
if (childDefs.length > 0 && parentDefs.length > 0) {
for (const child of childDefs) {
for (const parent of parentDefs) {
// Skip self-references
if (child.nodeId === parent.nodeId) continue;
let parents = directParents.get(child.nodeId);
if (!parents) {
parents = new Set();
directParents.set(child.nodeId, parents);
}
parents.add(parent.nodeId);
}
}
}
// ── Implementor index (name-based) ──────────────────────────────
//
// Known limitation: Rust `kind: 'trait-impl'` entries are intentionally NOT
// added to the implementor index. Interface dispatch resolution currently
// does not traverse Rust trait objects, so recording them here would
// inflate the index without a consumer. Revisit if/when trait-object
// dispatch is added.
//
// Known limitation: `getImplementorFiles` is keyed by interface **name**
// (string), so two interfaces with the same unqualified name in different
// packages (e.g. `pkgA.IRepository` vs `pkgB.IRepository`) collide. This
// matches the behavior of the prior standalone `ImplementorMap` and is
// not a regression introduced by this consolidation.
let isImpl = false;
if (h.kind === 'implements') {
isImpl = true;
} else if (h.kind === 'extends') {
const lang = getLanguageFromFilename(h.filePath);
if (lang) {
const { type } = resolveExtendsType(h.parentName, h.filePath, ctx, lang);
isImpl = type === 'IMPLEMENTS';
}
}
if (isImpl) {
let files = implementorFiles.get(h.parentName);
if (!files) {
files = new Set();
implementorFiles.set(h.parentName, files);
}
files.add(h.filePath);
}
}
// --- Public API ---------------------------------------------------
const getParents = (childNodeId: string): string[] => {
const parents = directParents.get(childNodeId);
return parents ? [...parents] : [];
};
const getAncestors = (childNodeId: string): string[] => {
const result: string[] = [];
const visited = new Set<string>();
visited.add(childNodeId); // prevent cycles through the start node
// BFS with bounded depth
let frontier = getParents(childNodeId);
let depth = 0;
while (frontier.length > 0 && depth < MAX_ANCESTOR_DEPTH) {
const nextFrontier: string[] = [];
for (const parentId of frontier) {
if (visited.has(parentId)) continue;
visited.add(parentId);
result.push(parentId);
// Expand parent's own parents for next level
const grandparents = directParents.get(parentId);
if (grandparents) {
for (const gp of grandparents) {
if (!visited.has(gp)) nextFrontier.push(gp);
}
}
}
frontier = nextFrontier;
depth++;
}
return result;
};
const getImplementorFiles = (interfaceName: string): ReadonlySet<string> => {
return implementorFiles.get(interfaceName) ?? EMPTY_SET;
};
return { getParents, getAncestors, getImplementorFiles };
};

View file

@ -372,7 +372,7 @@ export const processHeritageFromExtracted = async (
/**
* Walk source files with the same heritage captures as parse-worker, producing
* {@link ExtractedHeritage} rows without mutating the graph. Used on the
* sequential pipeline path so `buildImplementorMap(..., ctx)` can run before
* sequential pipeline path so `buildHeritageMap(..., ctx)` can run before
* `processCalls` (worker path defers calls until heritage from all chunks exists).
*/
export async function extractExtractedHeritageFromFiles(

View file

@ -12,6 +12,7 @@
import type { SupportedLanguages } from 'gitnexus-shared';
import type { LanguageTypeConfig } from './type-extractors/types.js';
import type { CallRouter } from './call-routing.js';
import type { ClassExtractor } from './class-types.js';
import type { ExportChecker } from './export-detection.js';
import type { FieldExtractor } from './field-extractor.js';
import type { MethodExtractor } from './method-types.js';
@ -131,6 +132,10 @@ interface LanguageProviderConfig {
* declarations. Produces MethodInfo[] with name, parameters, visibility, isAbstract,
* isFinal, annotations metadata. Default: undefined (no method extraction). */
readonly methodExtractor?: MethodExtractor;
/** Class/type extractor for deriving canonical qualified names for class-like symbols.
* Uses the same provider-driven strategy pattern as method/field extraction so
* namespace/package/module rules stay language-specific. */
readonly classExtractor?: ClassExtractor;
/** Extract a semantic description for a definition node (e.g., PHP Eloquent
* property arrays, relation method descriptions).
* Default: undefined (no description extraction). */

View file

@ -9,6 +9,7 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { defineLanguage } from '../language-provider.js';
import { typeConfig as cCppConfig } from '../type-extractors/c-cpp.js';
import { cCppExportChecker } from '../export-detection.js';
@ -144,6 +145,17 @@ const C_BUILT_INS: ReadonlySet<string> = new Set([
'put',
]);
const cClassExtractor = createClassExtractor({
language: SupportedLanguages.C,
typeDeclarationNodes: ['struct_specifier', 'enum_specifier'],
});
const cppClassExtractor = createClassExtractor({
language: SupportedLanguages.CPlusPlus,
typeDeclarationNodes: ['class_specifier', 'struct_specifier', 'enum_specifier'],
ancestorScopeNodeTypes: ['namespace_definition', 'class_specifier', 'struct_specifier'],
});
/**
* C/C++ function name extraction unwraps pointer_declarator / reference_declarator /
* function_declarator / qualified_identifier chains to find the actual function name.
@ -315,6 +327,7 @@ export const cProvider = defineLanguage({
...cMethodConfig,
extractFunctionName: cCppExtractFunctionName,
}),
classExtractor: cClassExtractor,
labelOverride: cppLabelOverride,
builtInNames: C_BUILT_INS,
});
@ -333,6 +346,7 @@ export const cppProvider = defineLanguage({
...cppMethodConfig,
extractFunctionName: cCppExtractFunctionName,
}),
classExtractor: cppClassExtractor,
labelOverride: cppLabelOverride,
builtInNames: C_BUILT_INS,
});

View file

@ -7,6 +7,7 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { defineLanguage } from '../language-provider.js';
import { typeConfig as csharpConfig } from '../type-extractors/csharp.js';
import { csharpExportChecker } from '../export-detection.js';
@ -125,5 +126,24 @@ export const csharpProvider = defineLanguage({
mroStrategy: 'implements-split',
fieldExtractor: createFieldExtractor(csharpFieldConfig),
methodExtractor: createMethodExtractor(csharpMethodConfig),
classExtractor: createClassExtractor({
language: SupportedLanguages.CSharp,
typeDeclarationNodes: [
'class_declaration',
'interface_declaration',
'struct_declaration',
'enum_declaration',
'record_declaration',
],
fileScopeNodeTypes: ['file_scoped_namespace_declaration'],
ancestorScopeNodeTypes: [
'namespace_declaration',
'class_declaration',
'interface_declaration',
'struct_declaration',
'enum_declaration',
'record_declaration',
],
}),
builtInNames: BUILT_INS,
});

View file

@ -14,6 +14,7 @@ import type { SyntaxNode } from '../utils/ast-helpers.js';
import type { NodeLabel } from 'gitnexus-shared';
import { FUNCTION_NODE_TYPES } from '../utils/ast-helpers.js';
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { defineLanguage } from '../language-provider.js';
import { typeConfig as dartConfig } from '../type-extractors/dart.js';
import { dartExportChecker } from '../export-detection.js';
@ -92,6 +93,11 @@ export const dartProvider = defineLanguage({
importSemantics: 'wildcard',
fieldExtractor: createFieldExtractor(dartFieldConfig),
methodExtractor: createMethodExtractor(dartMethodConfig),
classExtractor: createClassExtractor({
language: SupportedLanguages.Dart,
typeDeclarationNodes: ['class_definition', 'extension_declaration', 'enum_declaration'],
ancestorScopeNodeTypes: ['class_definition', 'extension_declaration', 'enum_declaration'],
}),
enclosingFunctionFinder: dartEnclosingFunctionFinder,
builtInNames: BUILT_INS,
});

View file

@ -10,6 +10,7 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { defineLanguage } from '../language-provider.js';
import { typeConfig as goConfig } from '../type-extractors/go.js';
import { goExportChecker } from '../export-detection.js';
@ -30,4 +31,20 @@ export const goProvider = defineLanguage({
importSemantics: 'wildcard',
fieldExtractor: createFieldExtractor(goFieldConfig),
methodExtractor: createMethodExtractor(goMethodConfig),
classExtractor: createClassExtractor({
language: SupportedLanguages.Go,
typeDeclarationNodes: ['type_declaration'],
fileScopeNodeTypes: ['package_clause'],
extractName(node) {
const typeSpec = node.namedChildren.find((child) => child.type === 'type_spec');
return typeSpec?.childForFieldName('name')?.text;
},
extractType(node) {
const typeSpec = node.namedChildren.find((child) => child.type === 'type_spec');
const typeNode = typeSpec?.childForFieldName('type');
if (typeNode?.type === 'struct_type') return 'Struct';
if (typeNode?.type === 'interface_type') return 'Interface';
return undefined;
},
}),
});

View file

@ -8,6 +8,7 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { defineLanguage } from '../language-provider.js';
import { javaTypeConfig } from '../type-extractors/jvm.js';
import { javaExportChecker } from '../export-detection.js';
@ -31,4 +32,20 @@ export const javaProvider = defineLanguage({
mroStrategy: 'implements-split',
fieldExtractor: createFieldExtractor(javaConfig),
methodExtractor: createMethodExtractor(javaMethodConfig),
classExtractor: createClassExtractor({
language: SupportedLanguages.Java,
typeDeclarationNodes: [
'class_declaration',
'interface_declaration',
'enum_declaration',
'record_declaration',
],
fileScopeNodeTypes: ['package_declaration'],
ancestorScopeNodeTypes: [
'class_declaration',
'interface_declaration',
'enum_declaration',
'record_declaration',
],
}),
});

View file

@ -8,6 +8,7 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { defineLanguage } from '../language-provider.js';
import { kotlinTypeConfig } from '../type-extractors/jvm.js';
import { kotlinExportChecker } from '../export-detection.js';
@ -106,6 +107,16 @@ export const kotlinProvider = defineLanguage({
mroStrategy: 'implements-split',
fieldExtractor: createFieldExtractor(kotlinConfig),
methodExtractor: createMethodExtractor(kotlinMethodConfig),
classExtractor: createClassExtractor({
language: SupportedLanguages.Kotlin,
typeDeclarationNodes: ['class_declaration', 'object_declaration', 'companion_object'],
fileScopeNodeTypes: ['package_header'],
ancestorScopeNodeTypes: ['class_declaration', 'object_declaration', 'companion_object'],
extractType(node) {
if (node.type !== 'class_declaration') return undefined;
return node.children.some((child) => child?.text === 'interface') ? 'Interface' : 'Class';
},
}),
builtInNames: BUILT_INS,
labelOverride: (functionNode, defaultLabel) => {
if (defaultLabel !== 'Function') return defaultLabel;

View file

@ -7,6 +7,7 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { defineLanguage } from '../language-provider.js';
import { typeConfig as phpConfig } from '../type-extractors/php.js';
import { phpExportChecker } from '../export-detection.js';
@ -234,6 +235,11 @@ export const phpProvider = defineLanguage({
namedBindingExtractor: extractPhpNamedBindings,
fieldExtractor: createFieldExtractor(phpFieldConfig),
methodExtractor: createMethodExtractor(phpMethodConfig),
classExtractor: createClassExtractor({
language: SupportedLanguages.PHP,
typeDeclarationNodes: ['class_declaration', 'interface_declaration', 'enum_declaration'],
ancestorScopeNodeTypes: ['namespace_definition'],
}),
descriptionExtractor: phpDescriptionExtractor,
isRouteFile: isPhpRouteFile,
builtInNames: BUILT_INS,

View file

@ -11,6 +11,7 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { defineLanguage } from '../language-provider.js';
import { typeConfig as pythonConfig } from '../type-extractors/python.js';
import { pythonExportChecker } from '../export-detection.js';
@ -64,5 +65,10 @@ export const pythonProvider = defineLanguage({
mroStrategy: 'c3',
fieldExtractor: createFieldExtractor(pythonFieldConfig),
methodExtractor: createMethodExtractor(pythonMethodConfig),
classExtractor: createClassExtractor({
language: SupportedLanguages.Python,
typeDeclarationNodes: ['class_definition'],
ancestorScopeNodeTypes: ['class_definition'],
}),
builtInNames: BUILT_INS,
});

View file

@ -9,6 +9,7 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { NodeLabel } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { defineLanguage } from '../language-provider.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { typeConfig as rubyConfig } from '../type-extractors/ruby.js';
@ -112,5 +113,10 @@ export const rubyProvider = defineLanguage({
...rubyMethodConfig,
extractFunctionName: rubyExtractFunctionName,
}),
classExtractor: createClassExtractor({
language: SupportedLanguages.Ruby,
typeDeclarationNodes: ['class'],
ancestorScopeNodeTypes: ['module', 'class'],
}),
builtInNames: BUILT_INS,
});

View file

@ -12,6 +12,7 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { NodeLabel } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { defineLanguage } from '../language-provider.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { typeConfig as rustConfig } from '../type-extractors/rust.js';
@ -124,5 +125,10 @@ export const rustProvider = defineLanguage({
...rustMethodConfig,
extractFunctionName: rustExtractFunctionName,
}),
classExtractor: createClassExtractor({
language: SupportedLanguages.Rust,
typeDeclarationNodes: ['struct_item', 'enum_item'],
ancestorScopeNodeTypes: ['mod_item', 'struct_item', 'enum_item'],
}),
builtInNames: BUILT_INS,
});

View file

@ -12,6 +12,7 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { NodeLabel } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { defineLanguage } from '../language-provider.js';
import { typeConfig as swiftConfig } from '../type-extractors/swift.js';
import { swiftExportChecker } from '../export-detection.js';
@ -244,6 +245,18 @@ export const swiftProvider = defineLanguage({
...swiftMethodConfig,
extractFunctionName: swiftExtractFunctionName,
}),
classExtractor: createClassExtractor({
language: SupportedLanguages.Swift,
typeDeclarationNodes: ['class_declaration', 'protocol_declaration'],
ancestorScopeNodeTypes: ['class_declaration', 'protocol_declaration'],
extractType(node) {
if (node.type === 'protocol_declaration') return 'Interface';
if (node.type !== 'class_declaration') return undefined;
if (node.children.some((child) => child?.text === 'struct')) return 'Struct';
if (node.children.some((child) => child?.text === 'enum')) return 'Enum';
return 'Class';
},
}),
implicitImportWirer: wireSwiftImplicitImports,
builtInNames: BUILT_INS,
});

View file

@ -10,6 +10,8 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { NodeLabel } from 'gitnexus-shared';
import { defineLanguage } from '../language-provider.js';
import { createClassExtractor } from '../class-extractors/generic.js';
import type { ClassExtractionConfig } from '../class-types.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { typeConfig as typescriptConfig } from '../type-extractors/typescript.js';
import { tsExportChecker } from '../export-detection.js';
@ -147,6 +149,22 @@ export const BUILT_INS: ReadonlySet<string> = new Set([
'valueOf',
]);
const tsJsClassConfig: ClassExtractionConfig = {
language: SupportedLanguages.TypeScript,
typeDeclarationNodes: [
'class_declaration',
'abstract_class_declaration',
'interface_declaration',
'enum_declaration',
],
ancestorScopeNodeTypes: [
'class_declaration',
'abstract_class_declaration',
'interface_declaration',
'enum_declaration',
],
};
export const typescriptProvider = defineLanguage({
id: SupportedLanguages.TypeScript,
extensions: ['.ts', '.tsx'],
@ -160,6 +178,7 @@ export const typescriptProvider = defineLanguage({
...typescriptMethodConfig,
extractFunctionName: tsExtractFunctionName,
}),
classExtractor: createClassExtractor(tsJsClassConfig),
builtInNames: BUILT_INS,
});
@ -176,5 +195,9 @@ export const javascriptProvider = defineLanguage({
...javascriptMethodConfig,
extractFunctionName: tsExtractFunctionName,
}),
classExtractor: createClassExtractor({
...tsJsClassConfig,
language: SupportedLanguages.JavaScript,
}),
builtInNames: BUILT_INS,
});

View file

@ -12,6 +12,7 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { defineLanguage } from '../language-provider.js';
import { typeConfig as typescriptConfig } from '../type-extractors/typescript.js';
import { tsExportChecker } from '../export-detection.js';
@ -55,6 +56,22 @@ const VUE_SPECIFIC_BUILT_INS = [
const VUE_BUILT_INS: ReadonlySet<string> = new Set([...TS_BUILT_INS, ...VUE_SPECIFIC_BUILT_INS]);
const vueClassExtractor = createClassExtractor({
language: SupportedLanguages.Vue,
typeDeclarationNodes: [
'class_declaration',
'abstract_class_declaration',
'interface_declaration',
'enum_declaration',
],
ancestorScopeNodeTypes: [
'class_declaration',
'abstract_class_declaration',
'interface_declaration',
'enum_declaration',
],
});
export const vueProvider = defineLanguage({
id: SupportedLanguages.Vue,
extensions: ['.vue'],
@ -64,5 +81,6 @@ export const vueProvider = defineLanguage({
importResolver: resolveVueImport,
namedBindingExtractor: extractTsNamedBindings,
fieldExtractor: typescriptFieldExtractor,
classExtractor: vueClassExtractor,
builtInNames: VUE_BUILT_INS,
});

View file

@ -127,7 +127,7 @@ function gatherAncestors(classId: string, parentMap: Map<string, string[]>): str
* Returns an array of ancestor IDs in C3 order (excluding the class itself),
* or null if linearization fails (inconsistent or cyclic hierarchy).
*/
function c3Linearize(
export function c3Linearize(
classId: string,
parentMap: Map<string, string[]>,
cache: Map<string, string[] | null>,

View file

@ -4,7 +4,7 @@ import Parser from 'tree-sitter';
import { loadParser, loadLanguage, isLanguageAvailable } from '../tree-sitter/parser-loader.js';
import { getProvider } from './languages/index.js';
import { generateId } from '../../lib/utils.js';
import { SymbolTable } from './symbol-table.js';
import type { SymbolTable } from './symbol-table.js';
import { ASTCache } from './ast-cache.js';
import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared';
import { extractVueScript, isVueSetupTopLevel } from './vue-sfc-extractor.js';
@ -140,6 +140,7 @@ const processParsingWithWorkers = async (
returnType: sym.returnType,
declaredType: sym.declaredType,
ownerId: sym.ownerId,
qualifiedName: sym.qualifiedName,
});
}
@ -368,21 +369,29 @@ const processParsingSequential = async (
captureMap[c.name] = c.node;
});
const nodeLabel = getLabelFromCaptures(captureMap, provider);
if (!nodeLabel) return;
const definitionNodeForRange = getDefinitionNodeFromCaptures(captureMap);
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
const defaultNodeLabel = getLabelFromCaptures(captureMap, provider);
if (!defaultNodeLabel) return;
const nameNode = captureMap['name'];
const extractedClassSymbol =
definitionNode && provider.classExtractor?.isTypeDeclaration(definitionNode)
? provider.classExtractor.extract(definitionNode, {
name: nameNode?.text,
type: defaultNodeLabel,
})
: null;
const nodeLabel = extractedClassSymbol?.type ?? defaultNodeLabel;
// Synthesize name for constructors without explicit @name capture (e.g. Swift init)
if (!nameNode && nodeLabel !== 'Constructor') return;
const nodeName = nameNode ? nameNode.text : 'init';
if (!nameNode && nodeLabel !== 'Constructor' && !extractedClassSymbol) return;
const nodeName = extractedClassSymbol?.name ?? (nameNode ? nameNode.text : 'init');
const definitionNodeForRange = getDefinitionNodeFromCaptures(captureMap);
const startLine = definitionNodeForRange
? definitionNodeForRange.startPosition.row + lineOffset
: nameNode
? nameNode.startPosition.row + lineOffset
: lineOffset;
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
// Compute enclosing class BEFORE node ID — needed to qualify method IDs
const needsOwner =
@ -493,6 +502,12 @@ const processParsingSequential = async (
);
}
const nodeId = generateId(nodeLabel, `${file.path}:${qualifiedName}${arityTag}`);
const classNodeForSymbol = definitionNodeForRange || definitionNode || nameNode;
const qualifiedTypeName =
extractedClassSymbol?.qualifiedName ??
(classNodeForSymbol && provider.classExtractor?.isTypeDeclaration(classNodeForSymbol)
? (provider.classExtractor.extractQualifiedName(classNodeForSymbol, nodeName) ?? nodeName)
: undefined);
const frameworkHint = definitionNode
? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300))
: null;
@ -518,6 +533,7 @@ const processParsingSequential = async (
nameNode || definitionNodeForRange,
nodeName,
),
...(qualifiedTypeName !== undefined ? { qualifiedName: qualifiedTypeName } : {}),
...(frameworkHint
? {
astFrameworkMultiplier: frameworkHint.entryPointMultiplier,
@ -573,6 +589,7 @@ const processParsingSequential = async (
returnType: methodProps.returnType as string | undefined,
declaredType,
ownerId: enclosingClassId ?? undefined,
qualifiedName: qualifiedTypeName,
});
const fileId = generateId('File', file.path);

View file

@ -21,9 +21,8 @@ import {
buildImportedRawReturnTypes,
type ExportedTypeMap,
buildExportedTypeMapFromGraph,
buildImplementorMap,
mergeImplementorMaps,
} from './call-processor.js';
import { buildHeritageMap } from './heritage-map.js';
import { nextjsFileToRouteURL, normalizeFetchURL } from './route-extractors/nextjs.js';
import { expoFileToRouteURL } from './route-extractors/expo.js';
import { phpFileToRouteURL } from './route-extractors/php.js';
@ -949,11 +948,9 @@ async function runChunkedParseAndResolve(
// chunkContents + chunkFiles + chunkWorkerData go out of scope → GC reclaims
}
// Complete implementor map from all worker heritage, then resolve CALLS once (interface dispatch).
const fullWorkerImplementorMap =
deferredWorkerHeritage.length > 0
? buildImplementorMap(deferredWorkerHeritage, ctx)
: new Map<string, Set<string>>();
// Build unified HeritageMap (parent lookup + implementor index) after all chunks.
const fullWorkerHeritageMap =
deferredWorkerHeritage.length > 0 ? buildHeritageMap(deferredWorkerHeritage, ctx) : undefined;
if (deferredWorkerCalls.length > 0) {
await processCallsFromExtracted(
@ -974,7 +971,7 @@ async function runChunkedParseAndResolve(
});
},
deferredConstructorBindings.length > 0 ? deferredConstructorBindings : undefined,
fullWorkerImplementorMap,
fullWorkerHeritageMap,
);
}
@ -994,17 +991,38 @@ async function runChunkedParseAndResolve(
// Synthesize wildcard import bindings once after ALL imports are processed,
// before any call resolution — same rationale as the worker-path inline synthesis.
if (sequentialChunkPaths.length > 0) synthesizeWildcardImportBindings(graph, ctx);
// Merge implementor-map deltas per chunk (O(heritage per chunk)), not O(|edges|) graph scans
// per chunk — mirrors worker-path deferred heritage without re-iterating all relationships.
const sequentialImplementorMap = new Map<string, Set<string>>();
// Pass 1: Extract heritage from all sequential chunks.
// Heritage must be fully accumulated BEFORE call resolution so the HeritageMap
// has the complete ancestor chain and implementor index (same constraint as
// the worker path).
//
// File contents are read once here and cached for Pass 2 to avoid a 2× I/O
// cost on the sequential path (ASTs are intentionally NOT cached — rebuilding
// them in Pass 2 keeps peak memory bounded to one chunk at a time).
const allSequentialHeritage: ExtractedHeritage[] = [];
const cachedSequentialChunkFiles: Array<Array<{ path: string; content: string }>> = [];
for (const chunkPaths of sequentialChunkPaths) {
const chunkContents = await readFileContents(repoPath, chunkPaths);
const chunkFiles = chunkPaths
.filter((p) => chunkContents.has(p))
.map((p) => ({ path: p, content: chunkContents.get(p)! }));
cachedSequentialChunkFiles.push(chunkFiles);
astCache = createASTCache(chunkFiles.length);
const sequentialHeritage = await extractExtractedHeritageFromFiles(chunkFiles, astCache);
mergeImplementorMaps(sequentialImplementorMap, buildImplementorMap(sequentialHeritage, ctx));
// Manual loop (not spread) — `push(...arr)` blows the stack on very large
// arrays, see #650. Pay the explicit iteration cost for safety.
for (const h of sequentialHeritage) allSequentialHeritage.push(h);
astCache.clear();
}
// Build unified HeritageMap from all sequential heritage (parent lookup + implementor index).
const sequentialHeritageMap =
allSequentialHeritage.length > 0 ? buildHeritageMap(allSequentialHeritage, ctx) : undefined;
// Pass 2: Process calls, heritage edges, fetch calls, and ORM queries per chunk.
// Reuse the file contents cached in Pass 1 instead of re-reading from disk.
for (let chunkIdx = 0; chunkIdx < sequentialChunkPaths.length; chunkIdx++) {
const chunkFiles = cachedSequentialChunkFiles[chunkIdx];
astCache = createASTCache(chunkFiles.length);
const rubyHeritage = await processCalls(
graph,
chunkFiles,
@ -1015,7 +1033,7 @@ async function runChunkedParseAndResolve(
undefined,
undefined,
undefined,
sequentialImplementorMap,
sequentialHeritageMap,
);
await processHeritage(graph, chunkFiles, astCache, ctx);
if (rubyHeritage.length > 0) {
@ -1031,6 +1049,10 @@ async function runChunkedParseAndResolve(
extractORMQueriesInline(f.path, f.content, allORMQueries);
}
astCache.clear();
// Release cached chunk content as soon as Pass 2 finishes with it so the
// Pass-1 content map drains incrementally rather than being held for the
// full duration of Pass 2.
cachedSequentialChunkFiles[chunkIdx] = [];
}
// Log resolution cache stats
@ -1041,6 +1063,9 @@ async function runChunkedParseAndResolve(
console.log(
`🔍 Resolution cache: ${rcStats.cacheHits} hits, ${rcStats.cacheMisses} misses (${hitRate}% hit rate)`,
);
console.log(
`🔍 Fuzzy Lookups: ${rcStats.fuzzyCallCount} total, ${rcStats.fuzzyCallableCallCount} callable`,
);
}
// ── Worker path quality enrichment: merge TypeEnv file-scope bindings into ExportedTypeMap ──

View file

@ -70,6 +70,8 @@ export interface ResolutionContext {
getStats(): {
fileCount: number;
globalSymbolCount: number;
fuzzyCallCount: number;
fuzzyCallableCallCount: number;
cacheHits: number;
cacheMisses: number;
};

View file

@ -1,9 +1,15 @@
import type { NodeLabel } from 'gitnexus-shared';
export const CLASS_TYPES = new Set(['Class', 'Struct', 'Interface', 'Enum', 'Record']);
export interface SymbolDefinition {
nodeId: string;
filePath: string;
type: NodeLabel;
/** Canonical dot-separated qualified type name for class-like symbols
* (e.g. `App.Models.User`). Falls back to the simple symbol name when no
* package/namespace/module scope exists or no explicit qualified metadata is provided. */
qualifiedName?: string;
parameterCount?: number;
/** Number of required (non-optional, non-default) parameters.
* Enables range-based arity filtering: argCount >= requiredParameterCount && argCount <= parameterCount. */
@ -36,6 +42,7 @@ export interface SymbolTable {
returnType?: string;
declaredType?: string;
ownerId?: string;
qualifiedName?: string;
},
) => void;
@ -88,10 +95,31 @@ export interface SymbolTable {
*/
lookupMethodByOwner: (ownerNodeId: string, methodName: string) => SymbolDefinition | undefined;
/**
* Look up class-like definitions (Class, Struct, Interface, Enum, Record) by name.
* O(1) via dedicated eagerly-populated index keyed by symbol name.
* Returns all matching definitions across files (e.g. partial classes).
* Used by Phase 1 semantic-model tasks to replace filtered lookupFuzzy calls.
*/
lookupClassByName: (name: string) => SymbolDefinition[];
/**
* Look up class-like definitions by canonical qualified name.
* Qualified names are normalized to dot-separated scope segments across languages,
* e.g. `App.Models.User`, `com.example.User`, or `Admin.User`.
* Top-level class-like symbols with no explicit scope are indexed under their simple name.
*/
lookupClassByQualifiedName: (qualifiedName: string) => SymbolDefinition[];
/**
* Debugging: See how many symbols are tracked
*/
getStats: () => { fileCount: number; globalSymbolCount: number };
getStats: () => {
fileCount: number;
globalSymbolCount: number;
fuzzyCallCount: number;
fuzzyCallableCallCount: number;
};
/**
* Cleanup memory
@ -122,6 +150,15 @@ export const createSymbolTable = (): SymbolTable => {
// Method symbols with ownerId are indexed. Supports overloads (array values).
const methodByOwner = new Map<string, SymbolDefinition[]>();
// 6. Eagerly-populated Class-type Index — keyed by symbol name.
// Only Class, Struct, Interface, Enum, Record symbols are indexed.
const classByName = new Map<string, SymbolDefinition[]>();
const classByQualifiedName = new Map<string, SymbolDefinition[]>();
let fuzzyCallCount = 0;
let fuzzyCallableCallCount = 0;
const CALLABLE_TYPES = new Set(['Function', 'Method', 'Constructor']);
const add = (
@ -136,12 +173,17 @@ export const createSymbolTable = (): SymbolTable => {
returnType?: string;
declaredType?: string;
ownerId?: string;
qualifiedName?: string;
},
) => {
const qualifiedName = CLASS_TYPES.has(type)
? (metadata?.qualifiedName ?? name)
: metadata?.qualifiedName;
const def: SymbolDefinition = {
nodeId,
filePath,
type,
...(qualifiedName !== undefined ? { qualifiedName } : {}),
...(metadata?.parameterCount !== undefined
? { parameterCount: metadata.parameterCount }
: {}),
@ -183,8 +225,9 @@ export const createSymbolTable = (): SymbolTable => {
}
globalIndex.get(name)!.push(def);
// C2. Methods with ownerId go to methodByOwner index (in addition to globalIndex).
if (type === 'Method' && metadata?.ownerId) {
// C2. Methods and constructors with ownerId go to methodByOwner index
// (in addition to globalIndex).
if ((type === 'Method' || type === 'Constructor') && metadata?.ownerId) {
const key = `${metadata.ownerId}\0${name}`;
const existing = methodByOwner.get(key);
if (existing) {
@ -194,6 +237,24 @@ export const createSymbolTable = (): SymbolTable => {
}
}
// C3. Class-like types go to classByName index (in addition to globalIndex).
if (CLASS_TYPES.has(type)) {
const existing = classByName.get(name);
if (existing) {
existing.push(def);
} else {
classByName.set(name, [def]);
}
const qualifiedKey = qualifiedName ?? name;
const qualifiedMatches = classByQualifiedName.get(qualifiedKey);
if (qualifiedMatches) {
qualifiedMatches.push(def);
} else {
classByQualifiedName.set(qualifiedKey, [def]);
}
}
// D. Invalidate the lazy callable index only when adding callable types
if (CALLABLE_TYPES.has(type)) {
callableIndex = null;
@ -215,10 +276,12 @@ export const createSymbolTable = (): SymbolTable => {
};
const lookupFuzzy = (name: string): SymbolDefinition[] => {
fuzzyCallCount++;
return globalIndex.get(name) || [];
};
const lookupFuzzyCallable = (name: string): SymbolDefinition[] => {
fuzzyCallableCallCount++;
if (!callableIndex) {
// Build the callable index lazily on first use
callableIndex = new Map();
@ -254,9 +317,19 @@ export const createSymbolTable = (): SymbolTable => {
return defs[0];
};
const lookupClassByName = (name: string): SymbolDefinition[] => {
return classByName.get(name) ?? [];
};
const lookupClassByQualifiedName = (qualifiedName: string): SymbolDefinition[] => {
return classByQualifiedName.get(qualifiedName) ?? [];
};
const getStats = () => ({
fileCount: fileIndex.size,
globalSymbolCount: globalIndex.size,
fuzzyCallableCallCount: fuzzyCallableCallCount,
fuzzyCallCount: fuzzyCallCount,
});
const clear = () => {
@ -265,6 +338,10 @@ export const createSymbolTable = (): SymbolTable => {
callableIndex = null;
fieldByOwner.clear();
methodByOwner.clear();
classByName.clear();
classByQualifiedName.clear();
fuzzyCallCount = 0;
fuzzyCallableCallCount = 0;
};
return {
@ -276,6 +353,8 @@ export const createSymbolTable = (): SymbolTable => {
lookupFuzzyCallable,
lookupFieldByOwner,
lookupMethodByOwner,
lookupClassByName,
lookupClassByQualifiedName,
getStats,
clear,
};

View file

@ -395,7 +395,7 @@ const findEnclosingScopeKey = (
* using cross-file type information when available.
*
* Only `.has()` is exposed the SymbolTable doesn't support iteration.
* Results are memoized to avoid redundant lookupFuzzy scans across declarations.
* Results are memoized to avoid redundant class-index scans across declarations.
*/
const createClassNameLookup = (
localNames: Set<string>,
@ -410,7 +410,7 @@ const createClassNameLookup = (
const cached = memo.get(name);
if (cached !== undefined) return cached;
const result = symbolTable
.lookupFuzzy(name)
.lookupClassByName(name)
.some((def) => def.type === 'Class' || def.type === 'Enum' || def.type === 'Struct');
memo.set(name, result);
return result;
@ -459,18 +459,23 @@ const SKIP_SUBTREE_TYPES = new Set([
]);
const CLASS_LIKE_TYPES = new Set(['Class', 'Struct', 'Interface']);
type ClassDefRef = { nodeId: string; type: string; filePath: string };
const lookupClassDefsByName = (
symbolTable: SymbolTable,
name: string,
allowedTypes: ReadonlySet<string> = CLASS_LIKE_TYPES,
): ClassDefRef[] => symbolTable.lookupClassByName(name).filter((d) => allowedTypes.has(d.type));
/** Memoize class definition lookups during fixpoint iteration.
* SymbolTable is immutable during type resolution, so results never change.
* Eliminates redundant array allocations + filter scans across iterations. */
const createClassDefCache = (symbolTable?: SymbolTable) => {
const cache = new Map<string, Array<{ nodeId: string; type: string }>>();
const cache = new Map<string, ClassDefRef[]>();
return (typeName: string) => {
let result = cache.get(typeName);
if (result === undefined) {
result = symbolTable
? symbolTable.lookupFuzzy(typeName).filter((d) => CLASS_LIKE_TYPES.has(d.type))
: [];
result = symbolTable ? lookupClassDefsByName(symbolTable, typeName) : [];
cache.set(typeName, result);
}
return result;
@ -556,7 +561,7 @@ export const isSubclassOf = (
const walkParentChain = <T>(
typeName: string,
parentMap: ReadonlyMap<string, readonly string[]> | undefined,
getClassDefs: (name: string) => Array<{ nodeId: string; type: string }>,
getClassDefs: (name: string) => ClassDefRef[],
lookupOnClass: (nodeId: string) => T | undefined,
): T | undefined => {
if (!parentMap) return undefined;
@ -592,15 +597,13 @@ const resolveFieldType = (
field: string,
scopeEnv: ReadonlyMap<string, string>,
symbolTable?: SymbolTable,
getClassDefs?: (typeName: string) => Array<{ nodeId: string; type: string }>,
getClassDefs?: (typeName: string) => ClassDefRef[],
parentMap?: ReadonlyMap<string, readonly string[]>,
): string | undefined => {
if (!symbolTable) return undefined;
const receiverType = scopeEnv.get(receiver);
if (!receiverType) return undefined;
const lookup =
getClassDefs ??
((name: string) => symbolTable.lookupFuzzy(name).filter((d) => CLASS_LIKE_TYPES.has(d.type)));
const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(symbolTable, name));
const classDefs = lookup(receiverType);
if (classDefs.length !== 1) return undefined;
// Direct lookup first
@ -616,14 +619,14 @@ const resolveFieldType = (
/** Resolve a method's return type given a receiver variable and method name.
* Uses SymbolTable to find class nodeIds for the receiver's type, then
* looks up the method via lookupFuzzyCallable filtered by ownerId.
* looks up the method via owner-scoped lookupMethodByOwner.
* Falls back to MRO parent chain walking if direct lookup fails (Phase 11A). */
const resolveMethodReturnType = (
receiver: string,
method: string,
scopeEnv: ReadonlyMap<string, string>,
symbolTable?: SymbolTable,
getClassDefs?: (typeName: string) => Array<{ nodeId: string; type: string }>,
getClassDefs?: (typeName: string) => ClassDefRef[],
parentMap?: ReadonlyMap<string, readonly string[]>,
): string | undefined => {
if (!symbolTable) return undefined;
@ -631,33 +634,37 @@ const resolveMethodReturnType = (
// When substituteThisReceiver replaced $this/self with the enclosing class name,
// the receiver IS the type — look it up directly as a class name.
if (!receiverType) {
const lookup =
getClassDefs ??
((name: string) => symbolTable.lookupFuzzy(name).filter((d) => CLASS_LIKE_TYPES.has(d.type)));
const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(symbolTable, name));
if (lookup(receiver).length > 0) receiverType = receiver;
}
if (!receiverType) return undefined;
const lookup =
getClassDefs ??
((name: string) => symbolTable.lookupFuzzy(name).filter((d) => CLASS_LIKE_TYPES.has(d.type)));
const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(symbolTable, name));
const classDefs = lookup(receiverType);
if (classDefs.length === 0) return undefined;
// Direct lookup first
const classNodeIds = new Set(classDefs.map((d) => d.nodeId));
const methods = symbolTable
.lookupFuzzyCallable(method)
.filter((d) => d.ownerId && classNodeIds.has(d.ownerId));
const directMethodLookups = classDefs.map((d) => ({
classDef: d,
methodDef: symbolTable.lookupMethodByOwner(d.nodeId, method),
}));
const hasAmbiguousDirectLookup = directMethodLookups.some(({ classDef, methodDef }) => {
if (methodDef) return false;
return symbolTable
.lookupExactAll(classDef.filePath, method)
.some((d) => d.ownerId === classDef.nodeId);
});
if (hasAmbiguousDirectLookup) return undefined;
const methods = directMethodLookups
.map(({ methodDef }) => methodDef)
.filter((d): d is NonNullable<typeof d> => d !== undefined);
if (methods.length === 1 && methods[0].returnType) {
return extractReturnTypeName(methods[0].returnType);
}
// MRO parent chain walking on miss
if (methods.length === 0) {
const inherited = walkParentChain(receiverType, parentMap, lookup, (nodeId) => {
const parentMethods = symbolTable
.lookupFuzzyCallable(method)
.filter((d) => d.ownerId === nodeId);
if (parentMethods.length !== 1 || !parentMethods[0].returnType) return undefined;
return extractReturnTypeName(parentMethods[0].returnType);
const parentMethod = symbolTable.lookupMethodByOwner(nodeId, method);
if (!parentMethod?.returnType) return undefined;
return extractReturnTypeName(parentMethod.returnType);
});
return inherited;
}

View file

@ -15,7 +15,7 @@ import { createRequire } from 'node:module';
import { SupportedLanguages } from 'gitnexus-shared';
import { getProvider } from '../languages/index.js';
import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from '../constants.js';
import { SymbolTable } from '../symbol-table.js';
import type { SymbolTable } from '../symbol-table.js';
/** Language grammar type accepted by Parser.setLanguage(). */
type TreeSitterLanguage = Parameters<typeof Parser.prototype.setLanguage>[0];
@ -121,6 +121,7 @@ interface ParsedSymbol {
name: string;
nodeId: string;
type: NodeLabel;
qualifiedName?: string;
parameterCount?: number;
requiredParameterCount?: number;
parameterTypes?: string[];
@ -1809,14 +1810,22 @@ const processFileGroup = (
}
}
const nodeLabel = getLabelFromCaptures(captureMap, provider);
if (!nodeLabel) continue;
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
const defaultNodeLabel = getLabelFromCaptures(captureMap, provider);
if (!defaultNodeLabel) continue;
const nameNode = captureMap['name'];
const extractedClassSymbol =
definitionNode && provider.classExtractor?.isTypeDeclaration(definitionNode)
? provider.classExtractor.extract(definitionNode, {
name: nameNode?.text,
type: defaultNodeLabel,
})
: null;
const nodeLabel = extractedClassSymbol?.type ?? defaultNodeLabel;
// Synthesize name for constructors without explicit @name capture (e.g. Swift init)
if (!nameNode && nodeLabel !== 'Constructor') continue;
const nodeName = nameNode ? nameNode.text : 'init';
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
if (!nameNode && nodeLabel !== 'Constructor' && !extractedClassSymbol) continue;
const nodeName = extractedClassSymbol?.name ?? (nameNode ? nameNode.text : 'init');
const startLine = definitionNode
? definitionNode.startPosition.row + lineOffset
: nameNode
@ -1906,6 +1915,12 @@ const processFileGroup = (
arityTag += constTagForId(defMethodMap, nodeName, arityForId, defMethodInfo, groups);
}
const nodeId = generateId(nodeLabel, `${file.path}:${qualifiedName}${arityTag}`);
const classNodeForSymbol = definitionNode || nameNode;
const qualifiedTypeName =
extractedClassSymbol?.qualifiedName ??
(classNodeForSymbol && provider.classExtractor?.isTypeDeclaration(classNodeForSymbol)
? (provider.classExtractor.extractQualifiedName(classNodeForSymbol, nodeName) ?? nodeName)
: undefined);
const description = provider.descriptionExtractor?.(nodeLabel, nodeName, captureMap);
@ -1983,6 +1998,7 @@ const processFileGroup = (
language === SupportedLanguages.Vue && isVueSetup
? isVueSetupTopLevel(nameNode || definitionNode)
: cachedExportCheck(provider.exportChecker, nameNode || definitionNode, nodeName),
...(qualifiedTypeName !== undefined ? { qualifiedName: qualifiedTypeName } : {}),
...(frameworkHint
? {
astFrameworkMultiplier: frameworkHint.entryPointMultiplier,
@ -2002,6 +2018,7 @@ const processFileGroup = (
name: nodeName,
nodeId,
type: nodeLabel,
...(qualifiedTypeName !== undefined ? { qualifiedName: qualifiedTypeName } : {}),
parameterCount: methodProps.parameterCount as number | undefined,
requiredParameterCount: methodProps.requiredParameterCount as number | undefined,
parameterTypes: methodProps.parameterTypes as string[] | undefined,

View file

@ -0,0 +1,5 @@
#pragma once
#include "Parent.h"
class Child : public Parent {
};

View file

@ -0,0 +1,9 @@
#pragma once
#include <string>
class Parent {
public:
std::string parentMethod() {
return "parent";
}
};

View file

@ -0,0 +1,6 @@
#include "Child.h"
void run() {
Child c;
c.parentMethod();
}

View file

@ -0,0 +1,12 @@
namespace Services;
using Models;
public class App
{
public void Run()
{
var c = new Child();
c.ParentMethod();
}
}

View file

@ -0,0 +1,5 @@
namespace Models;
public class Child : Parent
{
}

View file

@ -0,0 +1,9 @@
namespace Models;
public class Parent
{
public string ParentMethod()
{
return "parent";
}
}

View file

@ -0,0 +1,7 @@
namespace Data.Auth
{
public class User
{
public void Save() {}
}
}

View file

@ -0,0 +1,6 @@
namespace Services.Auth;
public class User
{
public void Save() {}
}

View file

@ -0,0 +1,8 @@
import 'child.dart';
class App {
void run() {
final c = Child();
c.parentMethod();
}
}

View file

@ -0,0 +1,3 @@
import 'parent.dart';
class Child extends Parent {}

View file

@ -0,0 +1,5 @@
class Parent {
String parentMethod() {
return 'parent';
}
}

View file

@ -0,0 +1,3 @@
module example.com/app
go 1.21

View file

@ -0,0 +1,5 @@
package models
type Child struct {
Parent
}

View file

@ -0,0 +1,7 @@
package models
type Parent struct{}
func (p *Parent) ParentMethod() string {
return "parent"
}

View file

@ -0,0 +1,8 @@
package services
import "example.com/app/models"
func Run() {
c := &models.Child{}
c.ParentMethod()
}

View file

@ -0,0 +1,5 @@
package models;
public class Child extends Parent {
public void childOnly() {}
}

View file

@ -0,0 +1,5 @@
package models;
public class Parent {
public String parentMethod() { return "hello"; }
}

View file

@ -0,0 +1,10 @@
package services;
import models.Child;
public class App {
public void run() {
Child c = new Child();
c.parentMethod();
}
}

View file

@ -0,0 +1,5 @@
package com.example.admin;
public class User {
public void save() {}
}

View file

@ -0,0 +1,5 @@
package com.example.models;
public class User {
public void save() {}
}

View file

@ -0,0 +1,3 @@
import { Parent } from './Parent.js';
export class Child extends Parent {}

View file

@ -0,0 +1,5 @@
export class Parent {
parentMethod() {
return 'parent';
}
}

View file

@ -0,0 +1,6 @@
import { Child } from './Child.js';
export function run() {
const c = new Child();
c.parentMethod();
}

View file

@ -0,0 +1,10 @@
package services
import models.Child
class App {
fun run() {
val c = Child()
c.parentMethod()
}
}

View file

@ -0,0 +1,3 @@
package models
class Child : Parent()

View file

@ -0,0 +1,7 @@
package models
open class Parent {
fun parentMethod(): String {
return "parent"
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace Services;
use Models\Child;
class App
{
public function run(): void
{
$c = new Child();
$c->parentMethod();
}
}

View file

@ -0,0 +1,7 @@
<?php
namespace Models;
class Child extends ParentClass
{
}

View file

@ -0,0 +1,11 @@
<?php
namespace Models;
class ParentClass
{
public function parentMethod(): string
{
return 'parent';
}
}

View file

@ -0,0 +1,6 @@
from child import Child
def run() -> None:
c = Child()
c.parent_method()

View file

@ -0,0 +1,5 @@
from parent import Parent
class Child(Parent):
pass

View file

@ -0,0 +1,3 @@
class Parent:
def parent_method(self) -> str:
return "parent"

View file

@ -0,0 +1,8 @@
require_relative 'child'
class App
def run
c = Child.new
c.parent_method
end
end

View file

@ -0,0 +1,4 @@
require_relative 'parent'
class Child < Parent
end

View file

@ -0,0 +1,5 @@
class Parent
def parent_method
"parent"
end
end

View file

@ -0,0 +1,7 @@
module Admin
class User
def save
true
end
end
end

View file

@ -0,0 +1,9 @@
module Services
module Auth
class User
def save
true
end
end
end
end

View file

@ -0,0 +1,6 @@
class App {
func run() {
let c = Child()
c.parentMethod()
}
}

View file

@ -0,0 +1,2 @@
class Child: Parent {
}

View file

@ -0,0 +1,5 @@
class Parent {
func parentMethod() -> String {
return "parent"
}
}

View file

@ -0,0 +1,3 @@
import { Parent } from './Parent';
export class Child extends Parent {}

View file

@ -0,0 +1,5 @@
export class Parent {
parentMethod(): string {
return 'parent';
}
}

View file

@ -0,0 +1,6 @@
import { Child } from './Child';
export function run(): void {
const c = new Child();
c.parentMethod();
}

View file

@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';
import { createASTCache } from '../../src/core/ingestion/ast-cache.js';
import { processParsing } from '../../src/core/ingestion/parsing-processor.js';
import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
describe('qualified class lookups', () => {
it('derives canonical dot-separated names from namespaces, packages, and modules', async () => {
const graph = createKnowledgeGraph();
const symbolTable = createSymbolTable();
const astCache = createASTCache();
await processParsing(
graph,
[
{
path: 'src/Services/User.cs',
content: 'namespace Services.Auth;\npublic class User {}\n',
},
{
path: 'src/Data/User.cs',
content: 'namespace Data.Auth;\npublic class User {}\n',
},
{
path: 'src/models/Config.java',
content: 'package com.example.models;\nclass Config {}\n',
},
{
path: 'lib/admin/user.rb',
content: 'module Admin\n class User\n end\nend\n',
},
],
symbolTable,
astCache,
);
const userMatches = symbolTable.lookupClassByName('User');
expect(userMatches).toHaveLength(3);
expect(userMatches.map((match) => match.qualifiedName).sort()).toEqual(
['Admin.User', 'Data.Auth.User', 'Services.Auth.User'].sort(),
);
const servicesUser = symbolTable.lookupClassByQualifiedName('Services.Auth.User');
expect(servicesUser).toHaveLength(1);
expect(servicesUser[0].filePath).toBe('src/Services/User.cs');
expect(servicesUser[0].qualifiedName).toBe('Services.Auth.User');
const dataUser = symbolTable.lookupClassByQualifiedName('Data.Auth.User');
expect(dataUser).toHaveLength(1);
expect(dataUser[0].filePath).toBe('src/Data/User.cs');
const javaConfig = symbolTable.lookupClassByQualifiedName('com.example.models.Config');
expect(javaConfig).toHaveLength(1);
expect(javaConfig[0].qualifiedName).toBe('com.example.models.Config');
const rubyUser = symbolTable.lookupClassByQualifiedName('Admin.User');
expect(rubyUser).toHaveLength(1);
expect(rubyUser[0].qualifiedName).toBe('Admin.User');
});
it('falls back to the simple name for top-level class-like symbols', async () => {
const graph = createKnowledgeGraph();
const symbolTable = createSymbolTable();
const astCache = createASTCache();
await processParsing(
graph,
[{ path: 'src/plain-user.ts', content: 'export class User {}\n' }],
symbolTable,
astCache,
);
const simpleMatches = symbolTable.lookupClassByName('User');
expect(simpleMatches).toHaveLength(1);
expect(simpleMatches[0].qualifiedName).toBe('User');
const matches = symbolTable.lookupClassByQualifiedName('User');
expect(matches).toHaveLength(1);
expect(matches[0].qualifiedName).toBe('User');
});
});

View file

@ -1516,3 +1516,35 @@ describe('C++ out-of-class method definition with overloaded declarations', () =
expect(targetNode?.properties.parameterTypes).toEqual(['int']);
});
});
// ---------------------------------------------------------------------------
// SM-9: lookupMethodByOwnerWithMRO — c.parentMethod() via leftmost-base walk
// ---------------------------------------------------------------------------
describe('C++ Child extends Parent — inherited method resolution (SM-9)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-child-extends-parent'), () => {});
}, 60000);
it('detects Parent and Child classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('Parent');
expect(classes).toContain('Child');
});
it('emits EXTENDS edge: Child → Parent', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(edgeSet(extends_)).toContain('Child → Parent');
});
it('resolves c.parentMethod() to Parent.parentMethod via leftmost-base MRO walk', () => {
const calls = getRelationships(result, 'CALLS');
const parentMethodCall = calls.find(
(c) => c.target === 'parentMethod' && c.targetFilePath.includes('Parent.h'),
);
expect(parentMethodCall).toBeDefined();
expect(parentMethodCall!.source).toBe('run');
});
});

View file

@ -132,6 +132,23 @@ describe('C# ambiguous symbol resolution', () => {
});
});
describe('C# qualified class names', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-qualified-types'), () => {});
}, 60000);
it('stores distinct qualified names for same-named classes across namespaces', () => {
const users = getNodesByLabelFull(result, 'Class').filter((node) => node.name === 'User');
expect(users).toHaveLength(2);
expect(users.map((node) => node.properties.qualifiedName).sort()).toEqual([
'Data.Auth.User',
'Services.Auth.User',
]);
});
});
describe('C# call resolution with arity filtering', () => {
let result: PipelineResult;
@ -1911,3 +1928,38 @@ describe('C# overloaded method disambiguation (METHOD_IMPLEMENTS)', () => {
expect(ifaces).toContain('IRepository');
});
});
// ---------------------------------------------------------------------------
// SM-9: lookupMethodByOwnerWithMRO — c.ParentMethod() via implements-split walk
// ---------------------------------------------------------------------------
describe('C# Child extends Parent — inherited method resolution (SM-9)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'csharp-child-extends-parent'),
() => {},
);
}, 60000);
it('detects Parent and Child classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('Parent');
expect(classes).toContain('Child');
});
it('emits EXTENDS edge: Child → Parent', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(edgeSet(extends_)).toContain('Child → Parent');
});
it('resolves c.ParentMethod() to Parent.ParentMethod via implements-split MRO walk', () => {
const calls = getRelationships(result, 'CALLS');
const parentMethodCall = calls.find(
(c) => c.target === 'ParentMethod' && c.targetFilePath.includes('Parent.cs'),
);
expect(parentMethodCall).toBeDefined();
expect(parentMethodCall!.source).toBe('Run');
});
});

View file

@ -474,3 +474,41 @@ describe.skipIf(!dartAvailable)('Dart interface dispatch (METHOD_IMPLEMENTS)', (
expect(saveEdge).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// SM-9/SM-10: lookupMethodByOwnerWithMRO + D0 fast path — Dart first-wins
// ---------------------------------------------------------------------------
describe.skipIf(!dartAvailable)(
'Dart Child extends Parent — inherited method resolution (SM-9)',
() => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'dart-child-extends-parent'),
() => {},
);
}, 60000);
it('detects Parent and Child classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('Parent');
expect(classes).toContain('Child');
});
it('emits EXTENDS edge: Child → Parent', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(edgeSet(extends_)).toContain('Child → Parent');
});
it('resolves c.parentMethod() to Parent.parentMethod via first-wins MRO walk', () => {
const calls = getRelationships(result, 'CALLS');
const parentMethodCall = calls.find(
(c) => c.target === 'parentMethod' && c.targetFilePath.includes('parent.dart'),
);
expect(parentMethodCall).toBeDefined();
expect(parentMethodCall!.source).toBe('run');
});
},
);

View file

@ -1345,3 +1345,35 @@ describe('Go method enrichment', () => {
expect(classifyCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// SM-9/SM-10: lookupMethodByOwnerWithMRO + D0 fast path — Go struct embedding
// ---------------------------------------------------------------------------
describe('Go Child embeds Parent — inherited method resolution (SM-9)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'go-child-extends-parent'), () => {});
}, 60000);
it('detects Parent and Child structs', () => {
const structs = getNodesByLabel(result, 'Struct');
expect(structs).toContain('Parent');
expect(structs).toContain('Child');
});
it('emits EXTENDS edge: Child → Parent (struct embedding)', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(edgeSet(extends_)).toContain('Child → Parent');
});
it('resolves c.ParentMethod() to Parent.ParentMethod via first-wins MRO walk', () => {
const calls = getRelationships(result, 'CALLS');
const parentMethodCall = calls.find(
(c) => c.target === 'ParentMethod' && c.targetFilePath.includes('parent.go'),
);
expect(parentMethodCall).toBeDefined();
expect(parentMethodCall!.source).toBe('Run');
});
});

View file

@ -137,6 +137,23 @@ describe('Java ambiguous symbol resolution', () => {
});
});
describe('Java qualified class names', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-qualified-types'), () => {});
}, 60000);
it('stores distinct qualified names for same-named classes across packages', () => {
const users = getNodesByLabelFull(result, 'Class').filter((node) => node.name === 'User');
expect(users).toHaveLength(2);
expect(users.map((node) => node.properties.qualifiedName).sort()).toEqual([
'com.example.admin.User',
'com.example.models.User',
]);
});
});
describe('Java call resolution with arity filtering', () => {
let result: PipelineResult;
@ -2081,3 +2098,38 @@ describe('Cross-class method chain resolution (Java) — #575', () => {
expect(cityAccess.length).toBe(1);
});
});
// ---------------------------------------------------------------------------
// SM-9: lookupMethodByOwnerWithMRO — class Child extends Parent
// child.parentMethod() resolves to Parent#parentMethod via MRO parent walk.
// ---------------------------------------------------------------------------
describe('Java Child extends Parent — inherited method resolution (SM-9)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-child-extends-parent'), () => {});
}, 60000);
it('detects Parent and Child classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('Parent');
expect(classes).toContain('Child');
});
it('emits EXTENDS edge: Child → Parent', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(edgeSet(extends_)).toContain('Child → Parent');
});
it('resolves c.parentMethod() to Parent#parentMethod via MRO walk', () => {
const calls = getRelationships(result, 'CALLS');
const parentMethodCall = calls.find(
(c) => c.target === 'parentMethod' && c.targetFilePath.includes('Parent'),
);
expect(parentMethodCall).toBeDefined();
// Pin the caller too — not just the target — so a regression that
// misattributes the edge to a different source would fail loudly.
expect(parentMethodCall!.source).toBe('run');
});
});

View file

@ -506,3 +506,38 @@ describe('JavaScript method enrichment', () => {
expect(classifyCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// SM-9: lookupMethodByOwnerWithMRO — child.parentMethod() via first-wins walk
// ---------------------------------------------------------------------------
describe('JavaScript Child extends Parent — inherited method resolution (SM-9)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'javascript-child-extends-parent'),
() => {},
);
}, 60000);
it('detects Parent and Child classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('Parent');
expect(classes).toContain('Child');
});
it('emits EXTENDS edge: Child → Parent', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(edgeSet(extends_)).toContain('Child → Parent');
});
it('resolves c.parentMethod() to Parent.parentMethod via first-wins MRO walk', () => {
const calls = getRelationships(result, 'CALLS');
const parentMethodCall = calls.find(
(c) => c.target === 'parentMethod' && c.targetFilePath.includes('Parent.js'),
);
expect(parentMethodCall).toBeDefined();
expect(parentMethodCall!.source).toBe('run');
});
});

View file

@ -1991,3 +1991,38 @@ describe('Kotlin overloaded method disambiguation', () => {
expect(mi.length).toBe(3);
});
});
// ---------------------------------------------------------------------------
// SM-9: lookupMethodByOwnerWithMRO — child.parentMethod() via implements-split walk
// ---------------------------------------------------------------------------
describe('Kotlin Child extends Parent — inherited method resolution (SM-9)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'kotlin-child-extends-parent'),
() => {},
);
}, 60000);
it('detects Parent and Child classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('Parent');
expect(classes).toContain('Child');
});
it('emits EXTENDS edge: Child → Parent', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(edgeSet(extends_)).toContain('Child → Parent');
});
it('resolves c.parentMethod() to Parent.parentMethod via implements-split MRO walk', () => {
const calls = getRelationships(result, 'CALLS');
const parentMethodCall = calls.find(
(c) => c.target === 'parentMethod' && c.targetFilePath.includes('Parent.kt'),
);
expect(parentMethodCall).toBeDefined();
expect(parentMethodCall!.source).toBe('run');
});
});

View file

@ -1780,3 +1780,30 @@ describe('PHP abstract dispatch', () => {
expect(names).toEqual(['find', 'save']);
});
});
// ---------------------------------------------------------------------------
// SM-9/SM-10: lookupMethodByOwnerWithMRO + D0 fast path — PHP first-wins
// ---------------------------------------------------------------------------
describe('PHP Child extends ParentClass — inherited method resolution (SM-9)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'php-child-extends-parent'), () => {});
}, 60000);
it('detects ParentClass and Child classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('ParentClass');
expect(classes).toContain('Child');
});
it('resolves $c->parentMethod() to ParentClass::parentMethod via first-wins MRO walk', () => {
const calls = getRelationships(result, 'CALLS');
const parentMethodCall = calls.find(
(c) => c.target === 'parentMethod' && c.targetFilePath.includes('Parent.php'),
);
expect(parentMethodCall).toBeDefined();
expect(parentMethodCall!.source).toBe('run');
});
});

View file

@ -2111,3 +2111,38 @@ describe('Python abstract dispatch', () => {
expect(edges.length).toBe(0);
});
});
// ---------------------------------------------------------------------------
// SM-9: lookupMethodByOwnerWithMRO — child.parent_method() via C3 parent walk
// ---------------------------------------------------------------------------
describe('Python Child extends Parent — inherited method resolution (SM-9)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-child-extends-parent'),
() => {},
);
}, 60000);
it('detects Parent and Child classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('Parent');
expect(classes).toContain('Child');
});
it('emits EXTENDS edge: Child → Parent', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(edgeSet(extends_)).toContain('Child → Parent');
});
it('resolves c.parent_method() to Parent.parent_method via C3 MRO walk', () => {
const calls = getRelationships(result, 'CALLS');
const parentMethodCall = calls.find(
(c) => c.target === 'parent_method' && c.targetFilePath.includes('parent.py'),
);
expect(parentMethodCall).toBeDefined();
expect(parentMethodCall!.source).toBe('run');
});
});

View file

@ -255,6 +255,23 @@ describe('Ruby member-call resolution', () => {
});
});
describe('Ruby qualified class names', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-qualified-types'), () => {});
}, 60000);
it('stores distinct qualified names for same-named classes across modules', () => {
const users = getNodesByLabelFull(result, 'Class').filter((node) => node.name === 'User');
expect(users).toHaveLength(2);
expect(users.map((node) => node.properties.qualifiedName).sort()).toEqual([
'Admin.User',
'Services.Auth.User',
]);
});
});
// ---------------------------------------------------------------------------
// Ambiguous: Handler in two dirs, require_relative disambiguates
// ---------------------------------------------------------------------------
@ -1313,3 +1330,30 @@ describe('Ruby overload dispatch (format vs format_with_prefix)', () => {
expect(methods).toContain('run');
});
});
// ---------------------------------------------------------------------------
// SM-9/SM-10: lookupMethodByOwnerWithMRO + D0 fast path — Ruby first-wins
// ---------------------------------------------------------------------------
describe('Ruby Child extends Parent — inherited method resolution (SM-9)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-child-extends-parent'), () => {});
}, 60000);
it('detects Parent and Child classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('Parent');
expect(classes).toContain('Child');
});
it('resolves c.parent_method to Parent#parent_method via first-wins MRO walk', () => {
const calls = getRelationships(result, 'CALLS');
const parentMethodCall = calls.find(
(c) => c.target === 'parent_method' && c.targetFilePath.includes('parent.rb'),
);
expect(parentMethodCall).toBeDefined();
expect(parentMethodCall!.source).toBe('run');
});
});

View file

@ -865,3 +865,36 @@ describe.skipIf(!swiftAvailable)('Swift overloaded method disambiguation', () =>
expect(mi.length).toBe(3);
});
});
// ---------------------------------------------------------------------------
// SM-9/SM-10: lookupMethodByOwnerWithMRO + D0 fast path — Swift first-wins
// ---------------------------------------------------------------------------
describe.skipIf(!swiftAvailable)(
'Swift Child extends Parent — inherited method resolution (SM-9)',
() => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'swift-child-extends-parent'),
() => {},
);
}, 60000);
it('detects Parent and Child classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('Parent');
expect(classes).toContain('Child');
});
it('resolves c.parentMethod() to Parent.parentMethod via first-wins MRO walk', () => {
const calls = getRelationships(result, 'CALLS');
const parentMethodCall = calls.find(
(c) => c.target === 'parentMethod' && c.targetFilePath.includes('Parent.swift'),
);
expect(parentMethodCall).toBeDefined();
expect(parentMethodCall!.source).toBe('run');
});
},
);

View file

@ -2537,3 +2537,38 @@ describe('TypeScript same-arity overload cross-file resolution', () => {
expect(edges.length).toBe(1);
});
});
// ---------------------------------------------------------------------------
// SM-9: lookupMethodByOwnerWithMRO — child.parentMethod() via first-wins walk
// ---------------------------------------------------------------------------
describe('TypeScript Child extends Parent — inherited method resolution (SM-9)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'typescript-child-extends-parent'),
() => {},
);
}, 60000);
it('detects Parent and Child classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('Parent');
expect(classes).toContain('Child');
});
it('emits EXTENDS edge: Child → Parent', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(edgeSet(extends_)).toContain('Child → Parent');
});
it('resolves c.parentMethod() to Parent.parentMethod via first-wins MRO walk', () => {
const calls = getRelationships(result, 'CALLS');
const parentMethodCall = calls.find(
(c) => c.target === 'parentMethod' && c.targetFilePath.includes('Parent.ts'),
);
expect(parentMethodCall).toBeDefined();
expect(parentMethodCall!.source).toBe('run');
});
});

View file

@ -1,12 +1,13 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
processCalls,
processCallsFromExtracted,
seedCrossFileReceiverTypes,
extractConsumerAccessedKeys,
processNextjsFetchRoutes,
buildImplementorMap,
mergeImplementorMaps,
} from '../../src/core/ingestion/call-processor.js';
import { buildHeritageMap } from '../../src/core/ingestion/heritage-map.js';
import { createASTCache } from '../../src/core/ingestion/ast-cache.js';
import { extractReturnTypeName } from '../../src/core/ingestion/type-extractors/shared.js';
import {
createResolutionContext,
@ -754,6 +755,113 @@ describe('processCallsFromExtracted', () => {
});
});
describe('processCalls — Phase P class lookup fallback', () => {
let graph: ReturnType<typeof createKnowledgeGraph>;
let ctx: ResolutionContext;
beforeEach(() => {
graph = createKnowledgeGraph();
ctx = createResolutionContext();
});
it('uses lookupClassByName to override interface receiver types for cross-file virtual dispatch', async () => {
const appFile = 'services/App.java';
const contractFile = 'models/Pet.java';
const dogFile = 'models/Dog.java';
const petId = 'Interface:models/Pet.java:Pet';
const dogId = 'Class:models/Dog.java:Dog';
const fetchBallId = 'Method:models/Dog.java:fetchBall';
ctx.symbols.add(contractFile, 'Pet', petId, 'Interface');
ctx.symbols.add(dogFile, 'Dog', dogId, 'Class');
ctx.symbols.add(dogFile, 'fetchBall', fetchBallId, 'Method', { ownerId: dogId });
ctx.importMap.set(appFile, new Set([contractFile, dogFile]));
const classLookupSpy = vi.spyOn(ctx.symbols, 'lookupClassByName');
await processCalls(
graph,
[
{
path: appFile,
content: `
package services;
import models.Pet;
import models.Dog;
class App {
void run() {
Pet pet = new Dog();
pet.fetchBall();
}
}
`,
},
],
createASTCache(),
ctx,
);
const fetchBallCalls = graph.relationships.filter(
(r) => r.type === 'CALLS' && r.targetId === fetchBallId,
);
expect(fetchBallCalls).toHaveLength(1);
expect(classLookupSpy).toHaveBeenCalledWith('Dog');
expect(classLookupSpy).toHaveBeenCalledWith('Pet');
});
it('does not override when the constructor type is not indexed as class-like', async () => {
const appFile = 'services/App.java';
const contractFile = 'models/Pet.java';
const dogFile = 'models/Dog.java';
const otherDogFile = 'models/OtherDog.java';
const petId = 'Interface:models/Pet.java:Pet';
ctx.symbols.add(contractFile, 'Pet', petId, 'Interface');
ctx.symbols.add(dogFile, 'fetchBall', 'Method:models/Dog.java:fetchBall', 'Method', {
ownerId: 'Class:models/Dog.java:Dog',
});
ctx.symbols.add(otherDogFile, 'fetchBall', 'Method:models/OtherDog.java:fetchBall', 'Method', {
ownerId: 'Class:models/OtherDog.java:OtherDog',
});
ctx.importMap.set(appFile, new Set([contractFile, dogFile, otherDogFile]));
const classLookupSpy = vi.spyOn(ctx.symbols, 'lookupClassByName');
await processCalls(
graph,
[
{
path: appFile,
content: `
package services;
import models.Pet;
import models.Dog;
class App {
void run() {
Pet pet = new Dog();
pet.fetchBall();
}
}
`,
},
],
createASTCache(),
ctx,
);
const fetchBallCalls = graph.relationships.filter(
(r) => r.type === 'CALLS' && r.targetId === 'Method:models/Dog.java:fetchBall',
);
expect(fetchBallCalls).toHaveLength(0);
expect(classLookupSpy).toHaveBeenCalledWith('Dog');
expect(classLookupSpy).not.toHaveBeenCalledWith('Pet');
});
});
describe('extractReturnTypeName', () => {
it('extracts simple type name', () => {
expect(extractReturnTypeName('User')).toBe('User');
@ -1397,56 +1505,6 @@ describe('processNextjsFetchRoutes', () => {
});
});
describe('buildImplementorMap / mergeImplementorMaps', () => {
it('records direct implements edges per interface name', () => {
const heritage: ExtractedHeritage[] = [
{ filePath: 'a.java', className: 'C', parentName: 'Runnable', kind: 'implements' },
{ filePath: 'b.java', className: 'D', parentName: 'Runnable', kind: 'implements' },
];
const map = buildImplementorMap(heritage);
expect(map.get('Runnable')).toEqual(new Set(['a.java', 'b.java']));
});
it('ignores extends and other heritage kinds', () => {
const heritage: ExtractedHeritage[] = [
{ filePath: 'a.java', className: 'C', parentName: 'Base', kind: 'extends' },
{ filePath: 'a.java', className: 'C', parentName: 'I', kind: 'implements' },
];
const map = buildImplementorMap(heritage);
expect(map.has('Base')).toBe(false);
expect(map.get('I')).toEqual(new Set(['a.java']));
});
it('mergeImplementorMaps unions files per interface and adds new keys', () => {
const acc = new Map<string, Set<string>>();
mergeImplementorMaps(acc, new Map([['I', new Set(['a.java'])]]));
mergeImplementorMaps(
acc,
new Map([
['I', new Set(['b.java'])],
['J', new Set(['c.java'])],
]),
);
expect(acc.get('I')).toEqual(new Set(['a.java', 'b.java']));
expect(acc.get('J')).toEqual(new Set(['c.java']));
});
it('heritage merged across disjoint lists matches single buildImplementorMap (chunk-order invariant)', () => {
const chunk1: ExtractedHeritage[] = [
{ filePath: 'a.java', className: 'A', parentName: 'Iface', kind: 'implements' },
];
const chunk2: ExtractedHeritage[] = [
{ filePath: 'b.java', className: 'B', parentName: 'Iface', kind: 'implements' },
];
const oneShot = buildImplementorMap([...chunk1, ...chunk2]);
const acc = new Map<string, Set<string>>();
mergeImplementorMaps(acc, buildImplementorMap(chunk1));
mergeImplementorMaps(acc, buildImplementorMap(chunk2));
expect(oneShot.get('Iface')).toEqual(acc.get('Iface'));
expect(oneShot.get('Iface')).toEqual(new Set(['a.java', 'b.java']));
});
});
describe('processCallsFromExtracted — interface dispatch', () => {
let graph: ReturnType<typeof createKnowledgeGraph>;
let ctx: ResolutionContext;
@ -1497,9 +1555,14 @@ describe('processCallsFromExtracted — interface dispatch', () => {
});
it('adds CALLS to interface method plus lower-confidence edges to implementing methods', async () => {
const implementorMap = new Map<string, ReadonlySet<string>>([
['Action', new Set(['impl/A.java', 'impl/B.java'])],
]);
const heritage: ExtractedHeritage[] = [
{ filePath: 'impl/A.java', className: 'A', parentName: 'Action', kind: 'implements' },
{ filePath: 'impl/B.java', className: 'B', parentName: 'Action', kind: 'implements' },
];
// Need class symbols for heritage map to resolve implementors
ctx.symbols.add('impl/A.java', 'A', 'Class:impl/A.java:A', 'Class');
ctx.symbols.add('impl/B.java', 'B', 'Class:impl/B.java:B', 'Class');
const heritageMap = buildHeritageMap(heritage, ctx);
const calls: ExtractedCall[] = [
{
@ -1512,7 +1575,7 @@ describe('processCallsFromExtracted — interface dispatch', () => {
},
];
await processCallsFromExtracted(graph, calls, ctx, undefined, undefined, implementorMap);
await processCallsFromExtracted(graph, calls, ctx, undefined, undefined, heritageMap);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(3);
@ -1528,3 +1591,348 @@ describe('processCallsFromExtracted — interface dispatch', () => {
expect(toB?.reason).toBe('interface-dispatch');
});
});
// ---------------------------------------------------------------------------
// SM-10: D0 MRO fast path in resolveCallTarget
// ---------------------------------------------------------------------------
describe('processCalls — D0 MRO fast path (SM-10)', () => {
let graph: ReturnType<typeof createKnowledgeGraph>;
let ctx: ResolutionContext;
beforeEach(() => {
graph = createKnowledgeGraph();
ctx = createResolutionContext();
});
const setupChildParent = () => {
const parentFile = 'src/models/Parent.java';
const childFile = 'src/models/Child.java';
const appFile = 'src/services/App.java';
const parentId = 'class:models/Parent.java:Parent';
const childId = 'class:models/Child.java:Child';
const parentMethodId = 'method:models/Parent.java:parentMethod';
ctx.symbols.add(parentFile, 'Parent', parentId, 'Class');
ctx.symbols.add(childFile, 'Child', childId, 'Class');
ctx.symbols.add(parentFile, 'parentMethod', parentMethodId, 'Method', {
ownerId: parentId,
returnType: 'String',
});
ctx.importMap.set(appFile, new Set([childFile, parentFile]));
return { parentFile, childFile, appFile, parentId, childId, parentMethodId };
};
it('D0 hit: child.parentMethod() resolves via MRO walk when heritageMap is provided', async () => {
const { parentMethodId, appFile, parentFile, childFile } = setupChildParent();
const heritage: ExtractedHeritage[] = [
{
filePath: childFile,
className: 'Child',
parentName: 'Parent',
kind: 'extends',
},
];
const heritageMap = buildHeritageMap(heritage, ctx);
await processCalls(
graph,
[
{
path: parentFile,
content:
'package models;\npublic class Parent {\n public String parentMethod() { return ""; }\n}\n',
},
{
path: childFile,
content: 'package models;\npublic class Child extends Parent {}\n',
},
{
path: appFile,
content:
'package services;\nimport models.Child;\npublic class App {\n public void run() {\n Child c = new Child();\n c.parentMethod();\n }\n}\n',
},
],
createASTCache(),
ctx,
undefined,
undefined,
undefined,
undefined,
undefined,
heritageMap,
);
const parentMethodCalls = graph.relationships.filter(
(r) => r.type === 'CALLS' && r.targetId === parentMethodId,
);
expect(parentMethodCalls).toHaveLength(1);
});
it('D0 miss: heritageMap provided but method not in MRO chain falls through to D1-D4', async () => {
// Setup: Class Obj has a method `doWork` that is findable via tiered
// resolution (import-scoped lookup), but intentionally NOT registered in
// methodByOwner (no `ownerId` property). heritageMap is provided but has
// no ancestry entry for class:Obj. Expected flow:
// D0: lookupMethodByOwner(classId, 'doWork') → undefined
// heritageMap.getAncestors(classId) → []
// lookupMethodByOwnerWithMRO returns undefined → D0 miss
// D1-D4: receiver type resolves to Obj; D2 widens via lookupFuzzy;
// D3 file-filter picks the only candidate in Obj's file.
// Guarantees D0 miss does not swallow the call — D1-D4 still runs.
const classFile = 'src/models/Obj.java';
const appFile = 'src/services/App.java';
const classId = 'class:models/Obj.java:Obj';
const doWorkId = 'method:models/Obj.java:doWork';
ctx.symbols.add(classFile, 'Obj', classId, 'Class');
// Intentionally omit ownerId so methodByOwner has no entry — forces D0 miss.
ctx.symbols.add(classFile, 'doWork', doWorkId, 'Method', {
returnType: 'void',
parameterCount: 0,
});
ctx.importMap.set(appFile, new Set([classFile]));
// Empty heritage — no ancestry for Obj, so the MRO walk yields no parents.
const heritageMap = buildHeritageMap([], ctx);
const calls: ExtractedCall[] = [
{
filePath: appFile,
calledName: 'doWork',
sourceId: 'method:services/App.java:run',
argCount: 0,
callForm: 'member',
receiverTypeName: 'Obj',
},
];
await processCallsFromExtracted(graph, calls, ctx, undefined, undefined, heritageMap);
const doWorkCalls = graph.relationships.filter(
(r) => r.type === 'CALLS' && r.targetId === doWorkId,
);
expect(doWorkCalls).toHaveLength(1);
});
it('D0 skipped: same scenario still resolves via D1-D4 when heritageMap is undefined', async () => {
const { parentMethodId, appFile, parentFile, childFile } = setupChildParent();
await processCalls(
graph,
[
{
path: parentFile,
content:
'package models;\npublic class Parent {\n public String parentMethod() { return ""; }\n}\n',
},
{
path: childFile,
content: 'package models;\npublic class Child extends Parent {}\n',
},
{
path: appFile,
content:
'package services;\nimport models.Child;\npublic class App {\n public void run() {\n Child c = new Child();\n c.parentMethod();\n }\n}\n',
},
],
createASTCache(),
ctx,
// no heritageMap — D0 fast path must be skipped, D1-D4 must still resolve
);
const parentMethodCalls = graph.relationships.filter(
(r) => r.type === 'CALLS' && r.targetId === parentMethodId,
);
expect(parentMethodCalls).toHaveLength(1);
});
it('overloadHints guard: D0 skipped so literal-inferred overload disambiguation picks the right overload', async () => {
// Java sequential path: processCalls auto-generates `overloadHints` for
// languages whose provider exposes `inferLiteralType` (Java/Kotlin/C#/C++).
// When two overloads share the same return type, lookupMethodByOwner
// returns defs[0] (the first-added overload) regardless of argument
// types. Without the D0 guard this would mis-resolve `o.method("hello")`
// to method(int). With the guard, D0 is skipped because overloadHints
// is present, and the literal-inferred overload path in D2-D4+E picks
// method(String) correctly.
const classFile = 'src/models/Obj.java';
const appFile = 'src/services/App.java';
const classId = 'class:models/Obj.java:Obj';
const methodIntId = 'method:models/Obj.java:method(int)';
const methodStringId = 'method:models/Obj.java:method(String)';
ctx.symbols.add(classFile, 'Obj', classId, 'Class');
// int overload added FIRST so lookupMethodByOwner would return it.
ctx.symbols.add(classFile, 'method', methodIntId, 'Method', {
ownerId: classId,
returnType: 'String',
parameterCount: 1,
parameterTypes: ['int'],
});
ctx.symbols.add(classFile, 'method', methodStringId, 'Method', {
ownerId: classId,
returnType: 'String',
parameterCount: 1,
parameterTypes: ['String'],
});
ctx.importMap.set(appFile, new Set([classFile]));
const heritageMap = buildHeritageMap([], ctx);
await processCalls(
graph,
[
{
path: classFile,
content:
'package models;\npublic class Obj {\n public String method(int x) { return ""; }\n public String method(String s) { return ""; }\n}\n',
},
{
path: appFile,
content:
'package services;\nimport models.Obj;\npublic class App {\n public void run() {\n Obj o = new Obj();\n o.method("hello");\n }\n}\n',
},
],
createASTCache(),
ctx,
undefined,
undefined,
undefined,
undefined,
undefined,
heritageMap,
);
// Exactly one resolved call, and it must target the String overload.
const methodCalls = graph.relationships.filter(
(r) => r.type === 'CALLS' && (r.targetId === methodIntId || r.targetId === methodStringId),
);
expect(methodCalls).toHaveLength(1);
expect(methodCalls[0].targetId).toBe(methodStringId);
});
it('preComputedArgTypes guard: D0 skipped so arg-type disambiguation picks the right overload', async () => {
// Two overloads of the same method with identical return types live on
// the same owner class. Without the D0 guard, lookupMethodByOwner would
// return defs[0] (the first overload added) regardless of argument types,
// silently mis-resolving an `obj.method("hello")` call to method(int).
// With the guard, preComputedArgTypes forces D0 to be skipped and D2-D4+E
// disambiguates by parameter type.
const classFile = 'src/models/Obj.java';
const appFile = 'src/services/App.java';
const classId = 'class:models/Obj.java:Obj';
const methodIntId = 'method:models/Obj.java:method(int)';
const methodStringId = 'method:models/Obj.java:method(String)';
ctx.symbols.add(classFile, 'Obj', classId, 'Class');
// int overload added FIRST — without the guard this would be returned by
// lookupMethodByOwner's same-return-type fast path.
ctx.symbols.add(classFile, 'method', methodIntId, 'Method', {
ownerId: classId,
returnType: 'String',
parameterCount: 1,
parameterTypes: ['int'],
});
ctx.symbols.add(classFile, 'method', methodStringId, 'Method', {
ownerId: classId,
returnType: 'String',
parameterCount: 1,
parameterTypes: ['String'],
});
ctx.importMap.set(appFile, new Set([classFile]));
const heritageMap = buildHeritageMap([], ctx);
const calls: ExtractedCall[] = [
{
filePath: appFile,
calledName: 'method',
sourceId: 'method:services/App.java:run',
argCount: 1,
callForm: 'member',
receiverTypeName: 'Obj',
argTypes: ['String'],
},
];
await processCallsFromExtracted(graph, calls, ctx, undefined, undefined, heritageMap);
const methodCalls = graph.relationships.filter((r) => r.type === 'CALLS');
// Exactly one resolved call, and it must target the String overload —
// NOT the int overload that lookupMethodByOwner would have returned.
expect(methodCalls).toHaveLength(1);
expect(methodCalls[0].targetId).toBe(methodStringId);
});
it('module-alias guard: D0 skipped when receiverName matches an active module alias', async () => {
// Setup: two files each define a class named User with a method save().
// The caller has a Python-style module alias `import auth_mod as auth`,
// so auth.User().save() must resolve to auth_mod.py, NOT user_mod.py.
// D0 would call ctx.resolve('User') and could pick the wrong file; the
// alias guard must short-circuit D0 so the alias-filtered D1-D4 path
// runs and picks the correct file.
const authModFile = 'auth_mod.py';
const userModFile = 'user_mod.py';
const appFile = 'app.py';
const authUserId = 'class:auth_mod.py:User';
const userUserId = 'class:user_mod.py:User';
const authSaveId = 'method:auth_mod.py:save';
const userSaveId = 'method:user_mod.py:save';
ctx.symbols.add(authModFile, 'User', authUserId, 'Class');
ctx.symbols.add(userModFile, 'User', userUserId, 'Class');
ctx.symbols.add(authModFile, 'save', authSaveId, 'Method', {
ownerId: authUserId,
returnType: 'bool',
});
ctx.symbols.add(userModFile, 'save', userSaveId, 'Method', {
ownerId: userUserId,
returnType: 'bool',
});
// Register the module alias: in app.py, `auth` points to auth_mod.py.
const aliasMap = new Map<string, string>([['auth', authModFile]]);
ctx.moduleAliasMap.set(appFile, aliasMap);
ctx.importMap.set(appFile, new Set([authModFile]));
const heritageMap = buildHeritageMap([], ctx);
await processCalls(
graph,
[
{
path: authModFile,
content: 'class User:\n def save(self):\n return True\n',
},
{
path: userModFile,
content: 'class User:\n def save(self):\n return True\n',
},
{
path: appFile,
content:
'import auth_mod as auth\n\ndef run():\n user = auth.User()\n user.save()\n',
},
],
createASTCache(),
ctx,
undefined,
undefined,
undefined,
undefined,
undefined,
heritageMap,
);
// save() must resolve to auth_mod.py, NOT user_mod.py.
const authSave = graph.relationships.find(
(r) => r.type === 'CALLS' && r.targetId === authSaveId,
);
const userSave = graph.relationships.find(
(r) => r.type === 'CALLS' && r.targetId === userSaveId,
);
expect(authSave).toBeDefined();
expect(userSave).toBeUndefined();
});
});

View file

@ -0,0 +1,491 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { buildHeritageMap } from '../../src/core/ingestion/heritage-map.js';
import {
createResolutionContext,
type ResolutionContext,
} from '../../src/core/ingestion/resolution-context.js';
import type { ExtractedHeritage } from '../../src/core/ingestion/workers/parse-worker.js';
describe('buildHeritageMap', () => {
let ctx: ResolutionContext;
beforeEach(() => {
ctx = createResolutionContext();
});
// ── getParents ──────────────────────────────────────────────────────
describe('getParents', () => {
it('returns direct parents for a single extends relationship', () => {
ctx.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class');
ctx.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class');
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/child.ts', className: 'Child', parentName: 'Parent', kind: 'extends' },
];
const map = buildHeritageMap(heritage, ctx);
expect(map.getParents('class:Child')).toEqual(['class:Parent']);
});
it('returns direct parents for implements relationship', () => {
ctx.symbols.add('src/service.ts', 'Service', 'class:Service', 'Class');
ctx.symbols.add('src/iface.ts', 'IService', 'iface:IService', 'Interface');
const heritage: ExtractedHeritage[] = [
{
filePath: 'src/service.ts',
className: 'Service',
parentName: 'IService',
kind: 'implements',
},
];
const map = buildHeritageMap(heritage, ctx);
expect(map.getParents('class:Service')).toEqual(['iface:IService']);
});
it('returns direct parents for trait-impl relationship', () => {
ctx.symbols.add('src/point.rs', 'Point', 'struct:Point', 'Struct');
ctx.symbols.add('src/display.rs', 'Display', 'trait:Display', 'Interface');
const heritage: ExtractedHeritage[] = [
{
filePath: 'src/point.rs',
className: 'Point',
parentName: 'Display',
kind: 'trait-impl',
},
];
const map = buildHeritageMap(heritage, ctx);
expect(map.getParents('struct:Point')).toEqual(['trait:Display']);
});
it('returns multiple parents when class extends and implements', () => {
ctx.symbols.add('src/admin.ts', 'Admin', 'class:Admin', 'Class');
ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
ctx.symbols.add('src/serializable.ts', 'Serializable', 'iface:Serializable', 'Interface');
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/admin.ts', className: 'Admin', parentName: 'User', kind: 'extends' },
{
filePath: 'src/admin.ts',
className: 'Admin',
parentName: 'Serializable',
kind: 'implements',
},
];
const map = buildHeritageMap(heritage, ctx);
const parents = map.getParents('class:Admin');
expect(parents).toHaveLength(2);
expect(parents).toContain('class:User');
expect(parents).toContain('iface:Serializable');
});
it('returns empty array for unknown nodeId', () => {
const map = buildHeritageMap([], ctx);
expect(map.getParents('class:NonExistent')).toEqual([]);
});
it('skips heritage records where child class is not in symbol table', () => {
ctx.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class');
const heritage: ExtractedHeritage[] = [
{
filePath: 'src/child.ts',
className: 'Unknown',
parentName: 'Parent',
kind: 'extends',
},
];
const map = buildHeritageMap(heritage, ctx);
// No child resolved, so no entries
expect(map.getParents('class:Parent')).toEqual([]);
});
it('skips heritage records where parent class is not in symbol table', () => {
ctx.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class');
const heritage: ExtractedHeritage[] = [
{
filePath: 'src/child.ts',
className: 'Child',
parentName: 'Unknown',
kind: 'extends',
},
];
const map = buildHeritageMap(heritage, ctx);
expect(map.getParents('class:Child')).toEqual([]);
});
it('skips self-references', () => {
ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class');
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/a.ts', className: 'A', parentName: 'A', kind: 'extends' },
];
const map = buildHeritageMap(heritage, ctx);
expect(map.getParents('class:A')).toEqual([]);
});
it('deduplicates cross-chunk duplicates', () => {
ctx.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class');
ctx.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class');
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/child.ts', className: 'Child', parentName: 'Parent', kind: 'extends' },
{ filePath: 'src/child.ts', className: 'Child', parentName: 'Parent', kind: 'extends' },
];
const map = buildHeritageMap(heritage, ctx);
expect(map.getParents('class:Child')).toEqual(['class:Parent']);
});
});
// ── getAncestors ────────────────────────────────────────────────────
describe('getAncestors', () => {
it('returns full ancestor chain for multi-level inheritance', () => {
ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class');
ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class');
ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class');
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/c.ts', className: 'C', parentName: 'B', kind: 'extends' },
{ filePath: 'src/b.ts', className: 'B', parentName: 'A', kind: 'extends' },
];
const map = buildHeritageMap(heritage, ctx);
const ancestors = map.getAncestors('class:C');
expect(ancestors).toHaveLength(2);
expect(ancestors).toContain('class:B');
expect(ancestors).toContain('class:A');
});
it('handles diamond inheritance without duplicates', () => {
// A
// / \
// B C
// \ /
// D
ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class');
ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class');
ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class');
ctx.symbols.add('src/d.ts', 'D', 'class:D', 'Class');
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/d.ts', className: 'D', parentName: 'B', kind: 'extends' },
{ filePath: 'src/d.ts', className: 'D', parentName: 'C', kind: 'implements' },
{ filePath: 'src/b.ts', className: 'B', parentName: 'A', kind: 'extends' },
{ filePath: 'src/c.ts', className: 'C', parentName: 'A', kind: 'extends' },
];
const map = buildHeritageMap(heritage, ctx);
const ancestors = map.getAncestors('class:D');
expect(ancestors).toHaveLength(3); // B, C, A — no duplicates
expect(ancestors).toContain('class:B');
expect(ancestors).toContain('class:C');
expect(ancestors).toContain('class:A');
});
it('protects against cycles', () => {
ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class');
ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class');
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/a.ts', className: 'A', parentName: 'B', kind: 'extends' },
{ filePath: 'src/b.ts', className: 'B', parentName: 'A', kind: 'extends' },
];
const map = buildHeritageMap(heritage, ctx);
// Should not infinite-loop; each visited once
const ancestorsA = map.getAncestors('class:A');
expect(ancestorsA).toEqual(['class:B']);
const ancestorsB = map.getAncestors('class:B');
expect(ancestorsB).toEqual(['class:A']);
});
it('protects against multi-node cycles (A→B→C→A)', () => {
ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class');
ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class');
ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class');
// A → B → C → A (3-node cycle)
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/a.ts', className: 'A', parentName: 'B', kind: 'extends' },
{ filePath: 'src/b.ts', className: 'B', parentName: 'C', kind: 'extends' },
{ filePath: 'src/c.ts', className: 'C', parentName: 'A', kind: 'extends' },
];
const map = buildHeritageMap(heritage, ctx);
const ancestors = map.getAncestors('class:A');
// Should visit B and C but not loop back to A
expect(ancestors).toHaveLength(2);
expect(ancestors).toContain('class:B');
expect(ancestors).toContain('class:C');
});
it('returns empty array for node with no parents', () => {
ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class');
const map = buildHeritageMap([], ctx);
expect(map.getAncestors('class:A')).toEqual([]);
});
it('returns empty array for unknown nodeId', () => {
const map = buildHeritageMap([], ctx);
expect(map.getAncestors('class:NonExistent')).toEqual([]);
});
it('handles deep inheritance chain (bounded depth)', () => {
// Build a chain of 40 levels — should be bounded by MAX_ANCESTOR_DEPTH (32)
const heritage: ExtractedHeritage[] = [];
for (let i = 0; i < 40; i++) {
const childName = `Level${i}`;
const parentName = `Level${i + 1}`;
ctx.symbols.add(`src/${childName}.ts`, childName, `class:${childName}`, 'Class');
if (i === 39) {
ctx.symbols.add(`src/${parentName}.ts`, parentName, `class:${parentName}`, 'Class');
}
heritage.push({
filePath: `src/${childName}.ts`,
className: childName,
parentName: parentName,
kind: 'extends',
});
}
const map = buildHeritageMap(heritage, ctx);
const ancestors = map.getAncestors('class:Level0');
// Strictly linear chain of depth > MAX_ANCESTOR_DEPTH must terminate
// at exactly 32 BFS iterations. The tight `toBe(32)` guards against a
// future regression that silently returns fewer ancestors.
expect(ancestors.length).toBe(32);
// First ancestor should be the direct parent
expect(ancestors[0]).toBe('class:Level1');
// Last ancestor should be the 32nd level — beyond that is cut off
expect(ancestors[31]).toBe('class:Level32');
});
});
// ── empty heritage ──────────────────────────────────────────────────
describe('empty heritage', () => {
it('returns empty results for empty heritage array', () => {
const map = buildHeritageMap([], ctx);
expect(map.getParents('any')).toEqual([]);
expect(map.getAncestors('any')).toEqual([]);
expect(map.getImplementorFiles('any').size).toBe(0);
});
});
// ── getImplementorFiles ─────────────────────────────────────────────
describe('getImplementorFiles', () => {
it('records direct implements edges per interface name', () => {
ctx.symbols.add('a.java', 'C', 'class:C', 'Class');
ctx.symbols.add('b.java', 'D', 'class:D', 'Class');
ctx.symbols.add('iface.java', 'Runnable', 'iface:Runnable', 'Interface');
const heritage: ExtractedHeritage[] = [
{ filePath: 'a.java', className: 'C', parentName: 'Runnable', kind: 'implements' },
{ filePath: 'b.java', className: 'D', parentName: 'Runnable', kind: 'implements' },
];
const map = buildHeritageMap(heritage, ctx);
expect(map.getImplementorFiles('Runnable')).toEqual(new Set(['a.java', 'b.java']));
});
it('only records implementors for interface parents, not class parents', () => {
ctx.symbols.add('a.java', 'C', 'class:C', 'Class');
ctx.symbols.add('base.java', 'Base', 'class:Base', 'Class');
ctx.symbols.add('iface.java', 'I', 'iface:I', 'Interface');
const heritage: ExtractedHeritage[] = [
{ filePath: 'a.java', className: 'C', parentName: 'Base', kind: 'extends' },
{ filePath: 'a.java', className: 'C', parentName: 'I', kind: 'implements' },
];
const map = buildHeritageMap(heritage, ctx);
expect(map.getImplementorFiles('Base').size).toBe(0);
expect(map.getImplementorFiles('I')).toEqual(new Set(['a.java']));
});
it('returns empty set for unknown interface name', () => {
const map = buildHeritageMap([], ctx);
const result = map.getImplementorFiles('NonExistent');
expect(result.size).toBe(0);
});
it('records C# extends→IMPLEMENTS via interfaceNamePattern when parent is unresolved', () => {
// C# provider has interfaceNamePattern: /^I[A-Z]/.
// Only the child class is registered; the parent interface has no symbol.
// resolveExtendsType must fall through to the provider heuristic and
// classify `IDisposable` as IMPLEMENTS.
ctx.symbols.add('src/Service.cs', 'Service', 'class:Service', 'Class');
const heritage: ExtractedHeritage[] = [
{
filePath: 'src/Service.cs',
className: 'Service',
parentName: 'IDisposable',
kind: 'extends',
},
];
const map = buildHeritageMap(heritage, ctx);
expect(map.getImplementorFiles('IDisposable')).toEqual(new Set(['src/Service.cs']));
});
it('records Swift extends→IMPLEMENTS via heritageDefaultEdge when parent is unresolved', () => {
// Swift provider has heritageDefaultEdge: 'IMPLEMENTS'.
// Unresolved parents should default to IMPLEMENTS (protocol conformance).
ctx.symbols.add('src/MyView.swift', 'MyView', 'class:MyView', 'Class');
const heritage: ExtractedHeritage[] = [
{
filePath: 'src/MyView.swift',
className: 'MyView',
parentName: 'SomeProtocol',
kind: 'extends',
},
];
const map = buildHeritageMap(heritage, ctx);
expect(map.getImplementorFiles('SomeProtocol')).toEqual(new Set(['src/MyView.swift']));
});
it('records Java extends→IMPLEMENTS when parent is registered as an Interface symbol', () => {
// Java/C# path: when ctx.resolve finds a matching symbol whose type is
// Interface, resolveExtendsType returns IMPLEMENTS via the symbol lookup
// (not the interfaceNamePattern fallback).
ctx.symbols.add('src/Impl.java', 'Impl', 'class:Impl', 'Class');
ctx.symbols.add('src/MyContract.java', 'MyContract', 'iface:MyContract', 'Interface');
const heritage: ExtractedHeritage[] = [
{
filePath: 'src/Impl.java',
className: 'Impl',
parentName: 'MyContract',
kind: 'extends',
},
];
const map = buildHeritageMap(heritage, ctx);
expect(map.getImplementorFiles('MyContract')).toEqual(new Set(['src/Impl.java']));
});
it('records Kotlin implements edges', () => {
ctx.symbols.add('src/Impl.kt', 'Impl', 'class:Impl', 'Class');
ctx.symbols.add('src/Iface.kt', 'Iface', 'iface:Iface', 'Interface');
const heritage: ExtractedHeritage[] = [
{
filePath: 'src/Impl.kt',
className: 'Impl',
parentName: 'Iface',
kind: 'implements',
},
];
const map = buildHeritageMap(heritage, ctx);
expect(map.getImplementorFiles('Iface')).toEqual(new Set(['src/Impl.kt']));
});
it('records TypeScript implements edges', () => {
ctx.symbols.add('src/Service.ts', 'UserService', 'class:UserService', 'Class');
ctx.symbols.add('src/IService.ts', 'IUserService', 'iface:IUserService', 'Interface');
const heritage: ExtractedHeritage[] = [
{
filePath: 'src/Service.ts',
className: 'UserService',
parentName: 'IUserService',
kind: 'implements',
},
];
const map = buildHeritageMap(heritage, ctx);
expect(map.getImplementorFiles('IUserService')).toEqual(new Set(['src/Service.ts']));
});
it('records PHP implements edges', () => {
ctx.symbols.add('src/Impl.php', 'Impl', 'class:Impl', 'Class');
ctx.symbols.add('src/Iface.php', 'Iface', 'iface:Iface', 'Interface');
const heritage: ExtractedHeritage[] = [
{
filePath: 'src/Impl.php',
className: 'Impl',
parentName: 'Iface',
kind: 'implements',
},
];
const map = buildHeritageMap(heritage, ctx);
expect(map.getImplementorFiles('Iface')).toEqual(new Set(['src/Impl.php']));
});
it('does not record Rust trait-impl entries in the implementor index', () => {
// Documented limitation: trait-impl is intentionally not added to the
// implementor index — interface dispatch does not traverse trait objects.
ctx.symbols.add('src/point.rs', 'Point', 'struct:Point', 'Struct');
ctx.symbols.add('src/display.rs', 'Display', 'trait:Display', 'Interface');
const heritage: ExtractedHeritage[] = [
{
filePath: 'src/point.rs',
className: 'Point',
parentName: 'Display',
kind: 'trait-impl',
},
];
const map = buildHeritageMap(heritage, ctx);
expect(map.getImplementorFiles('Display').size).toBe(0);
// Parent lookup still works — only the implementor index skips trait-impl.
expect(map.getParents('struct:Point')).toEqual(['trait:Display']);
});
it('heritage merged across chunks matches single-pass (chunk-order invariant)', () => {
ctx.symbols.add('a.java', 'A', 'class:A', 'Class');
ctx.symbols.add('b.java', 'B', 'class:B', 'Class');
ctx.symbols.add('iface.java', 'Iface', 'iface:Iface', 'Interface');
const chunk1: ExtractedHeritage[] = [
{ filePath: 'a.java', className: 'A', parentName: 'Iface', kind: 'implements' },
];
const chunk2: ExtractedHeritage[] = [
{ filePath: 'b.java', className: 'B', parentName: 'Iface', kind: 'implements' },
];
const oneShot = buildHeritageMap([...chunk1, ...chunk2], ctx);
expect(oneShot.getImplementorFiles('Iface')).toEqual(new Set(['a.java', 'b.java']));
});
});
// ── chunk-order invariant ───────────────────────────────────────────
describe('chunk-order invariant', () => {
it('produces same result regardless of heritage record order', () => {
ctx.symbols.add('src/d.ts', 'D', 'class:D', 'Class');
ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class');
ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class');
ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class');
const heritage1: ExtractedHeritage[] = [
{ filePath: 'src/d.ts', className: 'D', parentName: 'C', kind: 'extends' },
{ filePath: 'src/c.ts', className: 'C', parentName: 'B', kind: 'extends' },
{ filePath: 'src/b.ts', className: 'B', parentName: 'A', kind: 'extends' },
];
const heritage2: ExtractedHeritage[] = [
{ filePath: 'src/b.ts', className: 'B', parentName: 'A', kind: 'extends' },
{ filePath: 'src/d.ts', className: 'D', parentName: 'C', kind: 'extends' },
{ filePath: 'src/c.ts', className: 'C', parentName: 'B', kind: 'extends' },
];
const map1 = buildHeritageMap(heritage1, ctx);
const map2 = buildHeritageMap(heritage2, ctx);
expect(map1.getParents('class:D').sort()).toEqual(map2.getParents('class:D').sort());
expect(map1.getAncestors('class:D').sort()).toEqual(map2.getAncestors('class:D').sort());
});
});
});

View file

@ -88,7 +88,12 @@ describe('SymbolTable', () => {
describe('getStats', () => {
it('returns zero counts for empty table', () => {
expect(table.getStats()).toEqual({ fileCount: 0, globalSymbolCount: 0 });
expect(table.getStats()).toEqual({
fileCount: 0,
globalSymbolCount: 0,
fuzzyCallCount: 0,
fuzzyCallableCallCount: 0,
});
});
it('tracks unique file count correctly', () => {
@ -383,12 +388,18 @@ describe('SymbolTable', () => {
expect(table.lookupMethodByOwner('class:Handler', 'process')).toBeUndefined();
});
it('does NOT index Constructor in methodByOwner', () => {
it('indexes Constructor in methodByOwner', () => {
table.add('src/models.ts', 'User', 'ctor:User', 'Constructor', {
parameterCount: 0,
ownerId: 'class:User',
});
expect(table.lookupMethodByOwner('class:User', 'User')).toBeUndefined();
expect(table.lookupMethodByOwner('class:User', 'User')).toEqual({
nodeId: 'ctor:User',
filePath: 'src/models.ts',
type: 'Constructor',
parameterCount: 0,
ownerId: 'class:User',
});
// But it should be in lookupFuzzyCallable
expect(table.lookupFuzzyCallable('User')).toHaveLength(1);
});
@ -465,7 +476,7 @@ describe('SymbolTable', () => {
});
describe('clear', () => {
it('resets all state including fieldByOwner and methodByOwner', () => {
it('resets all state including fieldByOwner, methodByOwner, and classByName', () => {
table.add('src/a.ts', 'foo', 'func:foo', 'Function');
table.add('src/b.ts', 'bar', 'func:bar', 'Function');
table.add('src/models.ts', 'address', 'prop:address', 'Property', {
@ -476,20 +487,32 @@ describe('SymbolTable', () => {
returnType: 'void',
ownerId: 'class:User',
});
table.add('src/models.ts', 'User', 'class:User', 'Class');
table.clear();
expect(table.getStats()).toEqual({ fileCount: 0, globalSymbolCount: 0 });
expect(table.getStats()).toEqual({
fileCount: 0,
globalSymbolCount: 0,
fuzzyCallCount: 0,
fuzzyCallableCallCount: 0,
});
expect(table.lookupExact('src/a.ts', 'foo')).toBeUndefined();
expect(table.lookupFuzzy('foo')).toEqual([]);
expect(table.lookupFieldByOwner('class:User', 'address')).toBeUndefined();
expect(table.lookupMethodByOwner('class:User', 'save')).toBeUndefined();
expect(table.lookupFuzzyCallable('foo')).toEqual([]);
expect(table.lookupClassByName('User')).toEqual([]);
});
it('allows re-adding after clear', () => {
table.add('src/a.ts', 'foo', 'func:foo', 'Function');
table.clear();
table.add('src/b.ts', 'bar', 'func:bar', 'Function');
expect(table.getStats()).toEqual({ fileCount: 1, globalSymbolCount: 1 });
expect(table.getStats()).toEqual({
fileCount: 1,
globalSymbolCount: 1,
fuzzyCallCount: 0,
fuzzyCallableCallCount: 0,
});
});
it('resets callableIndex so first lookup after clear rebuilds from scratch', () => {
@ -713,4 +736,665 @@ describe('SymbolTable', () => {
expect(table.lookupFieldByOwner('class:A\0id', '')).toBeUndefined();
});
});
describe('lookupClassByName', () => {
it('returns Class definitions by name', () => {
table.add('src/models.ts', 'User', 'class:User', 'Class');
const results = table.lookupClassByName('User');
expect(results).toHaveLength(1);
expect(results[0]).toEqual({
nodeId: 'class:User',
filePath: 'src/models.ts',
type: 'Class',
qualifiedName: 'User',
});
});
it('returns Struct definitions by name', () => {
table.add('src/models.rs', 'Point', 'struct:Point', 'Struct');
const results = table.lookupClassByName('Point');
expect(results).toHaveLength(1);
expect(results[0].type).toBe('Struct');
});
it('returns Interface definitions by name', () => {
table.add('src/types.ts', 'Serializable', 'iface:Serializable', 'Interface');
const results = table.lookupClassByName('Serializable');
expect(results).toHaveLength(1);
expect(results[0].type).toBe('Interface');
});
it('returns Enum definitions by name', () => {
table.add('src/types.ts', 'Color', 'enum:Color', 'Enum');
const results = table.lookupClassByName('Color');
expect(results).toHaveLength(1);
expect(results[0].type).toBe('Enum');
});
it('returns Record definitions by name', () => {
table.add('src/models.java', 'Config', 'record:Config', 'Record');
const results = table.lookupClassByName('Config');
expect(results).toHaveLength(1);
expect(results[0].type).toBe('Record');
});
it('does NOT include Function with the same name', () => {
table.add('src/models.ts', 'User', 'class:User', 'Class');
table.add('src/utils.ts', 'User', 'func:User', 'Function');
const results = table.lookupClassByName('User');
expect(results).toHaveLength(1);
expect(results[0].type).toBe('Class');
expect(results[0].nodeId).toBe('class:User');
});
it('does NOT include Method, Variable, Property, or Constructor', () => {
table.add('src/a.ts', 'Foo', 'method:Foo', 'Method');
table.add('src/a.ts', 'Bar', 'var:Bar', 'Variable');
table.add('src/a.ts', 'Baz', 'prop:Baz', 'Property');
table.add('src/a.ts', 'Qux', 'ctor:Qux', 'Constructor');
expect(table.lookupClassByName('Foo')).toEqual([]);
expect(table.lookupClassByName('Bar')).toEqual([]);
expect(table.lookupClassByName('Baz')).toEqual([]);
expect(table.lookupClassByName('Qux')).toEqual([]);
});
it('does NOT include other type-like labels outside the allowed class set', () => {
table.add('src/a.rs', 'User', 'trait:User', 'Trait');
table.add('src/a.ts', 'User', 'type:User', 'Type');
expect(table.lookupClassByName('User')).toEqual([]);
});
it('returns multiple classes with the same name from different files', () => {
table.add('src/models/user.ts', 'User', 'class:user:User', 'Class');
table.add('src/dto/user.ts', 'User', 'class:dto:User', 'Class');
const results = table.lookupClassByName('User');
expect(results).toHaveLength(2);
expect(results[0].filePath).toBe('src/models/user.ts');
expect(results[1].filePath).toBe('src/dto/user.ts');
});
it('returns empty array for unknown name', () => {
table.add('src/models.ts', 'User', 'class:User', 'Class');
expect(table.lookupClassByName('NonExistent')).toEqual([]);
});
it('returns empty array for empty table', () => {
expect(table.lookupClassByName('User')).toEqual([]);
});
it('after clear(), returns empty array', () => {
table.add('src/models.ts', 'User', 'class:User', 'Class');
expect(table.lookupClassByName('User')).toHaveLength(1);
table.clear();
expect(table.lookupClassByName('User')).toEqual([]);
});
it('returns mixed class-like types with the same name', () => {
// e.g. a Class and an Interface both named 'Comparable' in different files
table.add('src/base.ts', 'Comparable', 'class:Comparable', 'Class');
table.add('src/types.ts', 'Comparable', 'iface:Comparable', 'Interface');
const results = table.lookupClassByName('Comparable');
expect(results).toHaveLength(2);
expect(results.map((r) => r.type)).toEqual(['Class', 'Interface']);
});
it('preserves metadata on indexed class definitions', () => {
table.add('src/models.ts', 'User', 'class:User', 'Class', {
returnType: 'User',
ownerId: 'module:models',
});
const results = table.lookupClassByName('User');
expect(results).toHaveLength(1);
expect(results[0].ownerId).toBe('module:models');
});
it('class-like symbols are still available via lookupFuzzy', () => {
table.add('src/models.ts', 'User', 'class:User', 'Class');
// classByName is an additional index, not a replacement for globalIndex
expect(table.lookupFuzzy('User')).toHaveLength(1);
expect(table.lookupClassByName('User')).toHaveLength(1);
});
it('allows re-adding after clear and returns correct results', () => {
table.add('src/models.ts', 'User', 'class:User:v1', 'Class');
table.clear();
table.add('src/models.ts', 'User', 'class:User:v2', 'Class');
const results = table.lookupClassByName('User');
expect(results).toHaveLength(1);
expect(results[0].nodeId).toBe('class:User:v2');
});
});
describe('lookupClassByQualifiedName', () => {
it('indexes class-like definitions by qualified name without replacing simple-name lookup', () => {
table.add('src/services/user.cs', 'User', 'class:services:User', 'Class', {
qualifiedName: 'Services.User',
});
table.add('src/data/user.cs', 'User', 'class:data:User', 'Class', {
qualifiedName: 'Data.User',
});
expect(table.lookupClassByName('User')).toHaveLength(2);
expect(table.lookupClassByQualifiedName('Services.User')).toEqual([
{
nodeId: 'class:services:User',
filePath: 'src/services/user.cs',
type: 'Class',
qualifiedName: 'Services.User',
},
]);
const dataUserMatches = table.lookupClassByQualifiedName('Data.User');
expect(dataUserMatches).toHaveLength(1);
expect(dataUserMatches[0].qualifiedName).toBe('Data.User');
});
it('falls back to the simple name when no qualified metadata is provided', () => {
table.add('src/models.ts', 'User', 'class:User', 'Class');
expect(table.lookupClassByQualifiedName('User')).toEqual([
{
nodeId: 'class:User',
filePath: 'src/models.ts',
type: 'Class',
qualifiedName: 'User',
},
]);
});
it('returns empty array for non-class-like types even when qualified metadata is present', () => {
table.add('src/utils.ts', 'User', 'func:User', 'Function', {
qualifiedName: 'Services.User',
});
expect(table.lookupClassByQualifiedName('Services.User')).toEqual([]);
});
it('after clear(), returns empty array', () => {
table.add('src/services/user.cs', 'User', 'class:User', 'Class', {
qualifiedName: 'Services.User',
});
expect(table.lookupClassByQualifiedName('Services.User')).toHaveLength(1);
table.clear();
expect(table.lookupClassByQualifiedName('Services.User')).toEqual([]);
});
});
});
// ---------------------------------------------------------------------------
// lookupMethodByOwnerWithMRO — MRO-aware method resolution via HeritageMap
// ---------------------------------------------------------------------------
import { buildHeritageMap } from '../../src/core/ingestion/heritage-map.js';
import { lookupMethodByOwnerWithMRO } from '../../src/core/ingestion/call-processor.js';
import {
createResolutionContext,
type ResolutionContext,
} from '../../src/core/ingestion/resolution-context.js';
import { SupportedLanguages } from 'gitnexus-shared';
import type { ExtractedHeritage } from '../../src/core/ingestion/workers/parse-worker.js';
describe('lookupMethodByOwnerWithMRO', () => {
let ctx: ResolutionContext;
beforeEach(() => {
ctx = createResolutionContext();
});
it('child.parentMethod() resolves to Parent#parentMethod via MRO walk', () => {
ctx.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class');
ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class');
ctx.symbols.add('src/parent.java', 'parentMethod', 'method:Parent:parentMethod', 'Method', {
returnType: 'String',
ownerId: 'class:Parent',
});
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/child.java', className: 'Child', parentName: 'Parent', kind: 'extends' },
];
const map = buildHeritageMap(heritage, ctx);
const result = lookupMethodByOwnerWithMRO(
'class:Child',
'parentMethod',
map,
ctx.symbols,
SupportedLanguages.Java,
);
expect(result).toBeDefined();
expect(result!.nodeId).toBe('method:Parent:parentMethod');
expect(result!.returnType).toBe('String');
});
it('child override returns child version (direct hit, no walk)', () => {
ctx.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class');
ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class');
ctx.symbols.add('src/parent.java', 'save', 'method:Parent:save', 'Method', {
returnType: 'void',
ownerId: 'class:Parent',
});
ctx.symbols.add('src/child.java', 'save', 'method:Child:save', 'Method', {
returnType: 'void',
ownerId: 'class:Child',
});
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/child.java', className: 'Child', parentName: 'Parent', kind: 'extends' },
];
const map = buildHeritageMap(heritage, ctx);
const result = lookupMethodByOwnerWithMRO(
'class:Child',
'save',
map,
ctx.symbols,
SupportedLanguages.Java,
);
expect(result).toBeDefined();
expect(result!.nodeId).toBe('method:Child:save');
});
it('3-level inheritance: grandchild → child → parent, method on parent found', () => {
ctx.symbols.add('src/a.java', 'A', 'class:A', 'Class');
ctx.symbols.add('src/b.java', 'B', 'class:B', 'Class');
ctx.symbols.add('src/c.java', 'C', 'class:C', 'Class');
ctx.symbols.add('src/a.java', 'greet', 'method:A:greet', 'Method', {
returnType: 'Greeting',
ownerId: 'class:A',
});
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/c.java', className: 'C', parentName: 'B', kind: 'extends' },
{ filePath: 'src/b.java', className: 'B', parentName: 'A', kind: 'extends' },
];
const map = buildHeritageMap(heritage, ctx);
const result = lookupMethodByOwnerWithMRO(
'class:C',
'greet',
map,
ctx.symbols,
SupportedLanguages.Java,
);
expect(result).toBeDefined();
expect(result!.nodeId).toBe('method:A:greet');
expect(result!.returnType).toBe('Greeting');
});
it('diamond pattern: first-wins strategy returns first ancestor match in BFS order', () => {
ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class');
ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class');
ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class');
ctx.symbols.add('src/d.ts', 'D', 'class:D', 'Class');
ctx.symbols.add('src/b.ts', 'foo', 'method:B:foo', 'Method', {
returnType: 'String',
ownerId: 'class:B',
});
ctx.symbols.add('src/c.ts', 'foo', 'method:C:foo', 'Method', {
returnType: 'String',
ownerId: 'class:C',
});
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/d.ts', className: 'D', parentName: 'B', kind: 'extends' },
{ filePath: 'src/d.ts', className: 'D', parentName: 'C', kind: 'extends' },
{ filePath: 'src/b.ts', className: 'B', parentName: 'A', kind: 'extends' },
{ filePath: 'src/c.ts', className: 'C', parentName: 'A', kind: 'extends' },
];
const map = buildHeritageMap(heritage, ctx);
// TypeScript uses 'first-wins' — B is first parent, so B.foo wins
const result = lookupMethodByOwnerWithMRO(
'class:D',
'foo',
map,
ctx.symbols,
SupportedLanguages.TypeScript,
);
expect(result).toBeDefined();
expect(result!.nodeId).toBe('method:B:foo');
});
it('diamond pattern: c3 strategy uses C3 linearization order', () => {
ctx.symbols.add('src/a.py', 'A', 'class:A', 'Class');
ctx.symbols.add('src/b.py', 'B', 'class:B', 'Class');
ctx.symbols.add('src/c.py', 'C', 'class:C', 'Class');
ctx.symbols.add('src/d.py', 'D', 'class:D', 'Class');
ctx.symbols.add('src/b.py', 'foo', 'method:B:foo', 'Method', {
returnType: 'str',
ownerId: 'class:B',
});
ctx.symbols.add('src/c.py', 'foo', 'method:C:foo', 'Method', {
returnType: 'str',
ownerId: 'class:C',
});
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/d.py', className: 'D', parentName: 'B', kind: 'extends' },
{ filePath: 'src/d.py', className: 'D', parentName: 'C', kind: 'extends' },
{ filePath: 'src/b.py', className: 'B', parentName: 'A', kind: 'extends' },
{ filePath: 'src/c.py', className: 'C', parentName: 'A', kind: 'extends' },
];
const map = buildHeritageMap(heritage, ctx);
// Python uses 'c3' — C3 linearization for D(B,C): [B, C, A]
const result = lookupMethodByOwnerWithMRO(
'class:D',
'foo',
map,
ctx.symbols,
SupportedLanguages.Python,
);
expect(result).toBeDefined();
// C3 linearization resolves to B before C in this hierarchy
expect(result!.nodeId).toBe('method:B:foo');
});
it('qualified-syntax (Rust): returns undefined for inherited methods', () => {
ctx.symbols.add('src/parent.rs', 'Parent', 'class:Parent', 'Class');
ctx.symbols.add('src/child.rs', 'Child', 'class:Child', 'Class');
ctx.symbols.add('src/parent.rs', 'process', 'method:Parent:process', 'Method', {
returnType: 'void',
ownerId: 'class:Parent',
});
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/child.rs', className: 'Child', parentName: 'Parent', kind: 'extends' },
];
const map = buildHeritageMap(heritage, ctx);
const result = lookupMethodByOwnerWithMRO(
'class:Child',
'process',
map,
ctx.symbols,
SupportedLanguages.Rust,
);
// Rust requires qualified syntax — no auto-resolution
expect(result).toBeUndefined();
});
it('method not on any ancestor returns undefined', () => {
ctx.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class');
ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class');
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/child.java', className: 'Child', parentName: 'Parent', kind: 'extends' },
];
const map = buildHeritageMap(heritage, ctx);
const result = lookupMethodByOwnerWithMRO(
'class:Child',
'nonExistent',
map,
ctx.symbols,
SupportedLanguages.Java,
);
expect(result).toBeUndefined();
});
it('leftmost-base (C++): walks ancestors in BFS order', () => {
ctx.symbols.add('src/a.cpp', 'A', 'class:A', 'Class');
ctx.symbols.add('src/b.cpp', 'B', 'class:B', 'Class');
ctx.symbols.add('src/c.cpp', 'C', 'class:C', 'Class');
ctx.symbols.add('src/a.cpp', 'render', 'method:A:render', 'Method', {
returnType: 'void',
ownerId: 'class:A',
});
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/c.cpp', className: 'C', parentName: 'B', kind: 'extends' },
{ filePath: 'src/b.cpp', className: 'B', parentName: 'A', kind: 'extends' },
];
const map = buildHeritageMap(heritage, ctx);
const result = lookupMethodByOwnerWithMRO(
'class:C',
'render',
map,
ctx.symbols,
SupportedLanguages.CPlusPlus,
);
expect(result).toBeDefined();
expect(result!.nodeId).toBe('method:A:render');
});
it('implements-split (Java): walks ancestors to find inherited method', () => {
ctx.symbols.add('src/base.java', 'Base', 'class:Base', 'Class');
ctx.symbols.add('src/iface.java', 'IRepo', 'iface:IRepo', 'Interface');
ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class');
ctx.symbols.add('src/base.java', 'save', 'method:Base:save', 'Method', {
returnType: 'void',
ownerId: 'class:Base',
});
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/child.java', className: 'Child', parentName: 'Base', kind: 'extends' },
{
filePath: 'src/child.java',
className: 'Child',
parentName: 'IRepo',
kind: 'implements',
},
];
const map = buildHeritageMap(heritage, ctx);
const result = lookupMethodByOwnerWithMRO(
'class:Child',
'save',
map,
ctx.symbols,
SupportedLanguages.Java,
);
expect(result).toBeDefined();
expect(result!.nodeId).toBe('method:Base:save');
});
it('implements-split (Java): ambiguous default from two interfaces → BFS first-wins', () => {
// Java: class C implements I1, I2; both I1 and I2 declare the same
// default method. Full ambiguity detection (Java's "class must override
// conflicting defaults" rule) is deferred to computeMRO at the graph
// level. lookupMethodByOwnerWithMRO itself uses BFS order and returns
// the first match — this test pins that contract so a future regression
// that starts returning undefined (or flips the order) fails loudly.
ctx.symbols.add('src/I1.java', 'I1', 'iface:I1', 'Interface');
ctx.symbols.add('src/I2.java', 'I2', 'iface:I2', 'Interface');
ctx.symbols.add('src/C.java', 'C', 'class:C', 'Class');
ctx.symbols.add('src/I1.java', 'handle', 'method:I1:handle', 'Method', {
returnType: 'void',
ownerId: 'iface:I1',
});
ctx.symbols.add('src/I2.java', 'handle', 'method:I2:handle', 'Method', {
returnType: 'void',
ownerId: 'iface:I2',
});
// Insertion order is I1 then I2, so BFS returns I1 first.
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/C.java', className: 'C', parentName: 'I1', kind: 'implements' },
{ filePath: 'src/C.java', className: 'C', parentName: 'I2', kind: 'implements' },
];
const map = buildHeritageMap(heritage, ctx);
const result = lookupMethodByOwnerWithMRO(
'class:C',
'handle',
map,
ctx.symbols,
SupportedLanguages.Java,
);
expect(result).toBeDefined();
// BFS first-wins — I1 was declared first, so it wins.
expect(result!.nodeId).toBe('method:I1:handle');
});
it('implements-split (Java): class method takes precedence over interface default in BFS order', () => {
// Child extends Base implements IFoo. Both Base (class) and IFoo
// (interface) declare the same method. HeritageMap records extends
// before implements in the emitter's declaration order, so BFS visits
// Base before IFoo — class wins. Documents the current BFS-level
// behavior; the strict Java "class always wins" rule is enforced at
// the mro-processor graph pass.
ctx.symbols.add('src/Base.java', 'Base', 'class:Base', 'Class');
ctx.symbols.add('src/IFoo.java', 'IFoo', 'iface:IFoo', 'Interface');
ctx.symbols.add('src/Child.java', 'Child', 'class:Child', 'Class');
ctx.symbols.add('src/Base.java', 'handle', 'method:Base:handle', 'Method', {
returnType: 'void',
ownerId: 'class:Base',
});
ctx.symbols.add('src/IFoo.java', 'handle', 'method:IFoo:handle', 'Method', {
returnType: 'void',
ownerId: 'iface:IFoo',
});
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/Child.java', className: 'Child', parentName: 'Base', kind: 'extends' },
{ filePath: 'src/Child.java', className: 'Child', parentName: 'IFoo', kind: 'implements' },
];
const map = buildHeritageMap(heritage, ctx);
const result = lookupMethodByOwnerWithMRO(
'class:Child',
'handle',
map,
ctx.symbols,
SupportedLanguages.Java,
);
expect(result).toBeDefined();
expect(result!.nodeId).toBe('method:Base:handle');
});
it('implements-split (Kotlin): walks ancestors to find inherited method', () => {
ctx.symbols.add('src/base.kt', 'Base', 'class:Base', 'Class');
ctx.symbols.add('src/child.kt', 'Child', 'class:Child', 'Class');
ctx.symbols.add('src/base.kt', 'handle', 'method:Base:handle', 'Method', {
returnType: 'Unit',
ownerId: 'class:Base',
});
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/child.kt', className: 'Child', parentName: 'Base', kind: 'extends' },
];
const map = buildHeritageMap(heritage, ctx);
const result = lookupMethodByOwnerWithMRO(
'class:Child',
'handle',
map,
ctx.symbols,
SupportedLanguages.Kotlin,
);
expect(result).toBeDefined();
expect(result!.nodeId).toBe('method:Base:handle');
});
it('implements-split (C#): walks ancestors to find inherited method', () => {
ctx.symbols.add('src/Base.cs', 'Base', 'class:Base', 'Class');
ctx.symbols.add('src/Child.cs', 'Child', 'class:Child', 'Class');
ctx.symbols.add('src/Base.cs', 'Execute', 'method:Base:Execute', 'Method', {
returnType: 'void',
ownerId: 'class:Base',
});
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/Child.cs', className: 'Child', parentName: 'Base', kind: 'extends' },
];
const map = buildHeritageMap(heritage, ctx);
const result = lookupMethodByOwnerWithMRO(
'class:Child',
'Execute',
map,
ctx.symbols,
SupportedLanguages.CSharp,
);
expect(result).toBeDefined();
expect(result!.nodeId).toBe('method:Base:Execute');
});
it('first-wins (JavaScript): walks ancestors to find inherited method', () => {
// JavaScript provider is wired separately from TypeScript — this guards
// the provider wiring independent of the TS path.
ctx.symbols.add('src/animal.js', 'Animal', 'class:Animal', 'Class');
ctx.symbols.add('src/dog.js', 'Dog', 'class:Dog', 'Class');
ctx.symbols.add('src/animal.js', 'speak', 'method:Animal:speak', 'Method', {
returnType: 'string',
ownerId: 'class:Animal',
});
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/dog.js', className: 'Dog', parentName: 'Animal', kind: 'extends' },
];
const map = buildHeritageMap(heritage, ctx);
const result = lookupMethodByOwnerWithMRO(
'class:Dog',
'speak',
map,
ctx.symbols,
SupportedLanguages.JavaScript,
);
expect(result).toBeDefined();
expect(result!.nodeId).toBe('method:Animal:speak');
});
it('leftmost-base (C++): diamond inheritance resolves leftmost branch first', () => {
// Diamond: D extends B, C; B extends A; C extends A.
// Both B and C define render(). leftmost-base must return B#render (first
// branch in declaration order), not A#render or C#render.
ctx.symbols.add('src/a.cpp', 'A', 'class:A', 'Class');
ctx.symbols.add('src/b.cpp', 'B', 'class:B', 'Class');
ctx.symbols.add('src/c.cpp', 'C', 'class:C', 'Class');
ctx.symbols.add('src/d.cpp', 'D', 'class:D', 'Class');
ctx.symbols.add('src/a.cpp', 'render', 'method:A:render', 'Method', {
returnType: 'void',
ownerId: 'class:A',
});
ctx.symbols.add('src/b.cpp', 'render', 'method:B:render', 'Method', {
returnType: 'void',
ownerId: 'class:B',
});
ctx.symbols.add('src/c.cpp', 'render', 'method:C:render', 'Method', {
returnType: 'void',
ownerId: 'class:C',
});
const heritage: ExtractedHeritage[] = [
// Declaration order matters: B before C for leftmost-base semantics.
{ filePath: 'src/d.cpp', className: 'D', parentName: 'B', kind: 'extends' },
{ filePath: 'src/d.cpp', className: 'D', parentName: 'C', kind: 'extends' },
{ filePath: 'src/b.cpp', className: 'B', parentName: 'A', kind: 'extends' },
{ filePath: 'src/c.cpp', className: 'C', parentName: 'A', kind: 'extends' },
];
const map = buildHeritageMap(heritage, ctx);
const result = lookupMethodByOwnerWithMRO(
'class:D',
'render',
map,
ctx.symbols,
SupportedLanguages.CPlusPlus,
);
expect(result).toBeDefined();
// BFS via HeritageMap visits B before C (insertion order), so leftmost
// branch wins — matches C++ leftmost-base semantics for non-virtual base.
expect(result!.nodeId).toBe('method:B:render');
});
it('returns direct method on owner without walking (no heritage needed)', () => {
ctx.symbols.add('src/user.java', 'User', 'class:User', 'Class');
ctx.symbols.add('src/user.java', 'getName', 'method:User:getName', 'Method', {
returnType: 'String',
ownerId: 'class:User',
});
const map = buildHeritageMap([], ctx);
const result = lookupMethodByOwnerWithMRO(
'class:User',
'getName',
map,
ctx.symbols,
SupportedLanguages.Java,
);
expect(result).toBeDefined();
expect(result!.nodeId).toBe('method:User:getName');
});
});

View file

@ -1,5 +1,10 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { buildTypeEnv, type TypeEnvironment } from '../../src/core/ingestion/type-env.js';
import {
createSymbolTable,
type SymbolDefinition,
type SymbolTable,
} from '../../src/core/ingestion/symbol-table.js';
import {
stripNullable,
extractSimpleTypeName,
@ -16,6 +21,24 @@ import Kotlin from 'tree-sitter-kotlin';
import PHP from 'tree-sitter-php';
import Ruby from 'tree-sitter-ruby';
let Dart: unknown;
try {
Dart = require('tree-sitter-dart');
const testParser = new Parser();
testParser.setLanguage(Dart as Parser.Language);
} catch {
Dart = null;
}
let Swift: unknown;
try {
Swift = require('tree-sitter-swift');
const testParser = new Parser();
testParser.setLanguage(Swift as Parser.Language);
} catch {
Swift = null;
}
const parser = new Parser();
const parse = (code: string, lang: any) => {
@ -23,6 +46,21 @@ const parse = (code: string, lang: any) => {
return parser.parse(code);
};
const parseDart = (code: string) => {
if (!Dart) throw new Error('tree-sitter-dart not available');
parser.setLanguage(Dart as Parser.Language);
return parser.parse(code);
};
const parseSwift = (code: string) => {
if (!Swift) throw new Error('tree-sitter-swift not available');
parser.setLanguage(Swift as Parser.Language);
return parser.parse(code);
};
const describeDart = Dart ? describe : describe.skip;
const describeSwift = Swift ? describe : describe.skip;
/** Flatten a scoped TypeEnvironment into a simple name→type map (for simple test assertions). */
function flatGet(typeEnv: TypeEnvironment, varName: string): string | undefined {
for (const [, scopeMap] of typeEnv.allScopes()) {
@ -39,6 +77,37 @@ function flatSize(typeEnv: TypeEnvironment): number {
return count;
}
const createMockSymbolTable = (overrides: Partial<SymbolTable> = {}): SymbolTable => ({
add: () => {},
lookupExact: () => undefined,
lookupExactFull: () => undefined,
lookupExactAll: () => [],
lookupFuzzy: () => [],
lookupFuzzyCallable: () => [],
lookupFieldByOwner: () => undefined,
lookupMethodByOwner: () => undefined,
lookupClassByName: () => [],
lookupClassByQualifiedName: () => [],
getStats: () => ({
fileCount: 0,
globalSymbolCount: 0,
fuzzyCallCount: 0,
fuzzyCallableCallCount: 0,
}),
clear: () => {},
...overrides,
});
const createClassDef = (
name: string,
type: SymbolDefinition['type'] = 'Class',
filePath = `${name}.ts`,
): SymbolDefinition => ({
nodeId: `${type.toLowerCase()}:${name}`,
filePath,
type,
});
describe('buildTypeEnv', () => {
describe('TypeScript', () => {
it('extracts type from const declaration', () => {
@ -1134,6 +1203,7 @@ class RepoService {
type: 'Function' as const,
returnType: c.returnType,
})),
lookupClassByName: () => [],
lookupFuzzy: () => [],
lookupExact: () => undefined,
lookupExactFull: () => undefined,
@ -1979,7 +2049,7 @@ class RepoService {
);
// User is NOT defined in this file, but SymbolTable knows it's a Class
const mockSymbolTable = {
lookupFuzzy: (name: string) =>
lookupClassByName: (name: string) =>
name === 'User' ? [{ nodeId: 'n1', filePath: 'models.kt', type: 'Class' }] : [],
lookupExact: () => undefined,
lookupExactFull: () => undefined,
@ -2001,6 +2071,7 @@ class RepoService {
Kotlin,
);
const mockSymbolTable = {
lookupClassByName: () => [],
lookupFuzzy: (name: string) =>
name === 'doStuff' ? [{ nodeId: 'n1', filePath: 'utils.kt', type: 'Function' }] : [],
lookupFuzzyCallable: () => [],
@ -2076,6 +2147,698 @@ def main():
});
});
describe('lookupClassByName regression coverage', () => {
const makeClassLookupTable = (classDefs: Record<string, SymbolDefinition[]>) =>
createMockSymbolTable({
lookupClassByName: (name: string) => classDefs[name] ?? [],
});
it('Python cross-file constructor inference uses lookupClassByName', () => {
const tree = parse(
`
def main():
user = User("alice")
`,
Python,
);
const typeEnv = buildTypeEnv(tree, 'python', {
symbolTable: makeClassLookupTable({
User: [createClassDef('User', 'Class', 'models.py')],
}),
});
expect(flatGet(typeEnv, 'user')).toBe('User');
});
it('Python cross-file constructor inference does not bind plain functions', () => {
const tree = parse(
`
def main():
result = get_user()
`,
Python,
);
const typeEnv = buildTypeEnv(tree, 'python', {
symbolTable: makeClassLookupTable({}),
});
expect(flatGet(typeEnv, 'result')).toBeUndefined();
});
it('Python qualified cross-file constructor inference uses lookupClassByName', () => {
const tree = parse(
`
def main():
user = models.User("alice")
`,
Python,
);
const typeEnv = buildTypeEnv(tree, 'python', {
symbolTable: makeClassLookupTable({
User: [createClassDef('User', 'Class', 'models.py')],
}),
});
expect(flatGet(typeEnv, 'user')).toBe('User');
});
it('C++ cross-file constructor inference uses lookupClassByName', () => {
const tree = parse(
`
void run() {
auto user = User();
}
`,
CPP,
);
const typeEnv = buildTypeEnv(tree, 'cpp', {
symbolTable: makeClassLookupTable({
User: [createClassDef('User', 'Class', 'models.h')],
}),
});
expect(flatGet(typeEnv, 'user')).toBe('User');
});
it('C++ cross-file constructor inference does not bind plain functions', () => {
const tree = parse(
`
void run() {
auto result = getUser();
}
`,
CPP,
);
const typeEnv = buildTypeEnv(tree, 'cpp', {
symbolTable: makeClassLookupTable({}),
});
expect(flatGet(typeEnv, 'result')).toBeUndefined();
});
it('Ruby cross-file constructor inference uses lookupClassByName', () => {
const tree = parse(
`
def run
user = User.new
end
`,
Ruby,
);
const typeEnv = buildTypeEnv(tree, 'ruby', {
symbolTable: makeClassLookupTable({
User: [createClassDef('User', 'Class', 'models/user.rb')],
}),
});
expect(flatGet(typeEnv, 'user')).toBe('User');
});
it('Ruby namespaced constructor inference uses lookupClassByName', () => {
const tree = parse(
`
def run
service = Models::UserService.new
end
`,
Ruby,
);
const typeEnv = buildTypeEnv(tree, 'ruby', {
symbolTable: makeClassLookupTable({
UserService: [createClassDef('UserService', 'Class', 'models/user_service.rb')],
}),
});
expect(flatGet(typeEnv, 'service')).toBe('UserService');
});
it('Ruby cross-file constructor inference does not bind plain functions', () => {
const tree = parse(
`
def run
result = get_user()
end
`,
Ruby,
);
const typeEnv = buildTypeEnv(tree, 'ruby', {
symbolTable: makeClassLookupTable({}),
});
expect(flatGet(typeEnv, 'result')).toBeUndefined();
});
describeDart('Dart lookupClassByName regression coverage', () => {
it('Dart cross-file constructor inference uses lookupClassByName', () => {
const tree = parseDart(
`
void run() {
final user = User();
}
`,
);
const typeEnv = buildTypeEnv(tree, 'dart', {
symbolTable: makeClassLookupTable({
User: [createClassDef('User', 'Class', 'models.dart')],
}),
});
expect(flatGet(typeEnv, 'user')).toBe('User');
});
it('Dart named constructor inference uses lookupClassByName', () => {
const tree = parseDart(
`
void run() {
final user = User.named();
}
`,
);
const typeEnv = buildTypeEnv(tree, 'dart', {
symbolTable: makeClassLookupTable({
User: [createClassDef('User', 'Class', 'models.dart')],
}),
});
expect(flatGet(typeEnv, 'user')).toBe('User');
});
it('Dart cross-file constructor inference does not bind plain functions', () => {
const tree = parseDart(
`
void run() {
final result = getUser();
}
`,
);
const typeEnv = buildTypeEnv(tree, 'dart', {
symbolTable: makeClassLookupTable({}),
});
expect(flatGet(typeEnv, 'result')).toBeUndefined();
});
});
it('Rust unit-struct inference uses lookupClassByName', () => {
const tree = parse(
`
fn run() {
let service = UserService;
}
`,
Rust,
);
const typeEnv = buildTypeEnv(tree, 'rust', {
symbolTable: makeClassLookupTable({
UserService: [createClassDef('UserService', 'Struct', 'models.rs')],
}),
});
expect(flatGet(typeEnv, 'service')).toBe('UserService');
});
it('Rust unit-struct inference stays unresolved when lookupClassByName misses', () => {
const tree = parse(
`
fn run() {
let value = helper;
}
`,
Rust,
);
const typeEnv = buildTypeEnv(tree, 'rust', {
symbolTable: makeClassLookupTable({}),
});
expect(flatGet(typeEnv, 'value')).toBeUndefined();
});
describeSwift('Swift lookupClassByName regression coverage', () => {
it('Swift cross-file constructor inference uses lookupClassByName', () => {
const tree = parseSwift(
`
func run() {
let user = User(name: "alice")
}
`,
);
const typeEnv = buildTypeEnv(tree, 'swift', {
symbolTable: makeClassLookupTable({
User: [createClassDef('User', 'Class', 'Models/User.swift')],
}),
});
expect(flatGet(typeEnv, 'user')).toBe('User');
});
it('Swift explicit init inference uses lookupClassByName', () => {
const tree = parseSwift(
`
func run() {
let user = User.init(name: "alice")
}
`,
);
const typeEnv = buildTypeEnv(tree, 'swift', {
symbolTable: makeClassLookupTable({
User: [createClassDef('User', 'Class', 'Models/User.swift')],
}),
});
expect(flatGet(typeEnv, 'user')).toBe('User');
});
it('Swift cross-file constructor inference does not bind plain functions', () => {
const tree = parseSwift(
`
func run() {
let result = getUser()
}
`,
);
const typeEnv = buildTypeEnv(tree, 'swift', {
symbolTable: makeClassLookupTable({}),
});
expect(flatGet(typeEnv, 'result')).toBeUndefined();
});
});
it('field type resolution uses lookupClassByName-backed class defs', () => {
const tree = parse(
`
function process(user: User) {
const addr = user.address;
}
`,
TypeScript.typescript,
);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) =>
name === 'User' ? [createClassDef('User', 'Class', 'models.ts')] : [],
lookupFieldByOwner: (ownerNodeId: string, fieldName: string) =>
ownerNodeId === 'class:User' && fieldName === 'address'
? {
nodeId: 'prop:User:address',
filePath: 'models.ts',
type: 'Property' as const,
declaredType: 'Address',
}
: undefined,
});
const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable });
expect(flatGet(typeEnv, 'addr')).toBe('Address');
});
it('field type resolution stays unresolved when lookupClassByName finds no class', () => {
const tree = parse(
`
function process(user: User) {
const addr = user.address;
}
`,
TypeScript.typescript,
);
const symbolTable = createMockSymbolTable({
lookupClassByName: () => [],
});
const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable });
expect(flatGet(typeEnv, 'addr')).toBeUndefined();
});
it('method return type resolution uses lookupMethodByOwner-backed class defs', () => {
const tree = parse(
`
function process(repo: Repo) {
const profile = repo.getProfile();
}
`,
TypeScript.typescript,
);
const lookupFuzzyCallable = vi.fn(() => []);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) =>
name === 'Repo' ? [createClassDef('Repo', 'Class', 'models.ts')] : [],
lookupMethodByOwner: (ownerNodeId: string, methodName: string) =>
ownerNodeId === 'class:Repo' && methodName === 'getProfile'
? {
nodeId: 'method:Repo:getProfile',
filePath: 'models.ts',
type: 'Method',
ownerId: 'class:Repo',
returnType: 'Profile',
}
: undefined,
lookupFuzzyCallable,
});
const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable });
expect(flatGet(typeEnv, 'profile')).toBe('Profile');
expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile');
});
it('inherited method return type resolution uses lookupMethodByOwner on parent owners', () => {
const tree = parse(
`
function process(repo: Repo) {
const profile = repo.getProfile();
}
`,
TypeScript.typescript,
);
const lookupFuzzyCallable = vi.fn(() => []);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) => {
if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')];
if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')];
return [];
},
lookupMethodByOwner: (ownerNodeId: string, methodName: string) =>
ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile'
? {
nodeId: 'method:BaseRepo:getProfile',
filePath: 'base.ts',
type: 'Method',
ownerId: 'class:BaseRepo',
returnType: 'Profile',
}
: undefined,
lookupFuzzyCallable,
});
const typeEnv = buildTypeEnv(tree, 'typescript', {
symbolTable,
parentMap: new Map([['Repo', ['BaseRepo']]]),
});
expect(flatGet(typeEnv, 'profile')).toBe('Profile');
expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile');
});
it('method return type resolution handles multiple class defs when only one owner has the method', () => {
const tree = parse(
`
function process(repo: Repo) {
const profile = repo.getProfile();
}
`,
TypeScript.typescript,
);
const lookupFuzzyCallable = vi.fn(() => []);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) =>
name === 'Repo'
? [
createClassDef('Repo', 'Class', 'models-a.ts'),
{
...createClassDef('Repo', 'Class', 'models-b.ts'),
nodeId: 'class:Repo:partial',
},
]
: [],
lookupMethodByOwner: (ownerNodeId: string, methodName: string) =>
ownerNodeId === 'class:Repo:partial' && methodName === 'getProfile'
? {
nodeId: 'method:Repo:getProfile',
filePath: 'models-b.ts',
type: 'Method',
ownerId: 'class:Repo:partial',
returnType: 'Profile',
}
: undefined,
lookupExactAll: () => [],
lookupFuzzyCallable,
});
const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable });
expect(flatGet(typeEnv, 'profile')).toBe('Profile');
expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile');
});
it('method return type resolution with multiple class defs falls back to MRO when direct owners miss', () => {
const tree = parse(
`
function process(repo: Repo) {
const profile = repo.getProfile();
}
`,
TypeScript.typescript,
);
const lookupFuzzyCallable = vi.fn(() => []);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) => {
if (name === 'Repo') {
return [
createClassDef('Repo', 'Class', 'models-a.ts'),
{ ...createClassDef('Repo', 'Class', 'models-b.ts'), nodeId: 'class:Repo:partial' },
];
}
if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')];
return [];
},
lookupMethodByOwner: (ownerNodeId: string, methodName: string) =>
ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile'
? {
nodeId: 'method:BaseRepo:getProfile',
filePath: 'base.ts',
type: 'Method',
ownerId: 'class:BaseRepo',
returnType: 'Profile',
}
: undefined,
lookupExactAll: () => [],
lookupFuzzyCallable,
});
const typeEnv = buildTypeEnv(tree, 'typescript', {
symbolTable,
parentMap: new Map([['Repo', ['BaseRepo']]]),
});
expect(flatGet(typeEnv, 'profile')).toBe('Profile');
expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile');
});
it('method return type resolution stays unresolved when multiple class defs each define the method', () => {
const tree = parse(
`
function process(repo: Repo) {
const profile = repo.getProfile();
}
`,
TypeScript.typescript,
);
const lookupFuzzyCallable = vi.fn(() => []);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) =>
name === 'Repo'
? [
createClassDef('Repo', 'Class', 'models-a.ts'),
{
...createClassDef('Repo', 'Class', 'models-b.ts'),
nodeId: 'class:Repo:partial',
},
]
: [],
lookupMethodByOwner: (ownerNodeId: string, methodName: string) => {
if (methodName !== 'getProfile') return undefined;
if (ownerNodeId === 'class:Repo') {
return {
nodeId: 'method:Repo:getProfile#a',
filePath: 'models-a.ts',
type: 'Method',
ownerId: 'class:Repo',
returnType: 'Profile',
};
}
if (ownerNodeId === 'class:Repo:partial') {
return {
nodeId: 'method:Repo:getProfile#b',
filePath: 'models-b.ts',
type: 'Method',
ownerId: 'class:Repo:partial',
returnType: 'Profile',
};
}
return undefined;
},
lookupExactAll: () => [],
lookupFuzzyCallable,
});
const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable });
expect(flatGet(typeEnv, 'profile')).toBeUndefined();
expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile');
});
it('method return type resolution preserves same-return overload success', () => {
const tree = parse(
`
function process(repo: Repo) {
const profile = repo.getProfile();
}
`,
TypeScript.typescript,
);
const lookupFuzzyCallable = vi.fn(() => []);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) =>
name === 'Repo' ? [createClassDef('Repo', 'Class', 'models.ts')] : [],
lookupMethodByOwner: (ownerNodeId: string, methodName: string) =>
ownerNodeId === 'class:Repo' && methodName === 'getProfile'
? {
nodeId: 'method:Repo:getProfile#1',
filePath: 'models.ts',
type: 'Method',
ownerId: 'class:Repo',
returnType: 'Profile',
}
: undefined,
lookupExactAll: (filePath: string, name: string) =>
filePath === 'models.ts' && name === 'getProfile'
? [
{
nodeId: 'method:Repo:getProfile#1',
filePath: 'models.ts',
type: 'Method',
ownerId: 'class:Repo',
returnType: 'Profile',
},
{
nodeId: 'method:Repo:getProfile#2',
filePath: 'models.ts',
type: 'Method',
ownerId: 'class:Repo',
returnType: 'Profile',
},
]
: [],
lookupFuzzyCallable,
});
const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable });
expect(flatGet(typeEnv, 'profile')).toBe('Profile');
expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile');
});
it('method return type resolution stays unresolved for ambiguous overloads with differing returns', () => {
const tree = parse(
`
function process(repo: Repo) {
const profile = repo.getProfile();
}
`,
TypeScript.typescript,
);
const lookupFuzzyCallable = vi.fn(() => []);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) => {
if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')];
if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')];
return [];
},
lookupMethodByOwner: (ownerNodeId: string, methodName: string) =>
ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile'
? {
nodeId: 'method:BaseRepo:getProfile',
filePath: 'base.ts',
type: 'Method',
ownerId: 'class:BaseRepo',
returnType: 'Profile',
}
: undefined,
lookupExactAll: (filePath: string, name: string) =>
filePath === 'models.ts' && name === 'getProfile'
? [
{
nodeId: 'method:Repo:getProfile#1',
filePath: 'models.ts',
type: 'Method',
ownerId: 'class:Repo',
returnType: 'User',
},
{
nodeId: 'method:Repo:getProfile#2',
filePath: 'models.ts',
type: 'Method',
ownerId: 'class:Repo',
returnType: 'Admin',
},
]
: [],
lookupFuzzyCallable,
});
const typeEnv = buildTypeEnv(tree, 'typescript', {
symbolTable,
parentMap: new Map([['Repo', ['BaseRepo']]]),
});
expect(flatGet(typeEnv, 'profile')).toBeUndefined();
expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile');
});
it('inherited method return type resolution preserves same-return overload success on parent owners', () => {
const tree = parse(
`
function process(repo: Repo) {
const profile = repo.getProfile();
}
`,
TypeScript.typescript,
);
const lookupFuzzyCallable = vi.fn(() => []);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) => {
if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')];
if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')];
return [];
},
lookupMethodByOwner: (ownerNodeId: string, methodName: string) =>
ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile'
? {
nodeId: 'method:BaseRepo:getProfile#1',
filePath: 'base.ts',
type: 'Method',
ownerId: 'class:BaseRepo',
returnType: 'Profile',
}
: undefined,
lookupExactAll: (filePath: string, name: string) =>
filePath === 'base.ts' && name === 'getProfile'
? [
{
nodeId: 'method:BaseRepo:getProfile#1',
filePath: 'base.ts',
type: 'Method',
ownerId: 'class:BaseRepo',
returnType: 'Profile',
},
{
nodeId: 'method:BaseRepo:getProfile#2',
filePath: 'base.ts',
type: 'Method',
ownerId: 'class:BaseRepo',
returnType: 'Profile',
},
]
: [],
lookupFuzzyCallable,
});
const typeEnv = buildTypeEnv(tree, 'typescript', {
symbolTable,
parentMap: new Map([['Repo', ['BaseRepo']]]),
});
expect(flatGet(typeEnv, 'profile')).toBe('Profile');
expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile');
});
it('inherited method return type resolution stays unresolved for ambiguous overloads on parent owners', () => {
const tree = parse(
`
function process(repo: Repo) {
const profile = repo.getProfile();
}
`,
TypeScript.typescript,
);
const symbolTable = createSymbolTable();
symbolTable.add('models.ts', 'Repo', 'class:Repo', 'Class');
symbolTable.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class');
symbolTable.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#1', 'Method', {
ownerId: 'class:BaseRepo',
parameterCount: 1,
returnType: 'User',
});
symbolTable.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#2', 'Method', {
ownerId: 'class:BaseRepo',
parameterCount: 2,
returnType: 'Admin',
});
const lookupFuzzyCallable = vi.spyOn(symbolTable, 'lookupFuzzyCallable');
const typeEnv = buildTypeEnv(tree, 'typescript', {
symbolTable,
parentMap: new Map([['Repo', ['BaseRepo']]]),
});
expect(flatGet(typeEnv, 'profile')).toBeUndefined();
expect(lookupFuzzyCallable).not.toHaveBeenCalled();
});
});
describe('Python walrus operator type inference', () => {
it('infers type from walrus operator with constructor call', () => {
const tree = parse(
@ -4992,6 +5755,7 @@ function process() {
type: 'Function' as const,
returnType: c.returnType,
})),
lookupClassByName: () => [],
lookupFuzzy: () => [],
lookupExact: () => undefined,
lookupExactFull: () => undefined,