From fc919ad6de154b5d8f132555013a6b6c0692a5d4 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sat, 25 Apr 2026 11:11:20 +0100 Subject: [PATCH] refactor(finalize): replace recursive followReexportChain with SCC-condensed iterative closure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy `followReexportChain` walked re-export drafts via mutual recursion guarded by a per-call visited set + a `MAX_REEXPORT_DEPTH` ceiling. Recursion is fragile (call-stack ceiling, no bound on depth that's actually meaningful), so this replaces it with a structurally better algorithm: a precomputed per-file re-export closure built by running Tarjan SCC over the re-export sub-graph and propagating names in reverse-topological order with a bounded intra-SCC fixpoint. Algorithm (`buildReexportClosures` in finalize-algorithm.ts): 1. Sub-graph: build the directed graph of `reexport` + `wildcard` drafts only (regular/namespace/dynamic imports do not contribute). 2. SCC condensation: run the same iterative `tarjanSccs` already used for the file-level import graph; output is in reverse-topo order so out-of-SCC neighbors are always already-finalized. 3. Per-SCC propagation: - Acyclic singleton: one pass populates from neighbors' closures. - Cyclic SCC: bounded fixpoint capped at |SCC|+1 iterations. With first-wins precedence the closure map is monotone, so each name needs at most |SCC| hops to traverse the cycle. Precedence (preserved from the recursive crawl): - Named re-exports take precedence over wildcards. - Within each kind, declaration order wins. Lookup at finalize time becomes O(1) (`lookupReexportedName`), down from O(chain_depth × drafts) per consult and recursive at that. Properties vs the legacy implementation: - Stack-safe by construction; no `MAX_REEXPORT_DEPTH` guard needed. - 1000-hop barrel chains now resolve in full (legacy capped at 100 and surfaced anything deeper as `unresolved`). - Cycles handled structurally via SCC, not via per-call visited set. - Same observable semantics: every existing test passes unchanged. Tests: - Replace the obsolete `MAX_REEXPORT_DEPTH (200-hop chain stops cleanly without stack overflow)` test (which asserted the OLD bug — that deep chains failed to resolve) with a positive 1000-hop test that asserts full resolution + accurate `transitiveVia`. Proves both the recursion is gone AND the closure correctly inherits the leaf def across all hops. - Update commentary on adjacent re-export tests to reference the closure mechanism. - Update `FinalizeFile.localDefs` JSDoc + import-decomposer.ts inline doc to point at `buildReexportClosures` instead of the removed function name. Validation: - gitnexus-shared builds cleanly. - gitnexus typechecks cleanly. - 28/28 finalize-algorithm.test.ts tests pass (incl. new 1000-hop). - 801/801 TypeScript scope-resolution tests pass under default (registry-primary) AND `REGISTRY_PRIMARY_TYPESCRIPT=0` (legacy DAG). - 404/404 Python + C# integration tests pass — no regression in cross-language consumers of the shared `finalize`. Made-with: Cursor --- .../scope-resolution/finalize-algorithm.ts | 307 ++++++++++++------ .../languages/typescript/import-decomposer.ts | 4 +- .../finalize-algorithm.test.ts | 68 ++-- 3 files changed, 253 insertions(+), 126 deletions(-) diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts index 27dfcaeab..24a45b67f 100644 --- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -51,19 +51,19 @@ export interface FinalizeFile { * **Multi-hop re-export contract.** `finalize` resolves an edge * `A → B (importedName: 'X')` by first looking up `X` in `B.localDefs`. * If `B` only has `export { X } from './C'` and does NOT surface `X` in - * its own `localDefs`, `finalize` falls back to `followReexportChain`, - * which traverses `B`'s `reexport` (and `wildcard` re-export) drafts - * to locate `X` in a downstream module's `localDefs`. The chain is - * cycle-guarded via a per-call visited set and inherits the - * upstream `targetDefId`, populating `transitiveVia` with the visited - * file path(s). + * its own `localDefs`, `finalize` falls back to the precomputed + * per-file re-export closure (`buildReexportClosures`), which encodes + * every name reachable through `B`'s named and wildcard re-exports — + * including transitively through cyclic SCCs. The lookup is O(1) and + * inherits the upstream `targetDefId`, populating `transitiveVia` with + * the file paths traversed to reach the leaf def. * * Surfacing re-exported names in `localDefs` is still a valid (and * slightly cheaper) optimization: the direct lookup short-circuits the - * recursive crawl. Parsers SHOULD prefer surfacing names they can resolve + * closure consult. Parsers SHOULD prefer surfacing names they can resolve * statically (e.g., `export { X } from './c'` when `c.ts` is parsed in - * the same workspace), and rely on `followReexportChain` for the long - * tail of barrel patterns. + * the same workspace), and rely on the closure for the long tail of + * barrel patterns. * * The fixpoint does NOT mutate `localDefs` across iterations — it is * static input. @@ -205,6 +205,12 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu // ── Phase 2: Tarjan SCC → reverse-topological list of SCCs. const sccs = tarjanSccs(graph); + // ── Phase 2.5: precompute the per-file re-export closure (iterative, + // SCC-condensed). Eliminates the recursive crawl that the per-edge + // `tryFinalize` call site used to do; lookups are O(1) afterwards. + // See `buildReexportClosures` for the algorithm. + const reexportClosures = buildReexportClosures(input.files, byFilePath, edgeIndex); + // ── Phase 3: process SCCs in reverse-topological order (leaves first). // Within each SCC, run a bounded fixpoint that resolves intra-SCC edges. // Edges leaving the SCC are already resolved (their target SCC is @@ -228,7 +234,7 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu const drafts = edgeIndex.get(filePath)!; for (const draft of drafts) { if (draft.finalized !== null) continue; - const finalized = tryFinalize(draft, byFilePath, edgeIndex); + const finalized = tryFinalize(draft, byFilePath, reexportClosures); if (finalized !== null) { draft.finalized = finalized; progressed = true; @@ -410,7 +416,7 @@ function extractExportedName(parsed: ParsedImport): string { function tryFinalize( draft: ImportEdgeDraft, byFilePath: Map, - edgeIndex: Map, + reexportClosures: ReadonlyMap, ): ImportEdge | null { const targetFile = draft.targetFile; if (targetFile === null) return draft.base; // already terminal @@ -463,18 +469,11 @@ function tryFinalize( // // models.ts // export { User } from './base'; // emit no local def for `User`; the name surfaces only via their own - // `reexport` edge. Consult the target's `reexport` drafts for a - // matching `localName === importedName`, prefer the finalized one (its - // `targetDefId` is the upstream symbol), and inherit that defId + - // chain metadata. Stays O(1) per chain hop by relying on the caller's - // fixpoint to settle leaf-first. - const followed = followReexportChain( - targetModule, - importedName, - byFilePath, - edgeIndex, - new Set(), - ); + // `reexport` edge. The per-file re-export closure built in phase 2.5 + // already encodes every name reachable through that file's named and + // wildcard re-exports — including transitively through cyclic SCCs — + // so the lookup is O(1) and never recurses. + const followed = lookupReexportedName(reexportClosures, targetFile, importedName); if (followed === null) { // Target resolvable but the name isn't exported — keep trying in case a // re-export inside the target's SCC surfaces it in a later iteration. @@ -493,100 +492,216 @@ function tryFinalize( }; } -/** - * Maximum re-export hop count for `followReexportChain`. The visited - * set already prevents cycles; this cap adds a bounded-depth guarantee - * that mirrors the explicit "Iterative DFS to avoid stack overflow" - * policy in `tarjanSccs`. 100 is comfortably above any realistic - * hand-authored barrel chain (typical depth is 1–5; auto-generated - * barrels rarely exceed 20) while staying well below JS engine call - * stack limits even for the recursive implementation. - */ -const MAX_REEXPORT_DEPTH = 100; +// ─── Internal: re-export closure (phase 2.5) ─────────────────────────────── /** - * Chase a name through `reexport` edges in a barrel file. + * Per-file map of `name → terminal def + via path` — i.e. every name + * importable from this file via its named/wildcard re-export chain + * (excluding the file's own `localDefs`, which the caller checks first + * via `findExportByName`). `via` is the ordered list of intermediate + * files traversed to reach the def. * - * At each hop, consult the file's re-export drafts looking for one whose - * `localName === name`. If the edge has finalized (has a `targetDefId` - * pointing into a leaf file's `localDefs`), return the leaf def. If the - * edge is still a draft (leaf not yet processed this iteration), recurse - * into its target file. Wildcard re-exports (`export * from './m'`) are - * also followed opportunistically — they expand to whatever `name` - * resolves to in the wildcard target's localDefs or further reexports. - * - * Visited-set guards against circular re-export chains - * (`a.ts → b.ts → a.ts`), which TypeScript rejects at type-check but - * can still appear in parsed input. The `depth` cap (see - * `MAX_REEXPORT_DEPTH`) adds a bounded-path guarantee on top of the - * cycle guard. + * Built once per finalize pass. Lookups are O(1). */ -function followReexportChain( - module: FinalizeFile, - name: string, - byFilePath: Map, - edgeIndex: Map, - visited: Set, - depth: number = 0, -): { def: SymbolDefinition; via: readonly string[] } | null { - if (depth > MAX_REEXPORT_DEPTH) return null; - if (visited.has(module.filePath)) return null; - visited.add(module.filePath); +type ReexportClosureEntry = { readonly def: SymbolDefinition; readonly via: readonly string[] }; +type FileReexportClosure = ReadonlyMap; - const drafts = edgeIndex.get(module.filePath); - if (drafts === undefined) return null; +/** + * Build per-file re-export closures. + * + * **Algorithm.** Iterative SCC-condensed reverse-topological propagation, + * structurally identical to how `finalize` itself processes the file- + * level import graph. Replaces the legacy recursive + * `followReexportChain` crawl with a bounded, stack-safe pass: + * + * 1. **Sub-graph.** Build a directed graph whose edges are + * `reexport` and `wildcard` drafts only (regular imports do not + * contribute to the export surface, and `namespace`/ + * `reexport-namespace` are terminal — their target def lives in + * `localDefs`). + * 2. **SCC condensation.** Run the same iterative `tarjanSccs` over + * the sub-graph. Output is in reverse-topological order (leaves + * first), so when we process an SCC every out-of-SCC neighbor + * already has its closure populated. + * 3. **Per-SCC propagation.** + * * Acyclic singleton: one pass — read neighbors' (already + * fully populated) closures. + * * Cyclic SCC (cycle ≥ 2 files, or self-loop): bounded + * fixpoint inside the SCC, capped at `|SCC| + 1` iterations + * (each iteration propagates names one hop further around + * the cycle; first-wins precedence keeps the map monotone + * so the fixpoint converges in at most |SCC| hops). + * + * **Precedence semantics — preserved from the recursive crawl.** + * * Named re-exports take precedence over wildcards. + * * Within each kind, declaration order wins (first match for a + * given exported name is kept; later drafts skip). + * + * **Complexity.** + * * Pre-pass: O(V + E_re) for SCC, plus O(|SCC| × Σ drafts) per cyclic + * SCC. For tree-shaped barrel graphs (the common case) it + * collapses to O(E_re) total. + * * Per-edge lookup at finalize time: O(1). + * * Pathological deep chains that previously needed + * `MAX_REEXPORT_DEPTH=100` to bound stack growth now resolve + * in full and are bounded only by available memory — the + * iterative formulation has no call-stack ceiling. + */ +function buildReexportClosures( + files: readonly FinalizeFile[], + byFilePath: ReadonlyMap, + edgeIndex: ReadonlyMap, +): ReadonlyMap { + const closures = new Map>(); + for (const file of files) closures.set(file.filePath, new Map()); - // Named re-exports first (more specific). + // ── Step 1: build the re-export sub-graph (only resolvable + // reexport/wildcard targets contribute edges). + const subGraph = new Map>(); + for (const file of files) { + const targets = new Set(); + const drafts = edgeIndex.get(file.filePath); + if (drafts !== undefined) { + for (const d of drafts) { + if (d.source.kind !== 'reexport' && d.source.kind !== 'wildcard') continue; + if (d.targetFile === null) continue; + if (!byFilePath.has(d.targetFile)) continue; + targets.add(d.targetFile); + } + } + subGraph.set(file.filePath, targets); + } + + // ── Step 2: SCC over the sub-graph. Reuses the same iterative Tarjan + // implementation that drives the file-level finalize loop, so any + // call-stack-safety guarantees there transfer here unchanged. + const subSccs = tarjanSccs(subGraph); + + // ── Step 3: process SCCs in reverse-topological order. Acyclic + // singletons settle in one pass; cyclic SCCs run a bounded fixpoint. + for (const scc of subSccs) { + if (!scc.isCycle) { + populateFileClosure(scc.files[0]!, byFilePath, edgeIndex, closures); + continue; + } + // Cap = |SCC| + 1. With first-wins precedence each name needs at + // most |SCC| iterations to propagate fully around the cycle; the + // extra iteration confirms no progress and breaks the loop. + const cap = scc.files.length + 1; + let progressed = true; + let iter = 0; + while (progressed && iter < cap) { + progressed = false; + iter++; + for (const filePath of scc.files) { + if (populateFileClosure(filePath, byFilePath, edgeIndex, closures)) { + progressed = true; + } + } + } + } + + return closures; +} + +/** + * Populate one file's re-export closure for one pass. Returns `true` + * iff the closure grew (signalling fixpoint progress to the caller). + * + * Walks the file's drafts in declaration order, named re-exports first + * (precedence), then wildcards. For each draft, attempts: + * 1. **Direct hit** — name exists in the target file's `localDefs`. + * 2. **Inherited** — name exists in the target file's already-populated + * closure (which encodes the target's own re-export chain). + * + * `closures.get(targetFile)` may itself still be empty for in-SCC + * targets on the first iteration; the outer fixpoint loop handles + * that by re-invoking this function. + */ +function populateFileClosure( + filePath: string, + byFilePath: ReadonlyMap, + edgeIndex: ReadonlyMap, + closures: Map>, +): boolean { + const myClosure = closures.get(filePath)!; + const before = myClosure.size; + const drafts = edgeIndex.get(filePath); + if (drafts === undefined) return false; + + // Named re-exports — precedence over wildcards, declaration order + // first-wins for duplicates of the same exported name. for (const draft of drafts) { if (draft.source.kind !== 'reexport') continue; - if (draft.source.localName !== name) continue; - const nextTargetFile = draft.targetFile; - if (nextTargetFile === null) continue; - const nextModule = byFilePath.get(nextTargetFile); - if (nextModule === undefined) continue; + const targetFile = draft.targetFile; + if (targetFile === null) continue; + const targetModule = byFilePath.get(targetFile); + if (targetModule === undefined) continue; + + const localName = draft.source.localName; + if (myClosure.has(localName)) continue; const importedName = draft.source.importedName; - const exported = findExportByName(nextModule.localDefs, importedName); - if (exported !== undefined) { - return { def: exported, via: [nextTargetFile] }; + const direct = findExportByName(targetModule.localDefs, importedName); + if (direct !== undefined) { + myClosure.set(localName, { def: direct, via: Object.freeze([targetFile]) }); + continue; } - - // Recurse — the barrel's upstream is itself a barrel. - const deeper = followReexportChain( - nextModule, - importedName, - byFilePath, - edgeIndex, - visited, - depth + 1, - ); - if (deeper !== null) { - return { def: deeper.def, via: [nextTargetFile, ...deeper.via] }; + const inherited = closures.get(targetFile)?.get(importedName); + if (inherited !== undefined) { + myClosure.set(localName, { + def: inherited.def, + via: Object.freeze([targetFile, ...inherited.via]), + }); } + // Else: target's closure is still empty (in-SCC, awaiting next + // iteration). Outer loop will revisit. } - // Wildcard re-exports (`export * from './m'`) — fan out and keep the - // first match. `source.kind === 'wildcard'` here because the - // decomposer classifies `export * from ...` as `reexport-wildcard` → - // `wildcard` (see TypeScript import-decomposer). + // Wildcard re-exports — fan out the target's own surface (localDefs + // + transitive closure). `myClosure.has(name)` checks below preserve + // the named-precedence and first-wins semantics from above. for (const draft of drafts) { if (draft.source.kind !== 'wildcard') continue; - const nextTargetFile = draft.targetFile; - if (nextTargetFile === null) continue; - const nextModule = byFilePath.get(nextTargetFile); - if (nextModule === undefined) continue; + const targetFile = draft.targetFile; + if (targetFile === null) continue; + const targetModule = byFilePath.get(targetFile); + if (targetModule === undefined) continue; - const exported = findExportByName(nextModule.localDefs, name); - if (exported !== undefined) { - return { def: exported, via: [nextTargetFile] }; + for (const def of targetModule.localDefs) { + const name = deriveSimpleName(def); + if (name === null || myClosure.has(name)) continue; + myClosure.set(name, { def, via: Object.freeze([targetFile]) }); } - const deeper = followReexportChain(nextModule, name, byFilePath, edgeIndex, visited, depth + 1); - if (deeper !== null) { - return { def: deeper.def, via: [nextTargetFile, ...deeper.via] }; + const targetClosure = closures.get(targetFile); + if (targetClosure !== undefined) { + for (const [name, entry] of targetClosure) { + if (myClosure.has(name)) continue; + myClosure.set(name, { + def: entry.def, + via: Object.freeze([targetFile, ...entry.via]), + }); + } } } - return null; + return myClosure.size > before; +} + +/** + * O(1) lookup into a precomputed re-export closure. Replaces the legacy + * recursive `followReexportChain` traversal with a single map indexing. + */ +function lookupReexportedName( + closures: ReadonlyMap, + filePath: string, + name: string, +): { def: SymbolDefinition; via: readonly string[] } | null { + const closure = closures.get(filePath); + if (closure === undefined) return null; + const entry = closure.get(name); + if (entry === undefined) return null; + return { def: entry.def, via: entry.via }; } /** diff --git a/gitnexus/src/core/ingestion/languages/typescript/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/typescript/import-decomposer.ts index 313b983ea..d8d8a51a0 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/import-decomposer.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/import-decomposer.ts @@ -257,8 +257,8 @@ function splitReexport(stmtNode: SyntaxNode): CaptureMatch[] { // scope-extractor adds a `Namespace` SymbolDefinition for `ns` // to the barrel's `localDefs`. Without this, downstream files // doing `import { ns } from './barrel'` cannot resolve `ns`: - // `findExportByName` and `followReexportChain` only look at - // `localDefs` / `reexport` / `wildcard` drafts, never at + // `findExportByName` and the precomputed re-export closure only + // consult `localDefs` / `reexport` / `wildcard` drafts, never // `namespace`-kind imports. The synthetic declaration fixes that // without growing the shared finalizer's surface. const namespaceExport = findChild(stmtNode, 'namespace_export'); diff --git a/gitnexus/test/unit/scope-resolution/finalize-algorithm.test.ts b/gitnexus/test/unit/scope-resolution/finalize-algorithm.test.ts index f96ba9806..fc1a04192 100644 --- a/gitnexus/test/unit/scope-resolution/finalize-algorithm.test.ts +++ b/gitnexus/test/unit/scope-resolution/finalize-algorithm.test.ts @@ -299,14 +299,15 @@ describe('finalize', () => { expect(reexportEdge.transitiveVia).toEqual(['c']); }); - it('multi-hop re-export chains resolve via followReexportChain even when intermediate files do not surface the name', () => { + it('multi-hop re-export chains resolve via the precomputed closure even when intermediate files do not surface the name', () => { // Contract (see FinalizeFile.localDefs doc): `finalize` first looks // up `importedName` in `B.localDefs`. If B only has // export { X } from './C' // and does NOT surface X in its own localDefs, finalize falls back - // to `followReexportChain`, which crawls B's reexport drafts to - // locate X in a downstream module's localDefs and inherits the leaf - // `targetDefId`. The visited set guards against import cycles. + // to the precomputed re-export closure (`buildReexportClosures`), + // which encodes every name reachable through B's reexport/wildcard + // chain. The lookup is O(1) and inherits the leaf `targetDefId`. + // SCC-condensed iteration handles cyclic chains structurally. // // The "thin" variant (B does not surface X) resolves to C's def // via the re-export chain. The "thick" variant (B has its own X @@ -331,10 +332,10 @@ describe('finalize', () => { // Variant 2: B surfaces X via its OWN localDefs (distinct nodeId // from C's X) → direct B.localDefs lookup short-circuits the - // recursive re-export crawl. This is the "shadowing" optimization: - // when B explicitly surfaces a name, importers see B's def, not - // the upstream one. `transitiveVia` is undefined since no chain - // walk happened. + // closure consult. This is the "shadowing" optimization: when B + // explicitly surfaces a name, importers see B's def, not the + // upstream one. `transitiveVia` is undefined since no chain walk + // happened. const bThick = file('b', [def('def:b.X', 'Class', 'b.X')], [reexport('X', 'X', 'c')]); const aThick = file('a', [], [named('X', 'X', 'b')]); const thickFiles = [aThick, bThick, c]; @@ -363,25 +364,27 @@ describe('finalize', () => { it('terminates without infinite recursion when re-exports cycle back through the chain', () => { // Cycle: b re-exports from c, c re-exports from b. Neither surfaces - // X. `followReexportChain`'s visited set prevents infinite recursion; - // the edge resolves as unresolved (no terminal localDef found) but - // the call must return. + // X. The SCC-condensed closure builder lumps b+c into one cyclic + // SCC and runs a bounded fixpoint inside it; with no terminal def + // anywhere in the cycle, the closure entry for X never appears + // and the consuming edge is correctly marked unresolved. No call + // stack involvement at any point. const c = file('c', [], [reexport('X', 'X', 'b')]); const b = file('b', [], [reexport('X', 'X', 'c')]); const a = file('a', [], [named('X', 'X', 'b')]); const files = [a, b, c]; const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files)); const edge = firstImport(out, a.moduleScope)!; - // Cycle exhausts the chain crawl → unresolved name (file is known). expect(edge.targetFile).toBe('b'); expect(edge.linkStatus).toBe('unresolved'); expect(edge.targetDefId).toBeUndefined(); }); - it('falls through wildcard re-exports when followReexportChain crawls', () => { + it('falls through wildcard re-exports via the precomputed closure', () => { // B has `export * from './c'` (wildcard re-export). A imports `X` - // from B. `followReexportChain` should fall through the wildcard to - // find X in C.localDefs. + // from B. The closure builder fans out the wildcard at B by + // copying every name from C's localDefs (and C's own closure) + // into B's closure, so the lookup at finalize time is O(1). const c = file('c', [def('def:c.X', 'Class', 'c.X')]); const b = file('b', [], [wildcard('c')]); const a = file('a', [], [named('X', 'X', 'b')]); @@ -392,15 +395,18 @@ describe('finalize', () => { expect(edge.targetDefId).toBe('def:c.X'); }); - it('caps recursion at MAX_REEXPORT_DEPTH (200-hop chain stops cleanly without stack overflow)', () => { - // Build a 200-link chain a₀ → a₁ → … → a₂₀₀, where each + it('resolves arbitrarily deep re-export chains without stack overflow (1000 hops, fully linked)', () => { + // Build a 1000-link chain a₀ → a₁ → … → a₁₀₀₀, where each // intermediate is `export { X } from './aₙ₊₁'`. Only the last - // file (a₂₀₀) holds the actual `def:X`. With the depth cap at - // 100, the crawl must terminate without a stack-overflow and - // surface the edge as `unresolved` (no terminal def reachable - // within the budget). The edge MUST still target a₁ at file - // level — it just lacks a `targetDefId`. - const CHAIN_LEN = 200; + // file holds the actual `def:X`. The legacy recursive + // implementation needed `MAX_REEXPORT_DEPTH=100` purely as a + // call-stack ceiling (anything deeper would crash); the new + // SCC-condensed iterative closure resolves the entire chain + // structurally with zero stack involvement. Asserting full + // resolution + accurate `transitiveVia` proves both that the + // recursion is gone AND that the closure correctly inherits + // the leaf def across all hops. + const CHAIN_LEN = 1000; const chain: FinalizeFile[] = []; for (let i = 0; i <= CHAIN_LEN; i++) { const fp = `chain${i}`; @@ -415,13 +421,19 @@ describe('finalize', () => { const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files)); const edge = firstImport(out, consumer.moduleScope)!; expect(edge.targetFile).toBe('chain1'); - expect(edge.linkStatus).toBe('unresolved'); - expect(edge.targetDefId).toBeUndefined(); + expect(edge.linkStatus).toBeUndefined(); + expect(edge.targetDefId).toBe(`def:chain${CHAIN_LEN}.X`); + // `transitiveVia` enumerates every intermediate file from chain1 + // through chain1000 — proves the closure walked the full path. + expect(edge.transitiveVia).toBeDefined(); + expect(edge.transitiveVia!.length).toBe(CHAIN_LEN); + expect(edge.transitiveVia![0]).toBe('chain1'); + expect(edge.transitiveVia![CHAIN_LEN - 1]).toBe(`chain${CHAIN_LEN}`); }); - it('first-match-wins when followReexportChain encounters multiple sources for the same name', () => { - // B re-exports X from BOTH c and d. `followReexportChain` walks - // re-exports in declaration order; the first one that resolves wins. + it('first-match-wins when the closure encounters multiple sources for the same name', () => { + // B re-exports X from BOTH c and d. The closure builder walks + // re-exports in declaration order; first match wins. // Resolution must be deterministic (no flaky picks). const c = file('c', [def('def:c.X', 'Class', 'c.X')]); const d = file('d', [def('def:d.X', 'Class', 'd.X')]);