mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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:
parent
428d331e89
commit
fc919ad6de
3 changed files with 253 additions and 126 deletions
|
|
@ -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 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<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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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')]);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue