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:
Copilot 2026-04-08 19:00:08 +01:00 committed by GitHub
parent 83b5bec293
commit b75e76d44a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 716 additions and 141 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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