refactor(finalize): replace recursive followReexportChain with SCC-condensed iterative closure

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
This commit is contained in:
Gergo Magyar 2026-04-25 11:11:20 +01:00
parent 428d331e89
commit fc919ad6de
3 changed files with 253 additions and 126 deletions

View file

@ -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<string, FinalizeFile>,
edgeIndex: Map<string, ImportEdgeDraft[]>,
reexportClosures: ReadonlyMap<string, FileReexportClosure>,
): 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<string>(),
);
// `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 15; 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<string, FinalizeFile>,
edgeIndex: Map<string, ImportEdgeDraft[]>,
visited: Set<string>,
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<string, ReexportClosureEntry>;
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<string, FinalizeFile>,
edgeIndex: ReadonlyMap<string, ImportEdgeDraft[]>,
): ReadonlyMap<string, FileReexportClosure> {
const closures = new Map<string, Map<string, ReexportClosureEntry>>();
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<string, Set<string>>();
for (const file of files) {
const targets = new Set<string>();
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<string, FinalizeFile>,
edgeIndex: ReadonlyMap<string, ImportEdgeDraft[]>,
closures: Map<string, Map<string, ReexportClosureEntry>>,
): 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<string, FileReexportClosure>,
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 };
}
/**

View file

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

View file

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