mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* feat(ingestion): TypeScript registry-primary scope resolution (Ring 3) - Add TypeScript ScopeResolver stack (query/captures/interpret, import decomposition, hooks, arity, merge, receiver binding) and register in SCOPE_RESOLVERS. - Harden shared compound receiver and receiver-bound CALLS pass for map for-of tuple bindings, dotted typeRef shapes, and callable-alias fallbacks. - Flip TypeScript into MIGRATED_LANGUAGES; refresh AGENTS.md and type-resolution-system.md. - Shared finalize-algorithm updates for cross-file scope parity. - Tests: TS scope-resolution unit suite; legacy call-processor suite forces REGISTRY_PRIMARY_TYPESCRIPT=0; registry-primary flag test opts out TS in override scenario. Made-with: Cursor * fix(ingestion): SCC-ordered cross-file return-type propagation + multi-hop re-export resolution Fix CI failures on PR #1050 (TypeScript registry-primary migration) by making `propagateImportedReturnTypes` deterministic via reverse- topological SCC ordering and updating the multi-hop re-export contract to match `followReexportChain` behavior. Why: the legacy pass mirrored an intermediate ref instead of the terminal type when an importer was processed before its source module had its own typeBindings chain-followed (4-file alias chain regression in `ts-simple` fixture: `models.User -> service.user -> app.user` collapsed to `getUser` instead of `User`). Reverse-topological walk of `indexes.sccs` (leaves first) lets every importer see the source's already-followed terminal type in a single pass. Changes: - `imported-return-types.ts`: rewrite to walk SCCs leaves-first, chain- follow the source module's typeBindings BEFORE mirroring, and chain- follow the importer's typeBindings AFTER mirroring. Cyclic SCCs reach a partial fixpoint (no convergence guarantee, ts-circular only asserts no-throw). - `finalize-algorithm.ts`: docstring update on `FinalizeFile.localDefs` to reflect that `followReexportChain` resolves multi-hop re-exports through barrels even when intermediates do not surface the name - surfacing is now a static optimization, not a correctness requirement. - `contract/scope-resolver.ts` Invariant I3: explicitly document the SCC ordering requirement. - `pipeline/run.ts`: split PROF timer into `finalize` and `propagate` so the pass's cost is observable independently. - `ARCHITECTURE.md` Performance notes: describe SCC-ordered propagation. - `imported-return-types.ts`: expand chain-depth comment (2x effective depth from pre/post follow), add multi-ref break rationale, add `ts-simple` motivating-fixture pointer. Tests: - `finalize-algorithm.test.ts`: add 4 cases (3-hop chain, cyclic re-export visited-set guard, wildcard re-export fall-through, multi-source first-match-wins); fix misleading shared nodeId in the thick variant; rename and update the multi-hop test for the new contract (transitiveVia assertion on the thin variant). - `imported-return-types.test.ts` (NEW): unit tests for the SCC pass pinning topological collapse, local-annotation guard, missing-source skip, and cyclic-SCC no-throw. - `cross-file-binding.test.ts` + `ts-deep-alias-chain` fixture (NEW): 5-file integration regression guard for SCC-ordered propagation through 4 module boundaries. Validation: 865 scope-resolution + cross-file tests pass on Windows; typecheck clean across both packages; only pre-existing Swift overload failures remain (verified on PR base commit, environmental). Made-with: Cursor * fix(ingestion): address PR #1050 review findings — side-effect imports, resolve-cache perf, adapter signature Three independent fixes surfaced by the production-readiness review of the TypeScript registry-primary scope-resolution migration (RFC #909 Ring 3). All three pass under both REGISTRY_PRIMARY_TYPESCRIPT=0 and =1. 1. Side-effect imports were silently dropped (correctness regression). The legacy DAG emitted IMPORTS edges for `import './polyfill'` because its tree-sitter query matches `(import_statement source: (string))` regardless of clause. The new registry-primary path returned `[]` from `splitImportStatement()` for clause-less imports, so no ParsedImport / ImportEdge was ever produced — silent file-level edge loss. Add a generic 'side-effect' variant to `ParsedImport` and `ImportEdge['kind']` in `gitnexus-shared`; finalize resolves the target file and pre-finalizes the edge (no `targetDefId`, no `BindingRef`) so the SCC fixpoint loop skips it. The TypeScript provider now emits + interprets the new kind end-to-end. The variant is intentionally generic so other languages (Rust `use foo as _`, Python module-init) can adopt it. 2. Per-import re-derivation in `resolveImportTarget` (perf regression). The TS adapter built `new Set(allFilePaths)` on every call and let `resolveTsImportTarget` re-derive `allFileList` / `normalizedFileList` and discard the `resolveCache`. For a workspace with N files and M imports that's O(N × M) work per pass. Wrap the adapter in a closure that memoizes all five derived values keyed on the orchestrator's `ReadonlySet` identity; reset only when the set reference changes (start of new pass). New cost: O(N + M). 3. Misleading fake `ParsedImport` in the adapter (architecture). The adapter constructed `{ kind: 'named', localName: '_', importedName: '_', targetRaw }` to call `resolveTsImportTarget`, even though only `targetRaw` and the structural-typed context are read. Extract `resolveTsTarget(targetRaw, ctx)` so the adapter has an honest signature; `resolveTsImportTarget` still works for other callers. Also extract `narrowTsContext` for the type narrowing. Tests: - New 4-file fixture `typescript-side-effect-imports` with two side-effect imports + one named import. - New "TypeScript side-effect imports" describe in `test/integration/resolvers/typescript.test.ts` (parity-gated by `ci-scope-parity.yml` — runs under both flag states). - Updated 2 unit tests to expect 1 side-effect ParsedImport and 4 `@import.statement` matches (was 0 / 3). - 785 / 785 TS scope-resolution tests pass under both REGISTRY_PRIMARY_TYPESCRIPT=0 and =1. Made-with: Cursor * fix(scope): address Codex adversarial review findings on PR #1050 Four findings from the Codex adversarial review broke registry-primary TypeScript resolution for common patterns. All four now have unit and integration regression coverage that pass under both `REGISTRY_PRIMARY_TYPESCRIPT=0` (legacy DAG) and the default registry-primary path. [high] tsconfig path aliases dropped: Threaded `tsconfigPaths` through ScopeResolver via a new opaque `resolutionConfig` parameter and a `loadResolutionConfig(repoPath)` hook. The orchestrator (`scopeResolutionPhase` + `runScopeResolution`) loads it once per workspace pass and forwards into every `resolveImportTarget` call. TypeScript resolver now resolves `@/services/user` style imports through the standard resolver's alias branch. [high] TSX parsed with the wrong grammar: `emitTsScopeCaptures` now picks the parser/query by `filePath` (`.tsx` -> TSX grammar) and validates cached trees against the expected grammar via the new exported `tsCachedTreeMatchesGrammar` helper. Stale TS-grammar trees for `.tsx` files no longer leak through the scope query. [medium] Literal dynamic imports never linked: Added `kind: 'dynamic-resolved'` to `ParsedImport` and `ImportEdge`. The decomposer emits a synthetic `@import.literal` capture for string-literal dynamic imports; the interpreter maps that to `dynamic-resolved`; finalize pre-finalizes it as a file-level terminal (same shape as `side-effect`). `import('./feature')` now produces a real IMPORTS edge under the registry-primary path. Legacy DAG keeps its existing behavior — the new integration assertion is gated behind the flag. [medium] Namespace re-exports invisible from barrels: The decomposer now emits TWO captures for `export * as ns from './m'` — the existing `reexport-namespace` import draft AND a synthetic `@declaration.namespace` capture (via `buildNamespaceDeclarationMatch`). The latter creates a Namespace `SymbolDefinition` in the barrel's `localDefs`, so downstream `import { ns } from './barrel'` resolves through `findExportByName`. Regression fixtures under `gitnexus/test/fixtures/lang-resolution/`: - typescript-tsconfig-aliases (`@/` alias) - typescript-tsx-jsx (Button.tsx + App.tsx with JSX) - typescript-dynamic-import (`await import('./feature')`) - typescript-reexport-namespace (`export * as Models from './base'`) Validation: - gitnexus-shared builds clean - gitnexus typecheck clean - 385/385 TS scope-resolution tests pass under both `REGISTRY_PRIMARY_TYPESCRIPT=0` and default Made-with: Cursor * perf(scope): O(1) defById lookup + bounded re-export depth (PR #1050 round 3) Addresses the round-3 PR #1050 reviews (Claude adversarial + xkonjin): both flagged the existing O(N²) `findDefById` linear scan in `materializeBindings` and the unbounded recursion in `followReexportChain` as production-readiness blockers for TypeScript monorepos. Both fixes land alongside their regression tests under both `REGISTRY_PRIMARY_TYPESCRIPT=0` and the default registry-primary path. [high] materializeBindings O(N_files × N_defs × N_edges) → O(N_defs + N_edges): Build a `nodeId → SymbolDefinition` index map once at the top of `materializeBindings` (one O(N_defs) pass), then replace the per-edge `findDefById(files, edge.targetDefId)` linear scan with an O(1) `defById.get(edge.targetDefId)` lookup. Also drop the now-unused `findDefById` helper. At realistic TypeScript monorepo scale (~5k files × ~50 defs/file × ~100k linked import edges) this is the difference between ~25 s and a few ms inside finalize. Regression test in `finalize-algorithm.test.ts` builds 200 leaf files + 1 consumer importing one symbol from each, asserts every binding materializes correctly. [medium] followReexportChain unbounded recursion: The existing `visited` set caps depth at `O(N_files)` but allows recursion proportional to barrel-chain depth, mismatching the explicit "Iterative DFS to avoid stack overflow" policy in `tarjanSccs`. Added a `MAX_REEXPORT_DEPTH = 100` constant and a `depth` parameter to `followReexportChain` (defaults to 0); each recursive call passes `depth + 1` and the function returns `null` when the cap is exceeded. 100 is comfortably above any realistic hand-authored barrel chain (typical depth 1-5; auto-generated barrels rarely exceed 20) while staying well below JS engine call stack limits. Regression test wires a 200-link reexport chain and verifies the crawl terminates cleanly with `linkStatus: 'unresolved'` (no terminal def reachable within the budget). [low] synthesizeInstanceofNarrowings bare-identifier-only limitation: xkonjin's review #4 noted that the LHS narrowing only handles bare identifiers (`if (x instanceof Foo)`), not member expressions (`if (user.address instanceof Address)`). Added a JSDoc note explaining the constraint and pointing readers at field-type resolution as the workaround for member-chain receivers. Validation: - gitnexus-shared builds clean - gitnexus typecheck clean - 413/413 tests pass under both flag states for finalize-algorithm + TS unit + TS integration suites - 972/972 tests pass across full scope-resolution + Python + C# integration smoke (no cross-language regression) Made-with: Cursor * 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 * fix(scope): remove non-null assertions from scope resolution Made-with: Cursor * fix(scope): address TypeScript review follow-ups Made-with: Cursor * 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
640 lines
29 KiB
TypeScript
640 lines
29 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 });
|
||
|
||
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');
|
||
});
|
||
});
|
||
});
|