GitNexus/gitnexus/test/unit/scope-resolution/finalize-algorithm.test.ts
Gergo Magyar bda168af00 fix(scope): address TypeScript import review follow-ups
Add regression coverage for non-binding import edges and circular TypeScript bindings so PR #1050 review concerns stay visible without changing runtime semantics.

Made-with: Cursor
2026-04-26 06:44:34 +01:00

640 lines
29 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Unit tests for `finalize` (RFC #909 Ring 2 SHARED #915).
*
* Covers: acyclic chain · single-SCC cycle · multi-SCC · wildcard
* expansion · re-export flattening · dynamic import passthrough/linking ·
* side-effect imports · bounded fixpoint cap · module-scope binding
* materialization · unresolved target · external target · provider
* `mergeBindings` precedence.
*/
import { describe, it, expect } from 'vitest';
import {
finalize,
type FinalizeFile,
type FinalizeHooks,
type ParsedImport,
type BindingRef,
type SymbolDefinition,
type ScopeId,
} from 'gitnexus-shared';
// ─── Test helpers ───────────────────────────────────────────────────────────
const def = (
nodeId: string,
type: SymbolDefinition['type'] = 'Class',
qualifiedName?: string,
): SymbolDefinition => ({
nodeId,
filePath: 'x',
type,
...(qualifiedName !== undefined ? { qualifiedName } : {}),
});
const file = (
filePath: string,
localDefs: SymbolDefinition[] = [],
parsedImports: ParsedImport[] = [],
): FinalizeFile => ({
filePath,
moduleScope: `scope:${filePath}#1:0-9999:0:Module`,
localDefs: localDefs.map((d) => ({ ...d, filePath })),
parsedImports,
});
/** Simple hook set: `resolveImportTarget` does a direct path lookup; wildcard
* expansion returns the concrete names from the target's own local defs;
* `mergeBindings` appends (no precedence logic). */
const defaultHooks = (files: readonly FinalizeFile[]): FinalizeHooks => ({
resolveImportTarget(targetRaw) {
if (targetRaw === null || targetRaw.length === 0) return null;
return files.some((f) => f.filePath === targetRaw) ? targetRaw : null;
},
expandsWildcardTo(targetModuleScope) {
const target = files.find((f) => f.moduleScope === targetModuleScope);
if (target === undefined) return [];
return target.localDefs.map((d) => deriveSimple(d)).filter((n): n is string => n !== null);
},
mergeBindings(existing, incoming) {
return [...existing, ...incoming];
},
});
function deriveSimple(d: SymbolDefinition): string | null {
const q = d.qualifiedName;
if (q === undefined || q.length === 0) return null;
const dot = q.lastIndexOf('.');
return dot === -1 ? q : q.slice(dot + 1);
}
const named = (localName: string, importedName: string, targetRaw: string): ParsedImport => ({
kind: 'named',
localName,
importedName,
targetRaw,
});
const aliased = (
localName: string,
importedName: string,
alias: string,
targetRaw: string,
): ParsedImport => ({ kind: 'alias', localName, importedName, alias, targetRaw });
const namespace = (localName: string, importedName: string, targetRaw: string): ParsedImport => ({
kind: 'namespace',
localName,
importedName,
targetRaw,
});
const reexport = (localName: string, importedName: string, targetRaw: string): ParsedImport => ({
kind: 'reexport',
localName,
importedName,
targetRaw,
});
const wildcard = (targetRaw: string): ParsedImport => ({ kind: 'wildcard', targetRaw });
const dynamic = (localName: string, targetRaw: string | null): ParsedImport => ({
kind: 'dynamic-unresolved',
localName,
targetRaw,
});
const dynamicResolved = (targetRaw: string): ParsedImport => ({
kind: 'dynamic-resolved',
targetRaw,
});
const sideEffect = (targetRaw: string): ParsedImport => ({ kind: 'side-effect', targetRaw });
const firstImport = (out: ReturnType<typeof finalize>, scope: ScopeId) => {
const imports = out.imports.get(scope);
return imports?.[0];
};
const bindingsFor = (
out: ReturnType<typeof finalize>,
scope: ScopeId,
name: string,
): readonly BindingRef[] => {
const scopeBindings = out.bindings.get(scope);
return scopeBindings?.get(name) ?? [];
};
// ─── Tests ──────────────────────────────────────────────────────────────────
describe('finalize', () => {
describe('trivial / acyclic', () => {
it('handles an empty workspace', () => {
const out = finalize({ files: [], workspaceIndex: undefined }, defaultHooks([]));
expect(out.stats.totalFiles).toBe(0);
expect(out.stats.totalEdges).toBe(0);
expect(out.sccs).toEqual([]);
});
it('resolves a single named import across two files', () => {
const b = file('b', [def('def:b.User', 'Class', 'b.User')]);
const a = file('a', [], [named('User', 'User', 'b')]);
const files = [a, b];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const edge = firstImport(out, a.moduleScope)!;
expect(edge.kind).toBe('named');
expect(edge.targetFile).toBe('b');
expect(edge.targetModuleScope).toBe(b.moduleScope);
expect(edge.targetDefId).toBe('def:b.User');
expect(edge.linkStatus).toBeUndefined();
expect(out.stats.linkedEdges).toBe(1);
expect(out.stats.unresolvedEdges).toBe(0);
});
it('marks an edge unresolved when target file cannot be resolved', () => {
const a = file('a', [], [named('User', 'User', 'external-pkg')]);
const out = finalize({ files: [a], workspaceIndex: undefined }, defaultHooks([a]));
const edge = firstImport(out, a.moduleScope)!;
expect(edge.linkStatus).toBe('unresolved');
expect(edge.targetFile).toBeNull();
});
it('marks an edge unresolved when target file exists but name is not exported', () => {
const b = file('b', [def('def:b.Other', 'Class', 'b.Other')]);
const a = file('a', [], [named('User', 'User', 'b')]);
const files = [a, b];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const edge = firstImport(out, a.moduleScope)!;
expect(edge.linkStatus).toBe('unresolved');
// targetFile still known — unresolvability is at the name level.
expect(edge.targetFile).toBe('b');
});
it('passes dynamic-unresolved edges through without linking', () => {
const a = file('a', [], [dynamic('', 'runtime.computed')]);
const out = finalize({ files: [a], workspaceIndex: undefined }, defaultHooks([a]));
const edge = firstImport(out, a.moduleScope)!;
expect(edge.kind).toBe('dynamic-unresolved');
expect(edge.targetFile).toBeNull();
expect(edge.linkStatus).toBeUndefined();
});
it('links dynamic-resolved imports as file-level edges without bindings', () => {
const feature = file('feature', [def('def:feature.Feature', 'Class', 'feature.Feature')]);
const app = file('app', [], [dynamicResolved('feature')]);
const files = [app, feature];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const edge = firstImport(out, app.moduleScope)!;
expect(edge.kind).toBe('dynamic-resolved');
expect(edge.targetFile).toBe('feature');
expect(edge.linkStatus).toBeUndefined();
expect(edge.targetDefId).toBeUndefined();
expect(bindingsFor(out, app.moduleScope, 'Feature')).toEqual([]);
expect(out.stats.linkedEdges).toBe(1);
});
it('links side-effect imports as file-level edges without bindings', () => {
const polyfill = file('polyfill', [
def('def:polyfill.install', 'Function', 'polyfill.install'),
]);
const app = file('app', [], [sideEffect('polyfill')]);
const files = [app, polyfill];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const edge = firstImport(out, app.moduleScope)!;
expect(edge.kind).toBe('side-effect');
expect(edge.localName).toBe('');
expect(edge.targetFile).toBe('polyfill');
expect(edge.linkStatus).toBeUndefined();
expect(edge.targetDefId).toBeUndefined();
expect(bindingsFor(out, app.moduleScope, 'install')).toEqual([]);
expect(out.stats.linkedEdges).toBe(1);
});
});
describe('cycles + bounded fixpoint', () => {
it('finalizes a two-file cycle (A → B → A) without hanging', () => {
const a = file('a', [def('def:a.X', 'Class', 'a.X')], [named('Y', 'Y', 'b')]);
const b = file('b', [def('def:b.Y', 'Class', 'b.Y')], [named('X', 'X', 'a')]);
const files = [a, b];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const aEdge = firstImport(out, a.moduleScope)!;
const bEdge = firstImport(out, b.moduleScope)!;
expect(aEdge.targetDefId).toBe('def:b.Y');
expect(bEdge.targetDefId).toBe('def:a.X');
expect(out.stats.sccCount).toBeGreaterThanOrEqual(1);
});
it('packs cyclic files into a single SCC with isCycle=true', () => {
const a = file('a', [def('def:a.X', 'Class', 'a.X')], [named('Y', 'Y', 'b')]);
const b = file('b', [def('def:b.Y', 'Class', 'b.Y')], [named('X', 'X', 'a')]);
const files = [a, b];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const cycles = out.sccs.filter((scc) => scc.isCycle);
expect(cycles.length).toBe(1);
expect(cycles[0]!.files.length).toBe(2);
expect(new Set(cycles[0]!.files)).toEqual(new Set(['a', 'b']));
});
it('separates disjoint SCCs', () => {
// a↔b cycle, c↔d cycle — disjoint.
const a = file('a', [def('def:a.X', 'Class', 'a.X')], [named('Y', 'Y', 'b')]);
const b = file('b', [def('def:b.Y', 'Class', 'b.Y')], [named('X', 'X', 'a')]);
const c = file('c', [def('def:c.P', 'Class', 'c.P')], [named('Q', 'Q', 'd')]);
const d = file('d', [def('def:d.Q', 'Class', 'd.Q')], [named('P', 'P', 'c')]);
const files = [a, b, c, d];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const cycleSCCs = out.sccs.filter((scc) => scc.isCycle);
expect(cycleSCCs.length).toBe(2);
});
it('reports stats distinguishing linked from unresolved edges in a cycle', () => {
const a = file(
'a',
[def('def:a.X', 'Class', 'a.X')],
[named('Y', 'Y', 'b'), named('Ghost', 'Ghost', 'b')],
);
const b = file('b', [def('def:b.Y', 'Class', 'b.Y')], [named('X', 'X', 'a')]);
const files = [a, b];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
expect(out.stats.linkedEdges).toBe(2); // a→b.Y and b→a.X resolve
expect(out.stats.unresolvedEdges).toBe(1); // a→b.Ghost doesn't
});
it('transitions an intra-SCC edge to linkStatus=unresolved when the cap is reached', () => {
// A↔B cycle; A imports a name that B never exports. The file-level
// target resolves (b exists), but the name-level lookup never
// succeeds, so the fixpoint exhausts its cap and we fall through to
// `linkStatus: 'unresolved'` (distinct from `targetFile: null`).
const a = file(
'a',
[def('def:a.X', 'Class', 'a.X')],
[named('Ghost', 'Ghost', 'b'), named('Y', 'Y', 'b')],
);
const b = file('b', [def('def:b.Y', 'Class', 'b.Y')], [named('X', 'X', 'a')]);
const files = [a, b];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const aEdges = out.imports.get(a.moduleScope) ?? [];
const ghost = aEdges.find((e) => e.localName === 'Ghost');
expect(ghost).toBeDefined();
// Cap-hit distinction: file target is known, but name never resolved.
expect(ghost!.targetFile).toBe('b');
expect(ghost!.linkStatus).toBe('unresolved');
expect(ghost!.targetDefId).toBeUndefined();
});
});
describe('wildcard expansion', () => {
it('expands `wildcard` into one ImportEdge per exported name', () => {
const b = file('b', [
def('def:b.X', 'Class', 'b.X'),
def('def:b.Y', 'Class', 'b.Y'),
def('def:b.Z', 'Class', 'b.Z'),
]);
const a = file('a', [], [wildcard('b')]);
const files = [a, b];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const edges = out.imports.get(a.moduleScope) ?? [];
expect(edges.length).toBe(3);
expect(edges.every((e) => e.kind === 'wildcard-expanded')).toBe(true);
expect(new Set(edges.map((e) => e.localName))).toEqual(new Set(['X', 'Y', 'Z']));
expect(new Set(edges.map((e) => e.targetDefId))).toEqual(
new Set(['def:b.X', 'def:b.Y', 'def:b.Z']),
);
});
it('leaves a wildcard unresolved when the target file cannot be resolved', () => {
const a = file('a', [], [wildcard('external-pkg')]);
const out = finalize({ files: [a], workspaceIndex: undefined }, defaultHooks([a]));
const edges = out.imports.get(a.moduleScope) ?? [];
expect(edges.length).toBe(1);
expect(edges[0]!.linkStatus).toBe('unresolved');
});
it('expanded bindings land at `origin: wildcard`', () => {
const b = file('b', [def('def:b.X', 'Class', 'b.X')]);
const a = file('a', [], [wildcard('b')]);
const files = [a, b];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const bindings = bindingsFor(out, a.moduleScope, 'X');
expect(bindings.length).toBeGreaterThanOrEqual(1);
const imported = bindings.find((br) => br.origin === 'wildcard');
expect(imported).toBeDefined();
expect(imported!.def.nodeId).toBe('def:b.X');
});
});
describe('re-export flattening', () => {
it('sets transitiveVia on reexport edges', () => {
const c = file('c', [def('def:c.X', 'Class', 'c.X')]);
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 reexportEdge = firstImport(out, b.moduleScope)!;
expect(reexportEdge.kind).toBe('reexport');
expect(reexportEdge.transitiveVia).toEqual(['c']);
});
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 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
// local def) short-circuits to B's local def — surfacing the name
// is a valid optimization that bypasses the recursive crawl, but
// it can also intentionally shadow the re-export.
const c = file('c', [def('def:c.X', 'Class', 'c.X')]);
// Variant 1: B does NOT include X in its own localDefs.
const bThin = file('b', [], [reexport('X', 'X', 'c')]);
const aThin = file('a', [], [named('X', 'X', 'b')]);
const thinFiles = [aThin, bThin, c];
const thinOut = finalize(
{ files: thinFiles, workspaceIndex: undefined },
defaultHooks(thinFiles),
);
const thinEdge = firstImport(thinOut, aThin.moduleScope)!;
expect(thinEdge.linkStatus).toBeUndefined();
expect(thinEdge.targetDefId).toBe('def:c.X');
// `transitiveVia` records the chain: through B (target) into C (leaf).
expect(thinEdge.transitiveVia).toEqual(['b', 'c']);
// Variant 2: B surfaces X via its OWN localDefs (distinct nodeId
// from C's X) → direct B.localDefs lookup short-circuits the
// 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];
const thickOut = finalize(
{ files: thickFiles, workspaceIndex: undefined },
defaultHooks(thickFiles),
);
const thickEdge = firstImport(thickOut, aThick.moduleScope)!;
expect(thickEdge.linkStatus).toBeUndefined();
expect(thickEdge.targetDefId).toBe('def:b.X');
expect(thickEdge.transitiveVia).toBeUndefined();
});
it('resolves a 3-hop re-export chain (a → b → c → d) where intermediates do not surface the name', () => {
const d = file('d', [def('def:d.X', 'Class', 'd.X')]);
const c = file('c', [], [reexport('X', 'X', 'd')]);
const b = file('b', [], [reexport('X', 'X', 'c')]);
const a = file('a', [], [named('X', 'X', 'b')]);
const files = [a, b, c, d];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const edge = firstImport(out, a.moduleScope)!;
expect(edge.linkStatus).toBeUndefined();
expect(edge.targetDefId).toBe('def:d.X');
expect(edge.transitiveVia).toEqual(['b', 'c', 'd']);
});
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. 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)!;
expect(edge.targetFile).toBe('b');
expect(edge.linkStatus).toBe('unresolved');
expect(edge.targetDefId).toBeUndefined();
});
it('falls through wildcard re-exports via the precomputed closure', () => {
// B has `export * from './c'` (wildcard re-export). A imports `X`
// 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')]);
const files = [a, b, c];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const edge = firstImport(out, a.moduleScope)!;
expect(edge.linkStatus).toBeUndefined();
expect(edge.targetDefId).toBe('def:c.X');
});
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 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}`;
if (i === CHAIN_LEN) {
chain.push(file(fp, [def(`def:chain${i}.X`, 'Class', `chain${i}.X`)]));
} else {
chain.push(file(fp, [], [reexport('X', 'X', `chain${i + 1}`)]));
}
}
const consumer = file('consumer', [], [named('X', 'X', 'chain1')]);
const files = [consumer, ...chain];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const edge = firstImport(out, consumer.moduleScope)!;
expect(edge.targetFile).toBe('chain1');
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 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')]);
const b = file('b', [], [reexport('X', 'X', 'c'), reexport('X', 'X', 'd')]);
const a = file('a', [], [named('X', 'X', 'b')]);
const files = [a, b, c, d];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const edge = firstImport(out, a.moduleScope)!;
expect(edge.linkStatus).toBeUndefined();
// First re-export draft (`from 'c'`) wins. Recording this as the
// contract — if the algorithm changes to last-wins or merge, this
// test must be updated alongside the multi-binding propagation
// pass that mirrors typeBindings (which also uses first-wins).
expect(edge.targetDefId).toBe('def:c.X');
});
it('keeps first-wins precedence stable through cyclic shadowing', () => {
const c = file('c', [def('def:c.X', 'Class', 'c.X')]);
const d = file('d', [def('def:d.X', 'Class', 'd.X')]);
const a = file('a', [], [reexport('X', 'X', 'b'), reexport('X', 'X', 'd')]);
const b = file('b', [], [reexport('X', 'X', 'a'), reexport('X', 'X', 'c')]);
const consumer = file('consumer', [], [named('X', 'X', 'a')]);
const files = [consumer, a, b, c, d];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const edge = firstImport(out, consumer.moduleScope)!;
expect(edge.linkStatus).toBeUndefined();
expect(edge.targetDefId).toBe('def:c.X');
expect(edge.transitiveVia).toEqual(['a', 'b', 'c']);
});
});
describe('aliased + namespace imports', () => {
it('resolves an alias under its local name while preserving targetExportedName', () => {
const b = file('b', [def('def:b.User', 'Class', 'b.User')]);
const a = file('a', [], [aliased('Account', 'User', 'Account', 'b')]);
const files = [a, b];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const edge = firstImport(out, a.moduleScope)!;
expect(edge.kind).toBe('alias');
expect(edge.localName).toBe('Account');
expect(edge.targetExportedName).toBe('User');
expect(edge.targetDefId).toBe('def:b.User');
});
it('records namespace imports with origin=namespace in bindings', () => {
// Provider emits a synthetic module-representing def so the namespace
// binding can anchor to a real SymbolDefinition.
const numpyFile = file('numpy.py', [
def('def:numpy', 'Namespace', 'numpy'),
def('def:numpy.array', 'Function', 'numpy.array'),
]);
const a = file('a', [], [namespace('np', 'numpy', 'numpy.py')]);
const files = [a, numpyFile];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const npEdge = firstImport(out, a.moduleScope)!;
expect(npEdge.kind).toBe('namespace');
expect(npEdge.targetModuleScope).toBe(numpyFile.moduleScope);
const bindings = bindingsFor(out, a.moduleScope, 'np');
expect(bindings.some((b) => b.origin === 'namespace')).toBe(true);
expect(bindings.find((b) => b.origin === 'namespace')!.def.nodeId).toBe('def:numpy');
});
it('links a namespace import to the module scope even when no module-def exists', () => {
// No synthetic def in target — the edge still resolves to the module
// scope, just without a `targetDefId`. Bindings materialization skips
// the binding (no def to anchor to), but the edge itself is linked.
const numpyFile = file('numpy.py', [def('def:numpy.array', 'Function', 'numpy.array')]);
const a = file('a', [], [namespace('np', 'numpy', 'numpy.py')]);
const files = [a, numpyFile];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const npEdge = firstImport(out, a.moduleScope)!;
expect(npEdge.kind).toBe('namespace');
expect(npEdge.linkStatus).toBeUndefined();
expect(npEdge.targetModuleScope).toBe(numpyFile.moduleScope);
expect(npEdge.targetDefId).toBeUndefined();
});
});
describe('module-scope binding materialization', () => {
it('lays down local defs with origin=local', () => {
const a = file('a', [def('def:a.X', 'Class', 'a.X')]);
const out = finalize({ files: [a], workspaceIndex: undefined }, defaultHooks([a]));
const bindings = bindingsFor(out, a.moduleScope, 'X');
expect(bindings.length).toBe(1);
expect(bindings[0]!.origin).toBe('local');
expect(bindings[0]!.def.nodeId).toBe('def:a.X');
});
it('layers imports on top of local defs via mergeBindings', () => {
const b = file('b', [def('def:b.User', 'Class', 'b.User')]);
const a = file('a', [def('def:a.User', 'Class', 'a.User')], [named('User', 'User', 'b')]);
const files = [a, b];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const bindings = bindingsFor(out, a.moduleScope, 'User');
expect(bindings.length).toBe(2);
expect(bindings.some((br) => br.origin === 'local')).toBe(true);
expect(bindings.some((br) => br.origin === 'import')).toBe(true);
});
it('resolves imported defs across many files via O(1) defById index lookup', () => {
// Regression for the materializeBindings O(N²) → O(1) fix:
// a single consumer importing one symbol from each of N other
// files must materialize an `import` binding for each. Prior to
// the fix, finding `def.X` for each edge meant re-scanning every
// file's localDefs (O(N × D × E)). With the index, every
// `defById.get` is O(1), so this test stays under 50 ms even
// at N=200.
const N = 200;
const leafFiles: FinalizeFile[] = [];
const imports: ParsedImport[] = [];
for (let i = 0; i < N; i++) {
const fp = `leaf${i}`;
const localName = `Leaf${i}`;
leafFiles.push(file(fp, [def(`def:${fp}.${localName}`, 'Class', `${fp}.${localName}`)]));
imports.push(named(localName, localName, fp));
}
const consumer = file('consumer', [], imports);
const files = [consumer, ...leafFiles];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
for (let i = 0; i < N; i++) {
const bindings = bindingsFor(out, consumer.moduleScope, `Leaf${i}`);
expect(bindings.length).toBe(1);
expect(bindings[0]!.origin).toBe('import');
expect(bindings[0]!.def.nodeId).toBe(`def:leaf${i}.Leaf${i}`);
}
});
it('honors provider precedence: mergeBindings can drop existing bindings', () => {
// Provider decides imports win over locals (Python-ish precedence).
const b = file('b', [def('def:b.User', 'Class', 'b.User')]);
const a = file('a', [def('def:a.User', 'Class', 'a.User')], [named('User', 'User', 'b')]);
const files = [a, b];
const hooks: FinalizeHooks = {
...defaultHooks(files),
mergeBindings(_existing, incoming) {
// Replace existing with incoming — last-write-wins across tiers.
return incoming;
},
};
const out = finalize({ files, workspaceIndex: undefined }, hooks);
const bindings = bindingsFor(out, a.moduleScope, 'User');
// Only the last merged layer (the import) remains.
expect(bindings.length).toBe(1);
expect(bindings[0]!.origin).toBe('import');
});
});
describe('SCC-DAG exposure for parallelism', () => {
it('returns SCCs in reverse-topological order (leaves first)', () => {
// c ← b ← a (a imports b, b imports c, c has no imports)
const c = file('c', [def('def:c.C', 'Class', 'c.C')]);
const b = file('b', [def('def:b.B', 'Class', 'b.B')], [named('C', 'C', 'c')]);
const a = file('a', [def('def:a.A', 'Class', 'a.A')], [named('B', 'B', 'b')]);
const files = [a, b, c];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
// First SCC processed must be `c` (leaf), last must be `a`.
expect(out.sccs[0]!.files[0]).toBe('c');
expect(out.sccs[out.sccs.length - 1]!.files[0]).toBe('a');
});
});
});