From b75e76d44a8d9d7c1963c882d54cd88ee7ef8dc3 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:00:08 +0100 Subject: [PATCH] feat(SM-8): Build HeritageMap from accumulated ExtractedHeritage[] (#739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- gitnexus/src/core/ingestion/call-processor.ts | 83 +-- gitnexus/src/core/ingestion/heritage-map.ts | 167 ++++++ .../src/core/ingestion/heritage-processor.ts | 2 +- gitnexus/src/core/ingestion/pipeline.ts | 48 +- gitnexus/test/unit/call-processor.test.ts | 66 +-- gitnexus/test/unit/heritage-map.test.ts | 491 ++++++++++++++++++ 6 files changed, 716 insertions(+), 141 deletions(-) create mode 100644 gitnexus/src/core/ingestion/heritage-map.ts create mode 100644 gitnexus/test/unit/heritage-map.test.ts diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 27d18d495..e3bacbf7b 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -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>; - -/** - * 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> => { - const map = new Map>(); - 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>, - source: ReadonlyMap>, -): 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>, /** Phase 14 E3: cross-file RAW return types for for-loop element extraction. Keyed by filePath → Map. */ importedRawReturnTypesMap?: ReadonlyMap>, - implementorMap?: ImplementorMap, + heritageMap?: HeritageMap, ): Promise => { 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) { diff --git a/gitnexus/src/core/ingestion/heritage-map.ts b/gitnexus/src/core/ingestion/heritage-map.ts new file mode 100644 index 000000000..46d0c2120 --- /dev/null +++ b/gitnexus/src/core/ingestion/heritage-map.ts @@ -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; +} + +/** Shared empty set returned when no implementors are found. */ +const EMPTY_SET: ReadonlySet = 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 (Set to deduplicate cross-chunk duplicates) + const directParents = new Map>(); + + // interfaceName → Set (implementor lookup for interface dispatch) + const implementorFiles = new Map>(); + + 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(); + 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 => { + return implementorFiles.get(interfaceName) ?? EMPTY_SET; + }; + + return { getParents, getAncestors, getImplementorFiles }; +}; diff --git a/gitnexus/src/core/ingestion/heritage-processor.ts b/gitnexus/src/core/ingestion/heritage-processor.ts index 25085ddd8..37c3653a8 100644 --- a/gitnexus/src/core/ingestion/heritage-processor.ts +++ b/gitnexus/src/core/ingestion/heritage-processor.ts @@ -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( diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 623fd51c9..e73efd86e 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -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>(); + // 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>(); + // 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> = []; 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 diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts index 8b9bd5b8d..bb4590d5a 100644 --- a/gitnexus/test/unit/call-processor.test.ts +++ b/gitnexus/test/unit/call-processor.test.ts @@ -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>(); - 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>(); - 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; 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>([ - ['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); diff --git a/gitnexus/test/unit/heritage-map.test.ts b/gitnexus/test/unit/heritage-map.test.ts new file mode 100644 index 000000000..b4a6b1c4f --- /dev/null +++ b/gitnexus/test/unit/heritage-map.test.ts @@ -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()); + }); + }); +});