mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-12 23:02:45 +00:00
feat(SM-8): Build HeritageMap from accumulated ExtractedHeritage[] (#739)
* Initial plan * feat(SM-8): add HeritageMap with MRO-aware parent/ancestor lookup - New heritage-map.ts: HeritageMap interface with getParents() and getAncestors() - buildHeritageMap() consumes ExtractedHeritage[], resolves names via lookupClassByName - Cycle protection and bounded depth (MAX_ANCESTOR_DEPTH=32) in getAncestors - Worker path: HeritageMap built from deferredWorkerHeritage, threaded into processCallsFromExtracted - Sequential path: Heritage accumulated across chunks, HeritageMap built after all chunks, passed to processCalls - 18 unit tests covering parent lookup, multi-level, diamond, cycles, missing parent, bounded depth Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c413e0a3-5d63-4ddb-8ece-02fe6ed99efd Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test: rename cycle test for clarity per code review Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c413e0a3-5d63-4ddb-8ece-02fe6ed99efd Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * refactor(SM-8): merge implementor map into heritage map - Add `getImplementorFiles(interfaceName)` to HeritageMap interface - Build implementor index (interface name → file paths) alongside parent lookup in `buildHeritageMap`, using same `resolveExtendsType` logic - Remove `ImplementorMap` type, `buildImplementorMap`, `mergeImplementorMaps` from call-processor.ts - Update `findInterfaceDispatchTargets`, `processCalls`, and `processCallsFromExtracted` to use HeritageMap for both parent lookup and implementor dispatch - Pipeline: single `buildHeritageMap` call replaces separate buildImplementorMap + buildHeritageMap for both worker and sequential paths - Migrate implementor tests from call-processor.test.ts to heritage-map.test.ts (4 new getImplementorFiles tests) - Update interface dispatch test to use buildHeritageMap instead of hand-constructed ImplementorMap Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/085dffb4-b31e-4aa5-9aa3-4314bc0010e7 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test: rename implementor test for clarity per code review Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/085dffb4-b31e-4aa5-9aa3-4314bc0010e7 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix(SM-8): address PR #739 review comments - pipeline.ts: cache chunk file contents from Pass 1 to eliminate double-read of sequential chunks in Pass 2. Peak memory drains incrementally as Pass 2 processes each chunk. - heritage-map.ts: document Rust trait-impl omission from implementor index and the interface-name collision limitation. - heritage-map.test.ts: add six tests covering the extends->IMPLEMENTS path across C# (interfaceNamePattern), Swift (heritageDefaultEdge), Java (symbol-table Interface lookup), Kotlin, PHP, and the Rust trait-impl omission. - pipeline.ts: comment why the heritage accumulation uses a manual push loop instead of spread (ref #650). * test(SM-8): address second PR #739 review pass - Add TypeScript implements test to getImplementorFiles (closes the .ts coverage gap flagged by the bot reviewer). - Tighten deep-chain boundary assertion from toBeLessThanOrEqual(32) to toBe(32) so a future regression returning fewer ancestors fails loudly. Added an ancestors[31] === 'class:Level32' check to pin the upper boundary. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
This commit is contained in:
parent
83b5bec293
commit
b75e76d44a
6 changed files with 716 additions and 141 deletions
|
|
@ -30,7 +30,7 @@ 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 { getTreeSitterBufferSize } from './constants.js';
|
||||
import type {
|
||||
ExtractedCall,
|
||||
|
|
@ -515,65 +515,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
|
||||
|
|
@ -584,11 +525,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 [];
|
||||
|
|
@ -624,7 +565,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[] = [];
|
||||
|
|
@ -857,13 +798,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) {
|
||||
|
|
@ -1104,13 +1045,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) {
|
||||
|
|
@ -1779,7 +1720,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
|
||||
|
|
@ -1942,13 +1883,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) {
|
||||
|
|
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,9 +5,8 @@ import {
|
|||
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 {
|
||||
|
|
@ -1506,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;
|
||||
|
|
@ -1606,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[] = [
|
||||
{
|
||||
|
|
@ -1621,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);
|
||||
|
|
|
|||
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());
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue