mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Skill copy sync / shipped skills drift guard (push) Has been cancelled
941 lines
44 KiB
TypeScript
941 lines
44 KiB
TypeScript
/**
|
||
* 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 });
|
||
|
||
/** A named import that also republishes the name — see `reexportsName` on `ParsedImport`. */
|
||
const namedReexporting = (
|
||
localName: string,
|
||
importedName: string,
|
||
targetRaw: string,
|
||
): ParsedImport => ({
|
||
kind: 'named',
|
||
localName,
|
||
importedName,
|
||
targetRaw,
|
||
reexportsName: true,
|
||
});
|
||
|
||
/** The `from m import X as Y` form of {@link namedReexporting}. */
|
||
const aliasReexporting = (
|
||
localName: string,
|
||
importedName: string,
|
||
targetRaw: string,
|
||
): ParsedImport => ({
|
||
kind: 'alias',
|
||
localName,
|
||
importedName,
|
||
alias: localName,
|
||
targetRaw,
|
||
reexportsName: true,
|
||
});
|
||
|
||
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']);
|
||
});
|
||
|
||
// ── Languages with no dedicated re-export form (Python) ──────────────
|
||
// Contract and rationale live on `reexportsName` in `ParsedImport`.
|
||
it('resolves through a named import flagged reexportsName (Python __init__.py surface)', () => {
|
||
const c = file('c', [def('def:c.X', 'Function', 'c.X')]);
|
||
// `pkg/__init__.py`: re-publishes X without surfacing it in localDefs.
|
||
const b = file('b', [], [namedReexporting('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.linkStatus).toBeUndefined();
|
||
expect(edge.targetDefId).toBe('def:c.X');
|
||
expect(edge.transitiveVia).toEqual(['b', 'c']);
|
||
});
|
||
|
||
it('leaves a plain named import out of the closure (no reexportsName → unchanged behavior)', () => {
|
||
// Negative control: this is the pre-existing behavior every language
|
||
// without the flag still gets. Only the flag opts a named import in, so
|
||
// adding it cannot silently widen resolution for TS/Java/Go/etc.
|
||
const c = file('c', [def('def:c.X', 'Function', 'c.X')]);
|
||
const b = file('b', [], [named('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.targetDefId).toBeUndefined();
|
||
});
|
||
|
||
it('resolves a 3-hop chain of reexportsName named imports', () => {
|
||
// `pkg/__init__.py` → `pkg/sub/__init__.py` → defining module: the shape
|
||
// a nested Python package produces.
|
||
const d = file('d', [def('def:d.X', 'Function', 'd.X')]);
|
||
const c = file('c', [], [namedReexporting('X', 'X', 'd')]);
|
||
const b = file('b', [], [namedReexporting('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.targetDefId).toBe('def:d.X');
|
||
expect(edge.transitiveVia).toEqual(['b', 'c', 'd']);
|
||
});
|
||
|
||
it('keys the closure by the published alias for `from m import X as Y`', () => {
|
||
// `from c import X as Y` publishes `Y`, so an importer asking for Y must
|
||
// reach X's definition, and one asking for X must not.
|
||
const c = file('c', [def('def:c.X', 'Function', 'c.X')]);
|
||
const b = file('b', [], [aliasReexporting('Y', 'X', 'c')]);
|
||
const aY = file('a', [], [named('Y', 'Y', 'b')]);
|
||
const filesY = [aY, b, c];
|
||
const outY = finalize({ files: filesY, workspaceIndex: undefined }, defaultHooks(filesY));
|
||
const edgeY = firstImport(outY, aY.moduleScope)!;
|
||
expect(edgeY.targetDefId).toBe('def:c.X');
|
||
// Path as well as destination: reaching `def:c.X` by any other route
|
||
// would mean the closure was keyed by `importedName`, the exact bug
|
||
// this test exists to catch.
|
||
expect(edgeY.transitiveVia).toEqual(['b', 'c']);
|
||
|
||
const aX = file('a', [], [named('X', 'X', 'b')]);
|
||
const filesX = [aX, b, c];
|
||
const outX = finalize({ files: filesX, workspaceIndex: undefined }, defaultHooks(filesX));
|
||
expect(firstImport(outX, aX.moduleScope)!.targetDefId).toBeUndefined();
|
||
});
|
||
|
||
it('threads a definition out of a reexportsName cycle (package __init__ cycle)', () => {
|
||
// b and c re-export from each other (`Y`), the shape two package
|
||
// `__init__.py` files that import from each other produce. `X` enters
|
||
// the cycle at c and must reach d's real def through it.
|
||
//
|
||
// The cycle needs a terminal def to be worth asserting on: with no
|
||
// `SymbolDefinition` anywhere in the fixture, `toBeUndefined()` holds
|
||
// for a correct implementation, a reverted one, and a broken one alike.
|
||
const d = file('d', [def('def:d.X', 'Function', 'd.X')]);
|
||
const c = file('c', [], [namedReexporting('X', 'X', 'd'), namedReexporting('Y', 'Y', 'b')]);
|
||
const b = file('b', [], [namedReexporting('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.targetDefId).toBe('def:d.X');
|
||
expect(edge.transitiveVia).toEqual(['b', 'c', 'd']);
|
||
});
|
||
|
||
it('drops a name two reexportsName imports publish from different files', () => {
|
||
// CPython rebinds, so `from .v2 import Client` is the live `Client` and
|
||
// declaration-order first-wins would attribute every importer to the
|
||
// dead v1. Last-wins is no better — a `try:`/`except ImportError:` or
|
||
// `if sys.version_info` pair runs exactly one branch and which is not
|
||
// decidable here — so the ambiguous name is dropped and the importer
|
||
// stays unresolved, the pre-#2864 answer.
|
||
const v1 = file('v1', [def('def:v1.Client', 'Class', 'v1.Client')]);
|
||
const v2 = file('v2', [def('def:v2.Client', 'Class', 'v2.Client')]);
|
||
const pkg = file(
|
||
'pkg',
|
||
[],
|
||
[namedReexporting('Client', 'Client', 'v1'), namedReexporting('Client', 'Client', 'v2')],
|
||
);
|
||
const a = file('a', [], [named('Client', 'Client', 'pkg')]);
|
||
const files = [a, pkg, v1, v2];
|
||
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
|
||
const edge = firstImport(out, a.moduleScope)!;
|
||
expect(edge.targetDefId).toBeUndefined();
|
||
expect(edge.linkStatus).toBe('unresolved');
|
||
// The file-level dependency is unaffected — only the symbol-level
|
||
// attribution is withheld.
|
||
expect(edge.targetFile).toBe('pkg');
|
||
});
|
||
|
||
it('keeps a name two reexportsName imports publish from the SAME file', () => {
|
||
// Duplicate imports of one target are not ambiguous, so the
|
||
// drop-on-collision rule must not fire on them.
|
||
const impl = file('impl', [def('def:impl.X', 'Function', 'impl.X')]);
|
||
const pkg = file(
|
||
'pkg',
|
||
[],
|
||
[namedReexporting('X', 'X', 'impl'), namedReexporting('X', 'X', 'impl')],
|
||
);
|
||
const a = file('a', [], [named('X', 'X', 'pkg')]);
|
||
const files = [a, pkg, impl];
|
||
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
|
||
expect(firstImport(out, a.moduleScope)!.targetDefId).toBe('def:impl.X');
|
||
});
|
||
|
||
it('does not let a wildcard refill an ambiguous name', () => {
|
||
// Named re-exports take precedence over wildcards, so suppressing only
|
||
// the named loop would hand the name to `from .other import *` and
|
||
// reinstate an arbitrary winner through the back door.
|
||
const v1 = file('v1', [def('def:v1.Client', 'Class', 'v1.Client')]);
|
||
const v2 = file('v2', [def('def:v2.Client', 'Class', 'v2.Client')]);
|
||
const other = file('other', [def('def:other.Client', 'Class', 'other.Client')]);
|
||
const pkg = file(
|
||
'pkg',
|
||
[],
|
||
[
|
||
namedReexporting('Client', 'Client', 'v1'),
|
||
namedReexporting('Client', 'Client', 'v2'),
|
||
wildcard('other'),
|
||
],
|
||
);
|
||
const a = file('a', [], [named('Client', 'Client', 'pkg')]);
|
||
const files = [a, pkg, v1, v2, other];
|
||
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
|
||
expect(firstImport(out, a.moduleScope)!.targetDefId).toBeUndefined();
|
||
});
|
||
|
||
it('caps transitiveVia at 32 on a deep chain and marks it truncated', () => {
|
||
// Each hop copies the inherited path, so an uncapped chain is O(depth^2)
|
||
// in time and retained memory. At depth 400 the cap is worth 67 -> 25 ms
|
||
// and 145 -> 40 MB; the def it resolves to must not change.
|
||
//
|
||
// The truncated shape is fully determined: `extendVia` keeps the head,
|
||
// 30 inherited entries, and the marker — so it is 32 entries naming the
|
||
// nearest 31 hops, for any depth past the cap.
|
||
const depth = 60;
|
||
const files = [file('leaf', [def('def:leaf.X', 'Function', 'leaf.X')])];
|
||
let prev = 'leaf';
|
||
for (let d = 0; d < depth; d++) {
|
||
files.push(file(`hop${d}`, [], [namedReexporting('X', 'X', prev)]));
|
||
prev = `hop${d}`;
|
||
}
|
||
files.push(file('app', [], [named('X', 'X', prev)]));
|
||
const app = files[files.length - 1]!;
|
||
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
|
||
const edge = firstImport(out, app.moduleScope)!;
|
||
expect(edge.targetDefId).toBe('def:leaf.X');
|
||
const expected = [...Array.from({ length: 31 }, (_, i) => `hop${depth - 1 - i}`), '…'];
|
||
expect(edge.transitiveVia).toEqual(expected);
|
||
expect(edge.transitiveVia).toHaveLength(32);
|
||
});
|
||
|
||
it('leaves transitiveVia intact for chains under the cap', () => {
|
||
const d = file('d', [def('def:d.X', 'Function', 'd.X')]);
|
||
const c = file('c', [], [namedReexporting('X', 'X', 'd')]);
|
||
const b = file('b', [], [namedReexporting('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));
|
||
expect(firstImport(out, a.moduleScope)!.transitiveVia).toEqual(['b', 'c', 'd']);
|
||
});
|
||
|
||
it('leaves a reexportsName import reclassified to namespace out of the closure', () => {
|
||
// Python's `from . import logger`: the provider's `isNamespaceImport`
|
||
// hook reclassifies the draft to `namespace`, which aliases the target
|
||
// MODULE and publishes no name. Admitting it republished whatever def
|
||
// shared the module's simple name — for `logger.py` holding a
|
||
// module-level `logger = logging.getLogger(...)`, importers of
|
||
// `from pkg import logger` bound to that Variable instead of the module.
|
||
const logger = file('logger', [def('def:logger.logger', 'Variable', 'logger.logger')]);
|
||
const pkg = file('pkg', [], [namedReexporting('logger', 'logger', 'logger')]);
|
||
const a = file('a', [], [named('logger', 'logger', 'pkg')]);
|
||
const files = [a, pkg, logger];
|
||
const hooks = {
|
||
...defaultHooks(files),
|
||
isNamespaceImport: (imp: ParsedImport, targetFile: string | null) =>
|
||
targetFile === 'logger' && imp.kind === 'named' && imp.localName === 'logger',
|
||
};
|
||
const out = finalize({ files, workspaceIndex: undefined }, hooks);
|
||
expect(firstImport(out, a.moduleScope)!.targetDefId).toBeUndefined();
|
||
});
|
||
|
||
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` records the path the closure walked, now bounded by
|
||
// `MAX_VIA_LENGTH` — copying the inherited array at every hop is
|
||
// O(depth^2) in time and retained memory, and at this depth that is the
|
||
// dominant cost of the pass. Full resolution above is the load-bearing
|
||
// assertion and is unchanged: the chain is still walked end to end, only
|
||
// the provenance array is summarized. `transitiveVia` has no production
|
||
// reader; it is diagnostic.
|
||
expect(edge.transitiveVia).toEqual([
|
||
...Array.from({ length: 31 }, (_, i) => `chain${i + 1}`),
|
||
'…',
|
||
]);
|
||
expect(edge.transitiveVia).toHaveLength(32);
|
||
});
|
||
|
||
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');
|
||
});
|
||
});
|
||
|
||
/**
|
||
* `typeOnly` is the one `ParsedImport` fact `check --cycles` needs that
|
||
* finalize cannot re-derive: the emitted `kind` for `import type { X }` is
|
||
* the same `named` a value import produces. So it has to be carried, and
|
||
* carried onto `base` specifically — every finalized edge is built by
|
||
* spreading `base`, including the re-export and namespace paths.
|
||
*/
|
||
describe('type-only carry-through', () => {
|
||
const typeNamed = (localName: string, importedName: string, targetRaw: string): ParsedImport =>
|
||
({ ...named(localName, importedName, targetRaw), typeOnly: true }) as ParsedImport;
|
||
|
||
it('carries `typeOnly` onto a linked edge', () => {
|
||
const b = file('b', [def('def:b.User', 'Class', 'b.User')]);
|
||
const a = file('a', [], [typeNamed('User', 'User', 'b')]);
|
||
const files = [a, b];
|
||
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
|
||
const edge = firstImport(out, a.moduleScope)!;
|
||
// Same kind as the value import — which is exactly why the flag exists.
|
||
expect(edge.kind).toBe('named');
|
||
expect(edge.targetDefId).toBe('def:b.User');
|
||
expect(edge.typeOnly).toBe(true);
|
||
});
|
||
|
||
it('a value import gets NO `typeOnly` property', () => {
|
||
// Absent, not `false`: the field is spread in only when set, so an
|
||
// ordinary edge keeps the property set it had before this existed.
|
||
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));
|
||
expect(firstImport(out, a.moduleScope)!).not.toHaveProperty('typeOnly');
|
||
});
|
||
|
||
it('survives an unresolved target', () => {
|
||
// The unresolved branch builds its own `base`; it must set the flag too,
|
||
// or a package-external `import type` would read as a value import if it
|
||
// ever became resolvable.
|
||
const a = file('a', [], [typeNamed('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.typeOnly).toBe(true);
|
||
});
|
||
|
||
it('survives a re-export hop', () => {
|
||
// `export type { X } from './y'` in a barrel, imported through it. The
|
||
// finalized edge here is built on a later spread of `base`, not the one
|
||
// `makeEdgeDrafts` returned.
|
||
const leaf = file('leaf', [def('def:leaf.User', 'Class', 'leaf.User')]);
|
||
const barrel = file('barrel', [], [reexport('User', 'User', 'leaf')]);
|
||
const a = file('a', [], [typeNamed('User', 'User', 'barrel')]);
|
||
const files = [a, barrel, leaf];
|
||
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
|
||
const edge = firstImport(out, a.moduleScope)!;
|
||
expect(edge.targetDefId).toBe('def:leaf.User');
|
||
expect(edge.typeOnly).toBe(true);
|
||
});
|
||
|
||
it('a kind with no erased spelling never gains the flag', () => {
|
||
const b = file('b', [def('def:b.User', 'Class', 'b.User')]);
|
||
const a = file('a', [], [sideEffect('b'), dynamicResolved('b'), wildcard('b')]);
|
||
const files = [a, b];
|
||
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
|
||
const edges = out.imports.get(a.moduleScope) ?? [];
|
||
expect(edges.length).toBeGreaterThan(0);
|
||
expect(edges.filter((e) => 'typeOnly' in e)).toEqual([]);
|
||
});
|
||
});
|
||
|
||
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');
|
||
});
|
||
});
|
||
});
|