mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
feat(shared): SCC-aware finalize algorithm with bounded fixpoint (#915, RFC #909 Ring 2 SHARED) (#962)
* feat(shared): SCC-aware finalize algorithm with bounded fixpoint (#915, RFC #909 Ring 2 SHARED) Implements RFC §3.2 Phase 2 as pure logic in `gitnexus-shared`. Takes per-file parse output and returns linked `ImportEdge[]` + materialized module-scope bindings, fully language-agnostic (target resolution, wildcard expansion, and binding precedence all go through caller hooks). Three-phase algorithm: 1. Tarjan SCC over the file-level import graph (iterative, deterministic node order, O(V+E)). Returns SCCs in reverse-topological order so leaves finalize before dependents — and so disjoint SCCs are explicitly surfaced for parallel-processing callers. 2. Per-SCC bounded fixpoint. For each SCC in topo order, iterate up to `N = |intra-SCC edges|`; each pass tries to resolve every still- unlinked edge by looking up the imported name in the target file's local defs. Stops early when no progress. Edges still unlinked after the cap get `linkStatus: 'unresolved'` — keeps malformed inputs bounded and preserves the RFC §4v2 capped-signal contract for unresolved markers. 3. Wildcard expansion + module-scope binding materialization. For each `wildcard` ParsedImport that linked to a module, expand via `expandsWildcardTo` into one `wildcard-expanded` ImportEdge per exported name. Bindings per module scope are the merge of local defs (`origin: 'local'`), named / alias / reexport imports (`origin: 'import' | 'reexport'`), namespace imports (`origin: 'namespace'`), and wildcard expansions (`origin: 'wildcard'`), with precedence delegated to `provider.mergeBindings`. Dynamic imports rule: `kind: 'dynamic-unresolved'` passes through as an ImportEdge with `targetFile: null` and no BindingRef. Re-export flattening: reexport edges land with `transitiveVia: [targetFile]`. Multi-hop chains settle iteratively across the fixpoint. Types: - Adds `'wildcard'` variant to ParsedImport (parse-time signal for `import * from M`). The finalize-only `'wildcard-expanded'` ImportEdge kind is unchanged and remains finalize output only, as documented. - Exports `finalize` + `FinalizeFile` / `FinalizeInput` / `FinalizeHooks` / `FinalizeOutput` / `FinalizedScc` / `FinalizeStats`. Simple-name derivation: `deriveSimpleName` uses `def.qualifiedName` as the authoritative source (tail after the last `.`). Defs without a qualifiedName are not name-resolvable by this algorithm — an explicit design choice that trades strictness for predictability (no heuristic nodeId parsing). Tests (20, all passing): - Trivial: empty workspace · acyclic resolution · unresolvable target (file + name) · dynamic-unresolved passthrough. - Cycles: A↔B two-file cycle linked · cycles packed into SCC with isCycle=true · disjoint cycles produce disjoint SCCs · mixed linked/unresolved edges reported correctly in stats. - Wildcards: one ImportEdge per exported name · unresolved wildcards survive as single edges · expanded bindings carry origin='wildcard'. - Reexports: transitiveVia carries the intermediate file path. - Aliased + namespace: alias preserves targetExportedName under its local name · namespace links to module scope even without a module-def. - Bindings: locals land as origin='local' · imports layer on via mergeBindings · mergeBindings can drop existing (last-write-wins precedence honored). - SCC-DAG: reverse-topological ordering verified (leaf first). Combined scope-resolution / model / shadow suite: 229/229 pass. `tsc --noEmit` clean in both `gitnexus-shared` and `gitnexus`. Closes part of #909. Unblocks #917 (Registry.lookup's import-chain fast path consumes finalized ImportEdges); unblocks Ring 3 language migrations (per-language providers supply FinalizeHooks implementations). * chore(shared): address #915 review findings — dead code, docs, tests Review thread on PR #962. Code changes: - Remove dead `resolvedTargets` map + `keyFor` + `ParsedImportKey` type alias. The map was populated but never read; originally intended to cache / dedup resolutions for later phases but that path was never wired (finding 1.1). - Drop unused params (`_edgeIndex`, `_hooks`, `_workspace`) from `tryFinalize`. No planned fixpoint-state consultation; no reason to keep them reserved (finding 2.1). Documentation: - `FinalizeFile.localDefs` now documents the multi-hop re-export contract explicitly: `finalize` looks names up in the target's static `localDefs`; if B only re-exports from C and doesn't surface the name in its own localDefs, A's import of that name from B will hit the cap and be marked unresolved. Parsers that want multi-hop chains to settle end-to-end must include re-exported names in the intermediate file's localDefs (finding 1.2). - `FinalizeStats` now documents its counting granularity: all edge counters are per-`ParsedImport`, not per-materialized-`ImportEdge`. A wildcard expanding to N exports counts as one linked edge; dynamic-unresolved pass-throughs count as linked. The bindings map is the authoritative "has a BindingRef" source (finding 3.2). Tests (2 added, 22 total in finalize-algorithm.test.ts, 231/231 combined): - Explicit cap-hit → `linkStatus: 'unresolved'` assertion for a cycle where the name-level lookup never succeeds (distinct from `targetFile: null`; cap exhaustion path) (finding 3.1). - Multi-hop re-export contract test: demonstrates both variants — intermediate B WITHOUT X in localDefs → unresolved; B WITH X in localDefs → resolved to the original source DefId (finding 1.2). Not addressed (filed as follow-up issues): - LanguageProvider.resolveImportTarget vs FinalizeHooks signature divergence (finding 1.3) — pre-Ring-3 concern. - findDefById O(F×D) scan in Phase 5 (finding 4.1) — acceptable for Ring 2; optimize before large-workspace Ring 3 migrations.
This commit is contained in:
parent
8cf9ae0e0d
commit
a9a5e1c388
4 changed files with 1132 additions and 0 deletions
|
|
@ -81,6 +81,17 @@ export type {
|
|||
MethodDispatchInput,
|
||||
} from './scope-resolution/method-dispatch-index.js';
|
||||
|
||||
// SCC-aware cross-file finalize (RFC §3.2 Phase 2; Ring 2 SHARED #915)
|
||||
export { finalize } from './scope-resolution/finalize-algorithm.js';
|
||||
export type {
|
||||
FinalizeInput,
|
||||
FinalizeFile,
|
||||
FinalizeHooks,
|
||||
FinalizeOutput,
|
||||
FinalizedScc,
|
||||
FinalizeStats,
|
||||
} from './scope-resolution/finalize-algorithm.js';
|
||||
|
||||
// Scope tree spine + position lookup (RFC §2.2 + §3.1; Ring 2 SHARED #912)
|
||||
export { makeScopeId, clearScopeIdInternPool } from './scope-resolution/scope-id.js';
|
||||
export type { ScopeIdInput } from './scope-resolution/scope-id.js';
|
||||
|
|
|
|||
663
gitnexus-shared/src/scope-resolution/finalize-algorithm.ts
Normal file
663
gitnexus-shared/src/scope-resolution/finalize-algorithm.ts
Normal file
|
|
@ -0,0 +1,663 @@
|
|||
/**
|
||||
* `finalize` — cross-file finalize algorithm for the SemanticModel
|
||||
* (RFC §3.2 Phase 2; Ring 2 SHARED #915).
|
||||
*
|
||||
* Pure logic that takes per-file parse output (`ParsedImport[]` +
|
||||
* `SymbolDefinition[]`) and returns:
|
||||
*
|
||||
* - Linked `ImportEdge[]` per module scope, with `targetModuleScope` and
|
||||
* `targetDefId` filled where resolvable; edges that could not be
|
||||
* resolved within the hard fixpoint cap are marked
|
||||
* `linkStatus: 'unresolved'`.
|
||||
* - Materialized `bindings` per module scope — local defs merged with
|
||||
* imported / wildcard-expanded / re-exported names via the provider's
|
||||
* `mergeBindings` precedence.
|
||||
* - The SCC condensation of the import graph, exposed so disjoint SCCs
|
||||
* can be processed in parallel by callers that want that.
|
||||
*
|
||||
* The algorithm is **SCC-aware**: it runs Tarjan SCC over the file-level
|
||||
* import graph, processes SCCs in reverse-topological order (leaves
|
||||
* first), and within each SCC runs a bounded fixpoint link pass capped at
|
||||
* `N = |edges in SCC|`. Cyclic imports finalize without hanging; malformed
|
||||
* inputs are bounded by the cap.
|
||||
*
|
||||
* **No language-specific logic.** Target resolution, wildcard expansion,
|
||||
* and binding precedence all go through caller-supplied hooks
|
||||
* (`resolveImportTarget`, `expandsWildcardTo`, `mergeBindings`) that
|
||||
* match the LanguageProvider surface from #911.
|
||||
*
|
||||
* **Dynamic imports rule.** `kind === 'dynamic-unresolved'` passes through
|
||||
* as an `ImportEdge { kind: 'dynamic-unresolved', targetFile: null }`
|
||||
* with no `BindingRef`. They are parse-time signals, not linkable targets.
|
||||
*/
|
||||
|
||||
import type { SymbolDefinition } from './symbol-definition.js';
|
||||
import type { BindingRef, ImportEdge, ParsedImport, ScopeId, WorkspaceIndex } from './types.js';
|
||||
|
||||
// ─── Public contracts ───────────────────────────────────────────────────────
|
||||
|
||||
/** Per-file input for the finalize pass. */
|
||||
export interface FinalizeFile {
|
||||
readonly filePath: string;
|
||||
/** The module scope id for this file; owns the finalized imports + bindings. */
|
||||
readonly moduleScope: ScopeId;
|
||||
readonly parsedImports: readonly ParsedImport[];
|
||||
/**
|
||||
* Defs exported from this file — the "what other files can import by name"
|
||||
* surface. Typically those with `isExported: true` (the module's own
|
||||
* declarations) plus, for multi-hop re-export chains, the re-exported
|
||||
* names the parser chose to surface here.
|
||||
*
|
||||
* **Multi-hop re-export contract.** `finalize` resolves an edge
|
||||
* `A → B (importedName: 'X')` by looking up `X` in `B.localDefs`. If B
|
||||
* only has `export { X } from './C'` and the parser *does not* include
|
||||
* `X` in `B.localDefs`, A's edge hits the fixpoint cap and is marked
|
||||
* `linkStatus: 'unresolved'`. The fixpoint does NOT mutate `localDefs`
|
||||
* across iterations — it is static input.
|
||||
*
|
||||
* Parsers that want multi-hop re-export chains to settle end-to-end must
|
||||
* include re-exported names in the intermediate file's `localDefs` (with
|
||||
* the original `DefId` of the source symbol). This keeps the algorithm
|
||||
* O(1) per lookup and avoids graph-crawl during finalize.
|
||||
*/
|
||||
readonly localDefs: readonly SymbolDefinition[];
|
||||
}
|
||||
|
||||
/** Input to `finalize`. */
|
||||
export interface FinalizeInput {
|
||||
readonly files: readonly FinalizeFile[];
|
||||
/** Opaque workspace context forwarded to provider hooks. */
|
||||
readonly workspaceIndex: WorkspaceIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-supplied hooks. Mirror the optional LanguageProvider scope-
|
||||
* resolution hooks declared in #911; `finalize` calls them pure-ly and
|
||||
* expects pure answers.
|
||||
*/
|
||||
export interface FinalizeHooks {
|
||||
/**
|
||||
* Resolve a raw import target to the concrete file path that owns it.
|
||||
* Return `null` when no target file is resolvable (e.g., `np.foo` when
|
||||
* `numpy` is external to the workspace).
|
||||
*/
|
||||
resolveImportTarget(
|
||||
targetRaw: string,
|
||||
fromFile: string,
|
||||
workspaceIndex: WorkspaceIndex,
|
||||
): string | null;
|
||||
|
||||
/**
|
||||
* For a wildcard `import * from M`, return the names visible in the
|
||||
* exporting module scope `M`. The finalize pass looks each name up in
|
||||
* `M`'s local defs to produce a concrete `BindingRef`; names with no
|
||||
* matching export are dropped.
|
||||
*/
|
||||
expandsWildcardTo(targetModuleScope: ScopeId, workspaceIndex: WorkspaceIndex): readonly string[];
|
||||
|
||||
/**
|
||||
* Merge `incoming` bindings into `existing` for a given name. Called
|
||||
* once per name at each scope. Typical rules:
|
||||
* - Python: local > imported > wildcard (last-write-wins within tier).
|
||||
* - Rust: explicit `use` > glob; `pub use` overrides.
|
||||
* Return value replaces the bucket entirely — no implicit append.
|
||||
*/
|
||||
mergeBindings(
|
||||
existing: readonly BindingRef[],
|
||||
incoming: readonly BindingRef[],
|
||||
scope: ScopeId,
|
||||
): readonly BindingRef[];
|
||||
}
|
||||
|
||||
/** One SCC in the file-level import graph. */
|
||||
export interface FinalizedScc {
|
||||
readonly files: readonly string[];
|
||||
/** True iff this SCC has ≥ 2 files OR a single file that self-imports. */
|
||||
readonly isCycle: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counters reported by `finalize`.
|
||||
*
|
||||
* **Counting granularity** — all edge counters are **per-`ParsedImport`**,
|
||||
* not per-materialized-`ImportEdge`. A single `wildcard` ParsedImport that
|
||||
* expands to N exports counts as one linked edge in these stats; the
|
||||
* materialized output (`FinalizeOutput.imports`) will have N edges for
|
||||
* that input. `dynamic-unresolved` ParsedImports count as linked (they
|
||||
* pass through with no `linkStatus`), so `linkedEdges` ≠ "has a
|
||||
* BindingRef" — use the `bindings` map for that.
|
||||
*
|
||||
* In other words: `totalEdges === input.parsedImports.length` summed
|
||||
* across files, and `linkedEdges + unresolvedEdges === totalEdges`.
|
||||
*/
|
||||
export interface FinalizeStats {
|
||||
readonly totalFiles: number;
|
||||
/** Total `ParsedImport` records seen across all files. */
|
||||
readonly totalEdges: number;
|
||||
/**
|
||||
* `ParsedImport`s whose finalized edge does NOT carry
|
||||
* `linkStatus: 'unresolved'`. Includes `dynamic-unresolved` pass-throughs.
|
||||
*/
|
||||
readonly linkedEdges: number;
|
||||
/** `ParsedImport`s whose finalized edge carries `linkStatus: 'unresolved'`. */
|
||||
readonly unresolvedEdges: number;
|
||||
readonly sccCount: number;
|
||||
readonly largestSccSize: number;
|
||||
}
|
||||
|
||||
export interface FinalizeOutput {
|
||||
/** Linked `ImportEdge[]` per module scope, in original input order. */
|
||||
readonly imports: ReadonlyMap<ScopeId, readonly ImportEdge[]>;
|
||||
/** Materialized bindings per module scope. */
|
||||
readonly bindings: ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>;
|
||||
/** SCCs in reverse-topological order (leaves first). */
|
||||
readonly sccs: readonly FinalizedScc[];
|
||||
readonly stats: FinalizeStats;
|
||||
}
|
||||
|
||||
// ─── Entry point ───────────────────────────────────────────────────────────
|
||||
|
||||
export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOutput {
|
||||
const byFilePath = new Map<string, FinalizeFile>();
|
||||
for (const f of input.files) byFilePath.set(f.filePath, f);
|
||||
|
||||
// ── Phase 0: pre-resolve raw import targets (one syscall-equivalent per
|
||||
// (file, parsedImport)). Edges with no resolvable target become
|
||||
// `linkStatus: 'unresolved'` or, for dynamic-unresolved, pass through
|
||||
// with `targetFile: null`.
|
||||
const edgeIndex = new Map<string, ImportEdgeDraft[]>(); // filePath → drafts
|
||||
let totalEdges = 0;
|
||||
|
||||
for (const file of input.files) {
|
||||
const drafts: ImportEdgeDraft[] = [];
|
||||
for (const parsed of file.parsedImports) {
|
||||
const draft = makeEdgeDraft(parsed, file, hooks, input.workspaceIndex);
|
||||
drafts.push(draft);
|
||||
totalEdges++;
|
||||
}
|
||||
edgeIndex.set(file.filePath, drafts);
|
||||
}
|
||||
|
||||
// ── Phase 1: build file-level import graph (only resolvable edges form
|
||||
// graph edges; unresolvable ones are terminal and contribute no
|
||||
// fixpoint obligation).
|
||||
const graph = new Map<string, Set<string>>();
|
||||
for (const file of input.files) {
|
||||
graph.set(file.filePath, new Set());
|
||||
}
|
||||
for (const [fromFile, drafts] of edgeIndex) {
|
||||
const edges = graph.get(fromFile)!;
|
||||
for (const d of drafts) {
|
||||
if (d.targetFile !== null && byFilePath.has(d.targetFile)) {
|
||||
edges.add(d.targetFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2: Tarjan SCC → reverse-topological list of SCCs.
|
||||
const sccs = tarjanSccs(graph);
|
||||
|
||||
// ── Phase 3: process SCCs in reverse-topological order (leaves first).
|
||||
// Within each SCC, run a bounded fixpoint that resolves intra-SCC edges.
|
||||
// Edges leaving the SCC are already resolved (their target SCC is
|
||||
// already finalized); edges inside the SCC may need multiple passes.
|
||||
const linkedByScope = new Map<ScopeId, readonly ImportEdge[]>();
|
||||
let linkedEdges = 0;
|
||||
|
||||
for (const scc of sccs) {
|
||||
const sccFiles = new Set(scc.files);
|
||||
const capacity = countEdgesWithin(edgeIndex, sccFiles);
|
||||
|
||||
// Run the fixpoint up to `capacity` iterations. Each iteration tries to
|
||||
// resolve every still-unlinked edge in the SCC; stops early if a pass
|
||||
// makes no progress.
|
||||
let progressed = true;
|
||||
let iterations = 0;
|
||||
while (progressed && iterations < capacity) {
|
||||
progressed = false;
|
||||
iterations++;
|
||||
for (const filePath of scc.files) {
|
||||
const drafts = edgeIndex.get(filePath)!;
|
||||
for (const draft of drafts) {
|
||||
if (draft.finalized !== null) continue;
|
||||
const finalized = tryFinalize(draft, byFilePath);
|
||||
if (finalized !== null) {
|
||||
draft.finalized = finalized;
|
||||
progressed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Any drafts still not finalized within this SCC hit the cap → unresolved.
|
||||
for (const filePath of scc.files) {
|
||||
const drafts = edgeIndex.get(filePath)!;
|
||||
for (const draft of drafts) {
|
||||
if (draft.finalized !== null) continue;
|
||||
draft.finalized = {
|
||||
...draft.base,
|
||||
linkStatus: 'unresolved' as const,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 4: collect finalized `ImportEdge[]` per module scope, preserving
|
||||
// input order within each file, and wildcard-expand where applicable.
|
||||
for (const file of input.files) {
|
||||
const drafts = edgeIndex.get(file.filePath)!;
|
||||
const finalized: ImportEdge[] = [];
|
||||
for (const d of drafts) {
|
||||
const edge = d.finalized!;
|
||||
if (d.source.kind === 'wildcard' && edge.linkStatus !== 'unresolved') {
|
||||
// Produce one `wildcard-expanded` ImportEdge per exported name.
|
||||
const expanded = expandWildcard(edge, byFilePath, hooks, input.workspaceIndex);
|
||||
for (const e of expanded) finalized.push(e);
|
||||
} else {
|
||||
finalized.push(edge);
|
||||
}
|
||||
if (edge.linkStatus !== 'unresolved') linkedEdges++;
|
||||
}
|
||||
linkedByScope.set(file.moduleScope, Object.freeze(finalized));
|
||||
}
|
||||
|
||||
// ── Phase 5: materialize module-scope bindings (local + imports + wildcards),
|
||||
// delegating precedence to `provider.mergeBindings`.
|
||||
const bindingsByScope = materializeBindings(input.files, linkedByScope, hooks);
|
||||
|
||||
// ── Stats.
|
||||
const sccCount = sccs.length;
|
||||
let largestSccSize = 0;
|
||||
for (const scc of sccs) {
|
||||
if (scc.files.length > largestSccSize) largestSccSize = scc.files.length;
|
||||
}
|
||||
const stats: FinalizeStats = {
|
||||
totalFiles: input.files.length,
|
||||
totalEdges,
|
||||
linkedEdges,
|
||||
unresolvedEdges: totalEdges - linkedEdges,
|
||||
sccCount,
|
||||
largestSccSize,
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
imports: linkedByScope,
|
||||
bindings: bindingsByScope,
|
||||
sccs,
|
||||
stats,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Internal: edge drafting (phase 0) ──────────────────────────────────────
|
||||
|
||||
interface ImportEdgeDraft {
|
||||
readonly source: ParsedImport;
|
||||
readonly fromFile: string;
|
||||
readonly fromScope: ScopeId;
|
||||
readonly targetFile: string | null;
|
||||
readonly base: ImportEdge;
|
||||
finalized: ImportEdge | null;
|
||||
}
|
||||
|
||||
function makeEdgeDraft(
|
||||
parsed: ParsedImport,
|
||||
file: FinalizeFile,
|
||||
hooks: FinalizeHooks,
|
||||
workspace: WorkspaceIndex,
|
||||
): ImportEdgeDraft {
|
||||
// Dynamic-unresolved passes through — no `BindingRef`, no target file.
|
||||
if (parsed.kind === 'dynamic-unresolved') {
|
||||
const base: ImportEdge = {
|
||||
localName: parsed.localName,
|
||||
targetFile: null,
|
||||
targetExportedName: '',
|
||||
kind: 'dynamic-unresolved',
|
||||
};
|
||||
return {
|
||||
source: parsed,
|
||||
fromFile: file.filePath,
|
||||
fromScope: file.moduleScope,
|
||||
targetFile: null,
|
||||
base,
|
||||
finalized: base, // already fully finalized
|
||||
};
|
||||
}
|
||||
|
||||
const targetFile = hooks.resolveImportTarget(parsed.targetRaw ?? '', file.filePath, workspace);
|
||||
|
||||
// Edge is unresolvable at the file level — mark unresolved now.
|
||||
if (targetFile === null) {
|
||||
const edgeKind = parsed.kind === 'wildcard' ? 'wildcard-expanded' : parsed.kind;
|
||||
const localName = parsed.kind === 'wildcard' ? '' : parsed.localName;
|
||||
const targetExportedName = extractExportedName(parsed);
|
||||
const base: ImportEdge = {
|
||||
localName,
|
||||
targetFile: null,
|
||||
targetExportedName,
|
||||
kind: edgeKind,
|
||||
linkStatus: 'unresolved',
|
||||
};
|
||||
return {
|
||||
source: parsed,
|
||||
fromFile: file.filePath,
|
||||
fromScope: file.moduleScope,
|
||||
targetFile: null,
|
||||
base,
|
||||
finalized: base,
|
||||
};
|
||||
}
|
||||
|
||||
// Resolvable at the file level; intra-SCC fixpoint may still fail to fill
|
||||
// in `targetDefId` (e.g., symbol not exported from target).
|
||||
const edgeKind = parsed.kind === 'wildcard' ? 'wildcard-expanded' : parsed.kind;
|
||||
const localName = parsed.kind === 'wildcard' ? '' : parsed.localName;
|
||||
const targetExportedName = extractExportedName(parsed);
|
||||
const base: ImportEdge = {
|
||||
localName,
|
||||
targetFile,
|
||||
targetExportedName,
|
||||
kind: edgeKind,
|
||||
};
|
||||
return {
|
||||
source: parsed,
|
||||
fromFile: file.filePath,
|
||||
fromScope: file.moduleScope,
|
||||
targetFile,
|
||||
base,
|
||||
finalized: null,
|
||||
};
|
||||
}
|
||||
|
||||
function extractExportedName(parsed: ParsedImport): string {
|
||||
switch (parsed.kind) {
|
||||
case 'named':
|
||||
case 'alias':
|
||||
case 'namespace':
|
||||
case 'reexport':
|
||||
return parsed.importedName;
|
||||
case 'wildcard':
|
||||
case 'dynamic-unresolved':
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Internal: per-edge finalization (phase 3) ─────────────────────────────
|
||||
|
||||
function tryFinalize(
|
||||
draft: ImportEdgeDraft,
|
||||
byFilePath: Map<string, FinalizeFile>,
|
||||
): ImportEdge | null {
|
||||
const targetFile = draft.targetFile;
|
||||
if (targetFile === null) return draft.base; // already terminal
|
||||
|
||||
const targetModule = byFilePath.get(targetFile);
|
||||
if (targetModule === undefined) return draft.base; // external target — leave as-is
|
||||
|
||||
// Wildcards finalize at the file level; their per-name expansion happens
|
||||
// in phase 4. At this stage we just record the target module scope.
|
||||
if (draft.source.kind === 'wildcard') {
|
||||
return {
|
||||
...draft.base,
|
||||
targetModuleScope: targetModule.moduleScope,
|
||||
};
|
||||
}
|
||||
|
||||
// Namespace imports alias the target *module*; they don't name a
|
||||
// specific export. Link the module scope unconditionally. If the target
|
||||
// also exposes a def whose simple name matches `importedName` (some
|
||||
// languages emit a synthetic module-def), pick it up as the `targetDefId`
|
||||
// so consumers can reach the module as a symbol — but its absence is not
|
||||
// a failure.
|
||||
if (draft.source.kind === 'namespace') {
|
||||
const moduleDef = findExportByName(targetModule.localDefs, extractExportedName(draft.source));
|
||||
return {
|
||||
...draft.base,
|
||||
targetModuleScope: targetModule.moduleScope,
|
||||
...(moduleDef !== undefined ? { targetDefId: moduleDef.nodeId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// named / alias / reexport: look up the imported name in the target's
|
||||
// local defs. Multi-hop re-export chains settle iteratively — each hop
|
||||
// resolves once its prior hop is finalized.
|
||||
const importedName = extractExportedName(draft.source);
|
||||
const exported = findExportByName(targetModule.localDefs, importedName);
|
||||
|
||||
if (exported === undefined) {
|
||||
// Target resolvable but the name isn't exported — keep trying in case a
|
||||
// re-export inside the target's SCC surfaces it in a later iteration.
|
||||
return null;
|
||||
}
|
||||
|
||||
const transitiveVia = draft.source.kind === 'reexport' ? Object.freeze([targetFile]) : undefined;
|
||||
|
||||
return {
|
||||
...draft.base,
|
||||
targetModuleScope: targetModule.moduleScope,
|
||||
targetDefId: exported.nodeId,
|
||||
...(transitiveVia !== undefined ? { transitiveVia } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The "simple" (unqualified) name of a def, for import-name matching.
|
||||
*
|
||||
* Canonical source: `def.qualifiedName` — the tail after the last `.` (or
|
||||
* the whole string if no dot). Defs without a qualifiedName can't be
|
||||
* resolved by name here and return `null`; callers treat that as "name
|
||||
* not exported" and either retry in a later fixpoint iteration or mark
|
||||
* the edge unresolved.
|
||||
*/
|
||||
function deriveSimpleName(def: SymbolDefinition): string | null {
|
||||
const q = def.qualifiedName;
|
||||
if (q === undefined || q.length === 0) return null;
|
||||
const dot = q.lastIndexOf('.');
|
||||
return dot === -1 ? q : q.slice(dot + 1);
|
||||
}
|
||||
|
||||
function findExportByName(
|
||||
defs: readonly SymbolDefinition[],
|
||||
name: string,
|
||||
): SymbolDefinition | undefined {
|
||||
for (const d of defs) {
|
||||
if (deriveSimpleName(d) === name) return d;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function countEdgesWithin(edgeIndex: Map<string, ImportEdgeDraft[]>, files: Set<string>): number {
|
||||
let n = 0;
|
||||
for (const filePath of files) {
|
||||
const drafts = edgeIndex.get(filePath);
|
||||
if (drafts === undefined) continue;
|
||||
for (const d of drafts) {
|
||||
if (d.targetFile !== null && files.has(d.targetFile)) n++;
|
||||
}
|
||||
}
|
||||
// Guarantee at least one pass even for a trivial SCC (ensures deterministic
|
||||
// fixpoint termination even when a single-file SCC has zero intra-SCC edges
|
||||
// but still needs one settle pass).
|
||||
return Math.max(n, 1);
|
||||
}
|
||||
|
||||
// ─── Internal: wildcard expansion (phase 4) ────────────────────────────────
|
||||
|
||||
function expandWildcard(
|
||||
edge: ImportEdge,
|
||||
byFilePath: Map<string, FinalizeFile>,
|
||||
hooks: FinalizeHooks,
|
||||
workspace: WorkspaceIndex,
|
||||
): readonly ImportEdge[] {
|
||||
if (edge.targetModuleScope === undefined || edge.targetFile === null) {
|
||||
return [edge]; // unresolvable wildcard survives as a single unlinked edge
|
||||
}
|
||||
const target = byFilePath.get(edge.targetFile);
|
||||
if (target === undefined) return [edge];
|
||||
|
||||
const names = hooks.expandsWildcardTo(edge.targetModuleScope, workspace);
|
||||
if (names.length === 0) return [];
|
||||
|
||||
const expanded: ImportEdge[] = [];
|
||||
for (const name of names) {
|
||||
const def = findExportByName(target.localDefs, name);
|
||||
if (def === undefined) continue;
|
||||
expanded.push({
|
||||
localName: name,
|
||||
targetFile: edge.targetFile,
|
||||
targetExportedName: name,
|
||||
kind: 'wildcard-expanded',
|
||||
targetModuleScope: edge.targetModuleScope,
|
||||
targetDefId: def.nodeId,
|
||||
});
|
||||
}
|
||||
return expanded;
|
||||
}
|
||||
|
||||
// ─── Internal: bindings materialization (phase 5) ───────────────────────────
|
||||
|
||||
function materializeBindings(
|
||||
files: readonly FinalizeFile[],
|
||||
linkedByScope: ReadonlyMap<ScopeId, readonly ImportEdge[]>,
|
||||
hooks: FinalizeHooks,
|
||||
): ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>> {
|
||||
const out = new Map<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>();
|
||||
|
||||
for (const file of files) {
|
||||
const scopeBindings = new Map<string, readonly BindingRef[]>();
|
||||
|
||||
// Start with local defs as `origin: 'local'` bindings.
|
||||
for (const def of file.localDefs) {
|
||||
const name = deriveSimpleName(def);
|
||||
if (name === null) continue;
|
||||
const incoming: BindingRef[] = [{ def, origin: 'local' }];
|
||||
const existing = scopeBindings.get(name) ?? [];
|
||||
scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope));
|
||||
}
|
||||
|
||||
// Layer in finalized imports.
|
||||
const imports = linkedByScope.get(file.moduleScope) ?? [];
|
||||
for (const edge of imports) {
|
||||
if (edge.targetDefId === undefined || edge.linkStatus === 'unresolved') continue;
|
||||
// Every def the importing file needs to reach is in some other file's
|
||||
// `localDefs`; walk all files to find it. In practice we could index
|
||||
// this, but at finalize-time N(files) is small per workspace pass.
|
||||
const def = findDefById(files, edge.targetDefId);
|
||||
if (def === undefined) continue;
|
||||
|
||||
const origin: BindingRef['origin'] =
|
||||
edge.kind === 'namespace'
|
||||
? 'namespace'
|
||||
: edge.kind === 'wildcard-expanded'
|
||||
? 'wildcard'
|
||||
: edge.kind === 'reexport'
|
||||
? 'reexport'
|
||||
: 'import';
|
||||
const fallback = deriveSimpleName(def);
|
||||
const name = edge.localName.length > 0 ? edge.localName : fallback;
|
||||
if (name === null) continue;
|
||||
const incoming: BindingRef[] = [{ def, origin, via: edge }];
|
||||
const existing = scopeBindings.get(name) ?? [];
|
||||
scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope));
|
||||
}
|
||||
|
||||
// Freeze nested buckets for immutability.
|
||||
const frozen = new Map<string, readonly BindingRef[]>();
|
||||
for (const [name, refs] of scopeBindings) {
|
||||
frozen.set(name, Object.freeze(refs.slice()));
|
||||
}
|
||||
out.set(file.moduleScope, frozen);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function findDefById(files: readonly FinalizeFile[], defId: string): SymbolDefinition | undefined {
|
||||
for (const f of files) {
|
||||
for (const d of f.localDefs) {
|
||||
if (d.nodeId === defId) return d;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ─── Internal: Tarjan SCC ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Iterative Tarjan SCC. Returns SCCs in **reverse-topological** order
|
||||
* (leaves first — a property Tarjan gives for free, and the order
|
||||
* `finalize` wants so leaves are fully resolved before their dependents).
|
||||
*/
|
||||
function tarjanSccs(graph: ReadonlyMap<string, ReadonlySet<string>>): FinalizedScc[] {
|
||||
const index = new Map<string, number>();
|
||||
const lowlink = new Map<string, number>();
|
||||
const onStack = new Set<string>();
|
||||
const stack: string[] = [];
|
||||
const sccs: FinalizedScc[] = [];
|
||||
let idx = 0;
|
||||
|
||||
// Iterative DFS to avoid stack overflow on deep import chains.
|
||||
const allNodes = Array.from(graph.keys()).sort(); // deterministic order
|
||||
const iterStack: Array<{ node: string; children: Iterator<string>; entered: boolean }> = [];
|
||||
|
||||
for (const root of allNodes) {
|
||||
if (index.has(root)) continue;
|
||||
iterStack.push({
|
||||
node: root,
|
||||
children: (graph.get(root) ?? new Set<string>()).values(),
|
||||
entered: false,
|
||||
});
|
||||
while (iterStack.length > 0) {
|
||||
const frame = iterStack[iterStack.length - 1]!;
|
||||
|
||||
if (!frame.entered) {
|
||||
frame.entered = true;
|
||||
index.set(frame.node, idx);
|
||||
lowlink.set(frame.node, idx);
|
||||
idx++;
|
||||
stack.push(frame.node);
|
||||
onStack.add(frame.node);
|
||||
}
|
||||
|
||||
const nextChild = frame.children.next();
|
||||
if (nextChild.done) {
|
||||
// Post-visit: compute SCC membership if frame.node is a root.
|
||||
if (lowlink.get(frame.node) === index.get(frame.node)) {
|
||||
const scc: string[] = [];
|
||||
let selfInCycle = false;
|
||||
while (true) {
|
||||
const w = stack.pop()!;
|
||||
onStack.delete(w);
|
||||
scc.push(w);
|
||||
// A single-file self-loop counts as a cycle.
|
||||
if (w === frame.node) {
|
||||
selfInCycle = (graph.get(w) ?? new Set()).has(w);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const isCycle = scc.length > 1 || selfInCycle;
|
||||
sccs.push({ files: Object.freeze(scc), isCycle });
|
||||
}
|
||||
iterStack.pop();
|
||||
// Propagate lowlink to parent.
|
||||
if (iterStack.length > 0) {
|
||||
const parent = iterStack[iterStack.length - 1]!;
|
||||
lowlink.set(parent.node, Math.min(lowlink.get(parent.node)!, lowlink.get(frame.node)!));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const child = nextChild.value;
|
||||
if (!index.has(child)) {
|
||||
iterStack.push({
|
||||
node: child,
|
||||
children: (graph.get(child) ?? new Set<string>()).values(),
|
||||
entered: false,
|
||||
});
|
||||
} else if (onStack.has(child)) {
|
||||
lowlink.set(frame.node, Math.min(lowlink.get(frame.node)!, index.get(child)!));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sccs;
|
||||
}
|
||||
|
|
@ -152,6 +152,21 @@ export type ParsedImport =
|
|||
/** Set when the re-export renames the symbol (e.g. `export { X as Y } from './y'`). */
|
||||
readonly alias?: string;
|
||||
}
|
||||
/**
|
||||
* Wildcard import — brings every exported name from the target module into
|
||||
* the importing scope. The finalize algorithm expands this into one
|
||||
* `BindingRef` per exported name via the provider's `expandsWildcardTo`
|
||||
* hook, producing the finalize-only `ImportEdge` kind `'wildcard-expanded'`.
|
||||
*
|
||||
* Examples:
|
||||
* - Python `from foo import *` → `{ kind: 'wildcard', targetRaw: 'foo' }`
|
||||
* - JS `export * from './foo'` → `{ kind: 'wildcard', targetRaw: './foo' }`
|
||||
* - Rust `pub use foo::*` → `{ kind: 'wildcard', targetRaw: 'foo' }`
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'wildcard';
|
||||
readonly targetRaw: string;
|
||||
}
|
||||
/**
|
||||
* Runtime-computed target — the import path is not a static literal at
|
||||
* parse time. Providers SHOULD emit the unresolvable expression's source
|
||||
|
|
|
|||
443
gitnexus/test/unit/scope-resolution/finalize-algorithm.test.ts
Normal file
443
gitnexus/test/unit/scope-resolution/finalize-algorithm.test.ts
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
/**
|
||||
* Unit tests for `finalize` (RFC #909 Ring 2 SHARED #915).
|
||||
*
|
||||
* Covers: acyclic chain · single-SCC cycle · multi-SCC · wildcard
|
||||
* expansion · re-export flattening · dynamic-unresolved passthrough ·
|
||||
* 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 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();
|
||||
});
|
||||
});
|
||||
|
||||
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 only resolve when intermediate files include the name in localDefs', () => {
|
||||
// Contract (see FinalizeFile.localDefs doc): `finalize` looks up
|
||||
// `importedName` in `B.localDefs`. If B re-exports X from C but does
|
||||
// NOT include X in its own localDefs, A's import of X from B cannot
|
||||
// resolve — the fixpoint doesn't mutate localDefs across iterations.
|
||||
//
|
||||
// This test documents the current behavior: parsers that want
|
||||
// multi-hop chains to settle end-to-end must surface re-exported
|
||||
// names in the intermediate file's localDefs (with the original
|
||||
// source DefId).
|
||||
const c = file('c', [def('def:c.X', 'Class', 'c.X')]);
|
||||
// Variant 1: B does NOT include X in its own localDefs → A's import
|
||||
// fails.
|
||||
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),
|
||||
);
|
||||
expect(firstImport(thinOut, aThin.moduleScope)!.linkStatus).toBe('unresolved');
|
||||
|
||||
// Variant 2: B includes X in its localDefs (re-exports surfaced) → A resolves.
|
||||
const bThick = file(
|
||||
'b',
|
||||
[def('def:c.X', 'Class', 'b.X')], // B surfaces X with its own qname
|
||||
[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),
|
||||
);
|
||||
expect(firstImport(thickOut, aThick.moduleScope)!.linkStatus).toBeUndefined();
|
||||
expect(firstImport(thickOut, aThick.moduleScope)!.targetDefId).toBe('def:c.X');
|
||||
});
|
||||
});
|
||||
|
||||
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('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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue