mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-11 22:53:04 +00:00
Merge remote-tracking branch 'upstream/main' into codex/fix-large-graph-streaming
This commit is contained in:
commit
e7254ddece
86 changed files with 4189 additions and 241 deletions
|
|
@ -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
|
||||
* parent→children 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
177
gitnexus/src/core/ingestion/class-extractors/generic.ts
Normal file
177
gitnexus/src/core/ingestion/class-extractors/generic.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
44
gitnexus/src/core/ingestion/class-types.ts
Normal file
44
gitnexus/src/core/ingestion/class-types.ts
Normal 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;
|
||||
}
|
||||
167
gitnexus/src/core/ingestion/heritage-map.ts
Normal file
167
gitnexus/src/core/ingestion/heritage-map.ts
Normal 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 };
|
||||
};
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ export interface ResolutionContext {
|
|||
getStats(): {
|
||||
fileCount: number;
|
||||
globalSymbolCount: number;
|
||||
fuzzyCallCount: number;
|
||||
fuzzyCallableCallCount: number;
|
||||
cacheHits: number;
|
||||
cacheMisses: number;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
5
gitnexus/test/fixtures/lang-resolution/cpp-child-extends-parent/src/Child.h
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/cpp-child-extends-parent/src/Child.h
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#pragma once
|
||||
#include "Parent.h"
|
||||
|
||||
class Child : public Parent {
|
||||
};
|
||||
9
gitnexus/test/fixtures/lang-resolution/cpp-child-extends-parent/src/Parent.h
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/cpp-child-extends-parent/src/Parent.h
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#pragma once
|
||||
#include <string>
|
||||
|
||||
class Parent {
|
||||
public:
|
||||
std::string parentMethod() {
|
||||
return "parent";
|
||||
}
|
||||
};
|
||||
6
gitnexus/test/fixtures/lang-resolution/cpp-child-extends-parent/src/app.cpp
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/cpp-child-extends-parent/src/app.cpp
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#include "Child.h"
|
||||
|
||||
void run() {
|
||||
Child c;
|
||||
c.parentMethod();
|
||||
}
|
||||
12
gitnexus/test/fixtures/lang-resolution/csharp-child-extends-parent/src/App.cs
vendored
Normal file
12
gitnexus/test/fixtures/lang-resolution/csharp-child-extends-parent/src/App.cs
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
namespace Services;
|
||||
|
||||
using Models;
|
||||
|
||||
public class App
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
var c = new Child();
|
||||
c.ParentMethod();
|
||||
}
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/csharp-child-extends-parent/src/Child.cs
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/csharp-child-extends-parent/src/Child.cs
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
namespace Models;
|
||||
|
||||
public class Child : Parent
|
||||
{
|
||||
}
|
||||
9
gitnexus/test/fixtures/lang-resolution/csharp-child-extends-parent/src/Parent.cs
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/csharp-child-extends-parent/src/Parent.cs
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
namespace Models;
|
||||
|
||||
public class Parent
|
||||
{
|
||||
public string ParentMethod()
|
||||
{
|
||||
return "parent";
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/csharp-qualified-types/Data/User.cs
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/csharp-qualified-types/Data/User.cs
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace Data.Auth
|
||||
{
|
||||
public class User
|
||||
{
|
||||
public void Save() {}
|
||||
}
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/csharp-qualified-types/Services/User.cs
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/csharp-qualified-types/Services/User.cs
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace Services.Auth;
|
||||
|
||||
public class User
|
||||
{
|
||||
public void Save() {}
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/app.dart
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/app.dart
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import 'child.dart';
|
||||
|
||||
class App {
|
||||
void run() {
|
||||
final c = Child();
|
||||
c.parentMethod();
|
||||
}
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/child.dart
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/child.dart
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import 'parent.dart';
|
||||
|
||||
class Child extends Parent {}
|
||||
5
gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/parent.dart
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/parent.dart
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class Parent {
|
||||
String parentMethod() {
|
||||
return 'parent';
|
||||
}
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/go.mod
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module example.com/app
|
||||
|
||||
go 1.21
|
||||
5
gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/child.go
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/child.go
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package models
|
||||
|
||||
type Child struct {
|
||||
Parent
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/parent.go
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/parent.go
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package models
|
||||
|
||||
type Parent struct{}
|
||||
|
||||
func (p *Parent) ParentMethod() string {
|
||||
return "parent"
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/services/app.go
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/services/app.go
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package services
|
||||
|
||||
import "example.com/app/models"
|
||||
|
||||
func Run() {
|
||||
c := &models.Child{}
|
||||
c.ParentMethod()
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/java-child-extends-parent/src/models/Child.java
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/java-child-extends-parent/src/models/Child.java
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package models;
|
||||
|
||||
public class Child extends Parent {
|
||||
public void childOnly() {}
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/java-child-extends-parent/src/models/Parent.java
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/java-child-extends-parent/src/models/Parent.java
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package models;
|
||||
|
||||
public class Parent {
|
||||
public String parentMethod() { return "hello"; }
|
||||
}
|
||||
10
gitnexus/test/fixtures/lang-resolution/java-child-extends-parent/src/services/App.java
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/java-child-extends-parent/src/services/App.java
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package services;
|
||||
|
||||
import models.Child;
|
||||
|
||||
public class App {
|
||||
public void run() {
|
||||
Child c = new Child();
|
||||
c.parentMethod();
|
||||
}
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/java-qualified-types/com/example/admin/User.java
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/java-qualified-types/com/example/admin/User.java
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package com.example.admin;
|
||||
|
||||
public class User {
|
||||
public void save() {}
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/java-qualified-types/com/example/models/User.java
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/java-qualified-types/com/example/models/User.java
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package com.example.models;
|
||||
|
||||
public class User {
|
||||
public void save() {}
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/javascript-child-extends-parent/src/Child.js
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/javascript-child-extends-parent/src/Child.js
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { Parent } from './Parent.js';
|
||||
|
||||
export class Child extends Parent {}
|
||||
5
gitnexus/test/fixtures/lang-resolution/javascript-child-extends-parent/src/Parent.js
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/javascript-child-extends-parent/src/Parent.js
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export class Parent {
|
||||
parentMethod() {
|
||||
return 'parent';
|
||||
}
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/javascript-child-extends-parent/src/app.js
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/javascript-child-extends-parent/src/app.js
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { Child } from './Child.js';
|
||||
|
||||
export function run() {
|
||||
const c = new Child();
|
||||
c.parentMethod();
|
||||
}
|
||||
10
gitnexus/test/fixtures/lang-resolution/kotlin-child-extends-parent/src/App.kt
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/kotlin-child-extends-parent/src/App.kt
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package services
|
||||
|
||||
import models.Child
|
||||
|
||||
class App {
|
||||
fun run() {
|
||||
val c = Child()
|
||||
c.parentMethod()
|
||||
}
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/kotlin-child-extends-parent/src/Child.kt
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/kotlin-child-extends-parent/src/Child.kt
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
package models
|
||||
|
||||
class Child : Parent()
|
||||
7
gitnexus/test/fixtures/lang-resolution/kotlin-child-extends-parent/src/Parent.kt
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/kotlin-child-extends-parent/src/Parent.kt
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package models
|
||||
|
||||
open class Parent {
|
||||
fun parentMethod(): String {
|
||||
return "parent"
|
||||
}
|
||||
}
|
||||
14
gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/App.php
vendored
Normal file
14
gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/App.php
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
namespace Services;
|
||||
|
||||
use Models\Child;
|
||||
|
||||
class App
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$c = new Child();
|
||||
$c->parentMethod();
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/Child.php
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/Child.php
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
|
||||
namespace Models;
|
||||
|
||||
class Child extends ParentClass
|
||||
{
|
||||
}
|
||||
11
gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/Parent.php
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/Parent.php
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
namespace Models;
|
||||
|
||||
class ParentClass
|
||||
{
|
||||
public function parentMethod(): string
|
||||
{
|
||||
return 'parent';
|
||||
}
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/python-child-extends-parent/app.py
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/python-child-extends-parent/app.py
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from child import Child
|
||||
|
||||
|
||||
def run() -> None:
|
||||
c = Child()
|
||||
c.parent_method()
|
||||
5
gitnexus/test/fixtures/lang-resolution/python-child-extends-parent/child.py
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/python-child-extends-parent/child.py
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from parent import Parent
|
||||
|
||||
|
||||
class Child(Parent):
|
||||
pass
|
||||
3
gitnexus/test/fixtures/lang-resolution/python-child-extends-parent/parent.py
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/python-child-extends-parent/parent.py
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
class Parent:
|
||||
def parent_method(self) -> str:
|
||||
return "parent"
|
||||
8
gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/app.rb
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/app.rb
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
require_relative 'child'
|
||||
|
||||
class App
|
||||
def run
|
||||
c = Child.new
|
||||
c.parent_method
|
||||
end
|
||||
end
|
||||
4
gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/child.rb
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/child.rb
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
require_relative 'parent'
|
||||
|
||||
class Child < Parent
|
||||
end
|
||||
5
gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/parent.rb
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/parent.rb
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class Parent
|
||||
def parent_method
|
||||
"parent"
|
||||
end
|
||||
end
|
||||
7
gitnexus/test/fixtures/lang-resolution/ruby-qualified-types/lib/admin/user.rb
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/ruby-qualified-types/lib/admin/user.rb
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
module Admin
|
||||
class User
|
||||
def save
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
9
gitnexus/test/fixtures/lang-resolution/ruby-qualified-types/lib/services/auth/user.rb
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/ruby-qualified-types/lib/services/auth/user.rb
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
module Services
|
||||
module Auth
|
||||
class User
|
||||
def save
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
6
gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/App.swift
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/App.swift
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
class App {
|
||||
func run() {
|
||||
let c = Child()
|
||||
c.parentMethod()
|
||||
}
|
||||
}
|
||||
2
gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/Child.swift
vendored
Normal file
2
gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/Child.swift
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
class Child: Parent {
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/Parent.swift
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/Parent.swift
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class Parent {
|
||||
func parentMethod() -> String {
|
||||
return "parent"
|
||||
}
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/typescript-child-extends-parent/src/Child.ts
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/typescript-child-extends-parent/src/Child.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { Parent } from './Parent';
|
||||
|
||||
export class Child extends Parent {}
|
||||
5
gitnexus/test/fixtures/lang-resolution/typescript-child-extends-parent/src/Parent.ts
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/typescript-child-extends-parent/src/Parent.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export class Parent {
|
||||
parentMethod(): string {
|
||||
return 'parent';
|
||||
}
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/typescript-child-extends-parent/src/app.ts
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/typescript-child-extends-parent/src/app.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { Child } from './Child';
|
||||
|
||||
export function run(): void {
|
||||
const c = new Child();
|
||||
c.parentMethod();
|
||||
}
|
||||
81
gitnexus/test/integration/qualified-class-lookups.test.ts
Normal file
81
gitnexus/test/integration/qualified-class-lookups.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
491
gitnexus/test/unit/heritage-map.test.ts
Normal file
491
gitnexus/test/unit/heritage-map.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue