diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index f73c18f73..723c4970b 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -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'; diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts new file mode 100644 index 000000000..6f6de8ba6 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -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; + /** Materialized bindings per module scope. */ + readonly bindings: ReadonlyMap>; + /** 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(); + 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(); // 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>(); + 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(); + 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, +): 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, files: Set): 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, + 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, + hooks: FinalizeHooks, +): ReadonlyMap> { + const out = new Map>(); + + for (const file of files) { + const scopeBindings = new Map(); + + // 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(); + 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>): FinalizedScc[] { + const index = new Map(); + const lowlink = new Map(); + const onStack = new Set(); + 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; entered: boolean }> = []; + + for (const root of allNodes) { + if (index.has(root)) continue; + iterStack.push({ + node: root, + children: (graph.get(root) ?? new Set()).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()).values(), + entered: false, + }); + } else if (onStack.has(child)) { + lowlink.set(frame.node, Math.min(lowlink.get(frame.node)!, index.get(child)!)); + } + } + } + + return sccs; +} diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index d04373a94..9a08a3a38 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -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 diff --git a/gitnexus/test/unit/scope-resolution/finalize-algorithm.test.ts b/gitnexus/test/unit/scope-resolution/finalize-algorithm.test.ts new file mode 100644 index 000000000..501d0cba9 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/finalize-algorithm.test.ts @@ -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, scope: ScopeId) => { + const imports = out.imports.get(scope); + return imports?.[0]; +}; + +const bindingsFor = ( + out: ReturnType, + 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'); + }); + }); +});