From 25520e90a511b3e436a02b648edff3214c3dfa23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 18 Apr 2026 20:51:19 +0100 Subject: [PATCH] feat(ingestion): finalize-orchestrator materializes ScopeResolutionIndexes (#921, RFC #909 Ring 2 PKG) (#970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ties the Ring 2 pipeline together. Takes the `ParsedFile[]` produced by #920's parse-worker integration, feeds them to shared `finalize()` (#915), and bundles every workspace-wide index for attachment onto `MutableSemanticModel`. Thin integration glue per issue #884's boundary — all algorithm lives in `gitnexus-shared`. ## Shipped ### `model/scope-resolution-indexes.ts` (new) ```ts interface ScopeResolutionIndexes { readonly scopeTree: ScopeTree; readonly defs: DefIndex; readonly qualifiedNames: QualifiedNameIndex; readonly moduleScopes: ModuleScopeIndex; readonly methodDispatch: MethodDispatchIndex; readonly imports: ReadonlyMap; readonly bindings: ReadonlyMap>; readonly referenceSites: readonly ReferenceSite[]; readonly sccs: readonly FinalizedScc[]; readonly stats: FinalizeStats; } ``` The bundle produced by the orchestrator, consumed by the resolution phase. `ReferenceIndex` is deliberately NOT here — it's populated in the next phase (#925). ### `model/semantic-model.ts` — extended - `SemanticModel.scopes?: ScopeResolutionIndexes` — undefined until attached; once attached, frozen. - `MutableSemanticModel.attachScopeIndexes(indexes)` — one-shot write. Throws on second call; `Object.freeze`s the bundle on write. `clear()` resets the slot back to `undefined` so re-ingestion can re-attach. ### `finalize-orchestrator.ts` (new) ```ts finalizeScopeModel(parsedFiles, options?): ScopeResolutionIndexes ``` Orchestration steps: 1. Map `ParsedFile[]` → `FinalizeInput` (`FinalizeFile` is a structural subset, so no shape-shifting). 2. Call shared `finalize()` with provider hooks (defaults provided for the zero-provider case today). 3. Build the four workspace indexes (`DefIndex`, `QualifiedNameIndex`, `ModuleScopeIndex`, `ScopeTree`) from per-file unions. 4. Build an empty `MethodDispatchIndex` as a placeholder (owners=[], both callbacks return []). Real MRO wiring lands with the per-language adapters in #922. 5. Bundle + return. **Empty-input safety.** Zero parsedFiles → valid but empty bundle with all zero-sized indexes and `stats.totalFiles === 0`. Downstream code can consult `model.scopes` without branching on presence — only on `stats`. **Hook defaults** (`withDefaultHooks`) for missing provider hooks: - `resolveImportTarget: () => null` — every import goes `unresolved` - `expandsWildcardTo: () => []` — wildcards don't materialize - `mergeBindings: (a, b) => [...a, ...b]` — append without precedence Providers override these in #922 (per-language import adapters). ## Tests (10, all passing) - **Empty input** (1): zero parsedFiles → valid empty bundle - **Single file** (2): all per-file indexes populated · referenceSites aggregated - **Cross-file imports** (3): resolveImportTarget threads through + links · default-null resolver → unresolved · stats reflect graph - **MutableSemanticModel integration** (4): undefined initially · attach once · Object.freeze applied · throws on re-attach · clear() resets ## Verification - `tsc --noEmit` clean in both packages - `gitnexus-shared` build clean - 10/10 new tests pass - Full scope-resolution / shadow / model / flag suite: **321/321 pass** ## What's deferred (not this PR, per RFC #909 scope) - **Per-language hook adapters** (#922): `resolveImportTarget` + `expandsWildcardTo` + `mergeBindings` wired per language. - **MethodDispatchIndex wiring via HeritageMap**: populate MRO + implements via the existing CLI-package HeritageMap strategies. Likely companion to #922 or a focused follow-up. - **Pipeline invocation**: actually calling `finalizeScopeModel` from the real ingestion pipeline. The orchestrator is callable today; the ingestion entry point wiring lands with the shadow harness (#923). - **`ReferenceIndex` population**: RFC §3.2 Phase 4 / #925. ## Closes part of #909. Unblocks - #923 shadow harness — now has a fully materialized `model.scopes` to query against the legacy DAG for parity measurement - #925 ReferenceIndex → LadybugDB emission — consumes `model.scopes` - Ring 3 language migrations (#926+) — a language flipping to `REGISTRY_PRIMARY_=true` can now expect `model.scopes` to be populated when the pipeline wires the orchestrator in --- .../core/ingestion/finalize-orchestrator.ts | 196 ++++++++++++++++ .../model/scope-resolution-indexes.ts | 73 ++++++ .../core/ingestion/model/semantic-model.ts | 45 ++++ .../finalize-orchestrator.test.ts | 219 ++++++++++++++++++ 4 files changed, 533 insertions(+) create mode 100644 gitnexus/src/core/ingestion/finalize-orchestrator.ts create mode 100644 gitnexus/src/core/ingestion/model/scope-resolution-indexes.ts create mode 100644 gitnexus/test/unit/scope-resolution/finalize-orchestrator.test.ts diff --git a/gitnexus/src/core/ingestion/finalize-orchestrator.ts b/gitnexus/src/core/ingestion/finalize-orchestrator.ts new file mode 100644 index 000000000..8541e58aa --- /dev/null +++ b/gitnexus/src/core/ingestion/finalize-orchestrator.ts @@ -0,0 +1,196 @@ +/** + * `finalizeScopeModel` — turn a workspace's `ParsedFile[]` into a + * materialized `ScopeResolutionIndexes` (RFC §3.2 Phase 2; Ring 2 PKG #921). + * + * Thin integration glue, per issue #884's boundary: all algorithmic logic + * lives in `gitnexus-shared` (finalize algorithm #915, the four per-file + * indexes #913, the method-dispatch materialization #914, the scope tree + * #912). This file does three things only: + * + * 1. Map `ParsedFile[]` → `FinalizeInput` and call shared `finalize()`. + * 2. Build the four workspace-wide indexes from the union of per-file + * defs/scopes/modules/qualified-names. + * 3. Bundle the results into `ScopeResolutionIndexes` for + * `MutableSemanticModel.attachScopeIndexes(...)`. + * + * ## What this module is NOT responsible for + * + * - Invoking tree-sitter or running AST walks. That's the extractor (#919). + * - Per-language import-target resolution. Hooks are plumbed through + * but default to "unresolved" when no provider supplies them — the + * real adapters land with #922. + * - Populating `ReferenceIndex`. That's the resolution phase (#925). + * - Deciding which language uses registry-primary lookup. That's the + * flag reader (#924). + * + * ## Empty-input behavior + * + * When `parsedFiles` is empty (the common case today — no language has + * migrated yet), the orchestrator produces a valid but empty bundle: all + * indexes are zero-sized, the scope tree is empty, and + * `finalize.stats.totalFiles === 0`. This lets downstream consumers + * safely consult `model.scopes` without branching on presence. + */ + +import type { + BindingRef, + FinalizeFile, + FinalizeHooks, + ParsedFile, + Scope, + ScopeId, + SymbolDefinition, + WorkspaceIndex, +} from 'gitnexus-shared'; +import { + buildDefIndex, + buildMethodDispatchIndex, + buildModuleScopeIndex, + buildQualifiedNameIndex, + buildScopeTree, + finalize, +} from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from './model/scope-resolution-indexes.js'; + +// ─── Public entry point ───────────────────────────────────────────────────── + +/** + * Options forwarded to the orchestrator. All fields optional so callers + * that don't yet have per-language hooks (today) get sensible defaults; + * #922 will populate `hooks.resolveImportTarget` + friends per language. + */ +export interface FinalizeOrchestratorOptions { + /** + * Hooks forwarded to shared `finalize()`. Any omitted field gets a + * no-op default: unresolved targets, empty wildcard expansion, append + * merge for bindings. + */ + readonly hooks?: Partial; + /** + * Opaque workspace context forwarded to hooks. `undefined` today; Ring + * 2 PKG #922 populates this with a real cross-file index for the + * per-language resolvers. + */ + readonly workspaceIndex?: WorkspaceIndex; +} + +/** + * Produce a fully materialized `ScopeResolutionIndexes` from the + * workspace's per-file artifacts. + * + * Pure function (given pure hooks). No I/O, no globals consulted. The + * pipeline calls this once per ingestion run and hands the result to + * `MutableSemanticModel.attachScopeIndexes`. + */ +export function finalizeScopeModel( + parsedFiles: readonly ParsedFile[], + options: FinalizeOrchestratorOptions = {}, +): ScopeResolutionIndexes { + const hooks = withDefaultHooks(options.hooks ?? {}); + const workspaceIndex: WorkspaceIndex = options.workspaceIndex ?? undefined; + + // ── Step 1: Shared finalize — runs SCC-aware cross-file link + binding + // materialization. Returns linked imports + merged bindings per module + // scope + SCC condensation + stats. + const finalizeInput = { + files: parsedFiles.map(toFinalizeFile), + workspaceIndex, + }; + const finalizeOut = finalize(finalizeInput, hooks); + + // ── Step 2: Workspace-wide indexes built from the per-file unions. + // These are pure aggregations — no algorithm beyond what the builders + // in gitnexus-shared already encapsulate (first-write-wins, qname + // collision buckets, etc.). + + const allScopes: Scope[] = []; + const allDefs: SymbolDefinition[] = []; + const moduleEntries: { filePath: string; moduleScopeId: ScopeId }[] = []; + const allReferenceSites = [] as ReturnType; + + for (const file of parsedFiles) { + for (const s of file.scopes) allScopes.push(s); + for (const d of file.localDefs) allDefs.push(d); + moduleEntries.push({ filePath: file.filePath, moduleScopeId: file.moduleScope }); + } + // References kept out of the loop above to centralize list-init. + allReferenceSites.push(...collectReferenceSites(parsedFiles)); + + const scopeTree = buildScopeTree(allScopes); + const defs = buildDefIndex(allDefs); + const qualifiedNames = buildQualifiedNameIndex(allDefs); + const moduleScopes = buildModuleScopeIndex(moduleEntries); + + // ── Step 3: MethodDispatchIndex. Today we lack per-language MRO + // strategies wired into this orchestrator (that belongs with the + // HeritageMap bridge, a separate piece of work). Ship an EMPTY index + // so the bundle shape is consistent; the callbacks return `[]` for + // every owner and `implementsOf` returns `[]`. Populating this + // properly is tracked alongside the per-language provider hooks. + const methodDispatch = buildMethodDispatchIndex({ + owners: [], // empty → no MRO entries; `mroFor(x)` returns the frozen empty array + computeMro: () => [], + implementsOf: () => [], + }); + + return { + scopeTree, + defs, + qualifiedNames, + moduleScopes, + methodDispatch, + imports: finalizeOut.imports, + bindings: finalizeOut.bindings, + referenceSites: Object.freeze([...allReferenceSites]), + sccs: finalizeOut.sccs, + stats: finalizeOut.stats, + }; +} + +// ─── Internal ─────────────────────────────────────────────────────────────── + +/** Shape-reduce a `ParsedFile` to the narrower `FinalizeFile` the shared + * algorithm reads. The subset is stable — `FinalizeFile` is a proper + * subset of `ParsedFile`. */ +function toFinalizeFile(file: ParsedFile): FinalizeFile { + return { + filePath: file.filePath, + moduleScope: file.moduleScope, + parsedImports: file.parsedImports, + localDefs: file.localDefs, + }; +} + +/** Flatten every file's reference sites into one list. Order reflects + * input-file order, then capture order inside each file. Deterministic. */ +function collectReferenceSites(parsedFiles: readonly ParsedFile[]) { + const out: ParsedFile['referenceSites'][number][] = []; + for (const file of parsedFiles) { + for (const site of file.referenceSites) out.push(site); + } + return out; +} + +/** + * Fill in no-op defaults for any omitted hook. Keeps `finalize()` + * behavior well-defined for the zero-provider case today: + * + * - `resolveImportTarget: () => null` — every import edge ends up + * `linkStatus: 'unresolved'` (or dynamic-unresolved pass-through). + * - `expandsWildcardTo: () => []` — wildcards don't materialize. + * - `mergeBindings: (existing, incoming) => [...existing, ...incoming]` + * — append without precedence; providers override to implement local- + * shadows-import and similar rules. + */ +function withDefaultHooks(partial: Partial): FinalizeHooks { + return { + resolveImportTarget: partial.resolveImportTarget ?? (() => null), + expandsWildcardTo: partial.expandsWildcardTo ?? (() => []), + mergeBindings: + partial.mergeBindings ?? + (( + existing: readonly BindingRef[], + incoming: readonly BindingRef[], + ): readonly BindingRef[] => [...existing, ...incoming]), + }; +} diff --git a/gitnexus/src/core/ingestion/model/scope-resolution-indexes.ts b/gitnexus/src/core/ingestion/model/scope-resolution-indexes.ts new file mode 100644 index 000000000..67e32fc0e --- /dev/null +++ b/gitnexus/src/core/ingestion/model/scope-resolution-indexes.ts @@ -0,0 +1,73 @@ +/** + * `ScopeResolutionIndexes` — the bundle of materialized indexes produced + * by the finalize-orchestrator (RFC #909 Ring 2 PKG #921) and attached + * to `MutableSemanticModel`. + * + * Produced by `finalizeScopeModel(parsedFiles, hooks)` in + * `finalize-orchestrator.ts`. Consumed by the resolution phase (future + * tickets) where `Registry.lookup` / `resolveTypeRef` query this bundle + * to answer call-resolution questions without re-walking any AST. + * + * ## Lifecycle + * + * 1. Pipeline collects `ParsedFile[]` from the parsing-processor (#920). + * 2. Pipeline invokes `finalizeScopeModel(parsedFiles, hooks)` → + * returns a `ScopeResolutionIndexes` (this interface). + * 3. Pipeline calls `model.attachScopeIndexes(indexes)` to stamp them + * onto the `MutableSemanticModel`. This is a **one-shot write**; + * subsequent calls throw. After attachment, the indexes are frozen + * at the type level (everything is `readonly`) and at runtime via + * `Object.freeze` on the bundle. + * 4. Resolution callers hold a `SemanticModel` reference and read + * `model.scopes` to query. + * + * ## Content + * + * - `scopeTree` / `moduleScopes` / `defs` / `qualifiedNames` — the + * four Ring 2 SHARED indexes built over per-file artifacts. + * - `methodDispatch` — MRO + implements materialized view (#914). + * - `imports` — finalized `ImportEdge[]` per module scope (`parsedImports` + * resolved through cross-file link + wildcard expansion). + * - `bindings` — merged bindings per module scope (local + import + + * wildcard + re-export), with the provider's precedence applied. + * - `referenceSites` — union of every file's pre-resolution usage + * facts. Consumed by the resolution phase (future) to emit + * `Reference` records into `ReferenceIndex`. + * - `stats` — coarse-grained counts from the shared finalize algorithm + * (total files/edges, linked vs unresolved, SCC topology). + * + * `ReferenceIndex` is deliberately NOT here — it is populated in a later + * phase (RFC §3.2 Phase 4 / Ring 2 PKG #925) and owned separately. + */ + +import type { + BindingRef, + DefIndex, + FinalizedScc, + FinalizeStats, + ImportEdge, + MethodDispatchIndex, + ModuleScopeIndex, + QualifiedNameIndex, + ReferenceSite, + ScopeId, + ScopeTree, +} from 'gitnexus-shared'; + +export interface ScopeResolutionIndexes { + readonly scopeTree: ScopeTree; + readonly defs: DefIndex; + readonly qualifiedNames: QualifiedNameIndex; + readonly moduleScopes: ModuleScopeIndex; + readonly methodDispatch: MethodDispatchIndex; + /** Finalized `ImportEdge[]` per module scope. */ + readonly imports: ReadonlyMap; + /** Merged bindings (local + imports + wildcards) per module scope. */ + readonly bindings: ReadonlyMap>; + /** Pre-resolution usage facts; consumed by the resolution phase. */ + readonly referenceSites: readonly ReferenceSite[]; + /** SCC condensation of the file-level import graph — callers that want + * parallel per-SCC processing in the resolution phase read this. */ + readonly sccs: readonly FinalizedScc[]; + readonly stats: FinalizeStats; +} diff --git a/gitnexus/src/core/ingestion/model/semantic-model.ts b/gitnexus/src/core/ingestion/model/semantic-model.ts index 35a2628a5..d1c5f1446 100644 --- a/gitnexus/src/core/ingestion/model/semantic-model.ts +++ b/gitnexus/src/core/ingestion/model/semantic-model.ts @@ -57,6 +57,7 @@ import type { SymbolDefinition } from 'gitnexus-shared'; import type { SymbolTableReader, SymbolTableWriter, AddMetadata } from './symbol-table.js'; import { createSymbolTable } from './symbol-table.js'; import { createRegistrationTable } from './registration-table.js'; +import type { ScopeResolutionIndexes } from './scope-resolution-indexes.js'; // --------------------------------------------------------------------------- // Public read-only interface @@ -83,6 +84,19 @@ export interface SemanticModel { readonly methods: MethodRegistry; readonly fields: FieldRegistry; readonly symbols: SymbolTableReader; + /** + * Materialized scope-resolution indexes from RFC #909 Ring 2 PKG #921. + * + * `undefined` until the finalize-orchestrator attaches them. While + * `undefined`, the legacy DAG is the sole resolution surface; once set, + * resolvers whose language has `REGISTRY_PRIMARY_=true` consult + * these indexes instead. + * + * The attach is a one-shot write (see `MutableSemanticModel`). Callers + * holding a read-only `SemanticModel` handle see either `undefined` or + * the final frozen bundle — never a half-populated view. + */ + readonly scopes?: ScopeResolutionIndexes; } // --------------------------------------------------------------------------- @@ -100,6 +114,17 @@ export interface MutableSemanticModel extends SemanticModel { readonly symbols: SymbolTableWriter; /** Clear all registries AND the nested SymbolTable. */ clear(): void; + /** + * Stamp the finalize-orchestrator's output onto this model. + * + * **One-shot write.** Throws when called a second time — the indexes are + * meant to be materialized once per ingestion run. `Object.freeze` is + * applied to the attached bundle so consumers cannot mutate after attach. + * + * `clear()` resets the attached bundle back to `undefined`, enabling a + * fresh re-ingestion to attach a new bundle. + */ + attachScopeIndexes(indexes: ScopeResolutionIndexes): void; } // --------------------------------------------------------------------------- @@ -152,6 +177,21 @@ export const createSemanticModel = (): MutableSemanticModel => { return def; }; + // Scope-resolution bundle slot. Starts `undefined`; populated by a + // one-shot `attachScopeIndexes(...)` from the finalize-orchestrator. + // Held inside the factory closure so the returned `SemanticModel` + // surface exposes it as a plain `readonly` property without a setter. + let attachedScopes: ScopeResolutionIndexes | undefined; + + const attachScopeIndexes = (indexes: ScopeResolutionIndexes): void => { + if (attachedScopes !== undefined) { + throw new Error( + 'SemanticModel: scope indexes already attached. ' + 'Call `clear()` before re-attaching.', + ); + } + attachedScopes = Object.freeze(indexes); + }; + // Cascade clear: single source of truth for "reset the entire model". // Wired into both `model.clear()` AND `model.symbols.clear()` so that a // caller holding only a SymbolTable reference can't leave the @@ -162,6 +202,7 @@ export const createSemanticModel = (): MutableSemanticModel => { methods.clear(); fields.clear(); rawSymbols.clear(); + attachedScopes = undefined; }; // Writer-typed facade: exposes reads + add, but NO `clear` field. @@ -184,6 +225,10 @@ export const createSemanticModel = (): MutableSemanticModel => { methods, fields, symbols, + get scopes() { + return attachedScopes; + }, clear: cascadeClear, + attachScopeIndexes, }; }; diff --git a/gitnexus/test/unit/scope-resolution/finalize-orchestrator.test.ts b/gitnexus/test/unit/scope-resolution/finalize-orchestrator.test.ts new file mode 100644 index 000000000..f3d92f0f0 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/finalize-orchestrator.test.ts @@ -0,0 +1,219 @@ +/** + * Unit tests for `finalize-orchestrator` (RFC #909 Ring 2 PKG #921). + * + * Covers empty-input, single-file, multi-file-with-imports, and the + * `MutableSemanticModel.attachScopeIndexes` one-shot contract. + * + * Builds synthetic `ParsedFile` inputs directly — the orchestrator is + * below the extraction layer and independent of tree-sitter, so the + * tests don't need a real parser. + */ + +import { describe, it, expect } from 'vitest'; +import type { + BindingRef, + ParsedFile, + ParsedImport, + Scope, + ScopeId, + SymbolDefinition, +} from 'gitnexus-shared'; +import { finalizeScopeModel } from '../../../src/core/ingestion/finalize-orchestrator.js'; +import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js'; +import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js'; + +// ─── Fixture helpers ──────────────────────────────────────────────────────── + +const mkScope = ( + id: ScopeId, + parent: ScopeId | null, + filePath: string, + bindings: Record = {}, +): Scope => ({ + id, + parent, + kind: parent === null ? 'Module' : 'Class', + range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 }, + filePath, + bindings: new Map(Object.entries(bindings)), + ownedDefs: [], + imports: [], + typeBindings: new Map(), +}); + +const mkFile = (filePath: string, overrides: Partial = {}): ParsedFile => ({ + filePath, + moduleScope: `scope:${filePath}#module`, + scopes: overrides.scopes ?? [mkScope(`scope:${filePath}#module`, null, filePath)], + parsedImports: overrides.parsedImports ?? [], + localDefs: overrides.localDefs ?? [], + referenceSites: overrides.referenceSites ?? [], +}); + +const mkDef = (nodeId: string, filePath: string, qname: string): SymbolDefinition => ({ + nodeId, + filePath, + type: 'Class', + qualifiedName: qname, +}); + +// ─── Empty input ─────────────────────────────────────────────────────────── + +describe('finalizeScopeModel: empty input', () => { + it('produces a valid but empty bundle for zero parsedFiles', () => { + const out = finalizeScopeModel([]); + expect(out.scopeTree.size).toBe(0); + expect(out.defs.size).toBe(0); + expect(out.qualifiedNames.size).toBe(0); + expect(out.moduleScopes.size).toBe(0); + expect(out.methodDispatch.mroByOwnerDefId.size).toBe(0); + expect(out.imports.size).toBe(0); + expect(out.bindings.size).toBe(0); + expect(out.referenceSites).toEqual([]); + expect(out.sccs).toEqual([]); + expect(out.stats.totalFiles).toBe(0); + expect(out.stats.totalEdges).toBe(0); + }); +}); + +// ─── Single file ─────────────────────────────────────────────────────────── + +describe('finalizeScopeModel: single file', () => { + it('builds all per-file indexes from a single ParsedFile', () => { + const userClass = mkDef('def:User', 'models.ts', 'models.User'); + const file = mkFile('models.ts', { + localDefs: [userClass], + }); + const out = finalizeScopeModel([file]); + + expect(out.scopeTree.size).toBe(1); + expect(out.defs.get('def:User')).toBe(userClass); + expect(out.qualifiedNames.get('models.User')).toEqual(['def:User']); + expect(out.moduleScopes.get('models.ts')).toBe(file.moduleScope); + expect(out.stats.totalFiles).toBe(1); + }); + + it('forwards per-file referenceSites into the aggregated list', () => { + const file = mkFile('a.ts', { + referenceSites: [ + { + name: 'save', + atRange: { startLine: 5, startCol: 0, endLine: 5, endCol: 4 }, + inScope: 'scope:a.ts#module', + kind: 'call', + }, + ], + }); + const out = finalizeScopeModel([file]); + expect(out.referenceSites).toHaveLength(1); + expect(out.referenceSites[0]!.name).toBe('save'); + }); +}); + +// ─── Multi-file with cross-file imports ──────────────────────────────────── + +describe('finalizeScopeModel: cross-file imports', () => { + it('links a named import when the caller provides resolveImportTarget', () => { + const userClass = mkDef('def:User', 'models.ts', 'models.User'); + const modelsFile = mkFile('models.ts', { localDefs: [userClass] }); + + const importOfUser: ParsedImport = { + kind: 'named', + localName: 'User', + importedName: 'User', + targetRaw: 'models.ts', + }; + const appFile = mkFile('app.ts', { parsedImports: [importOfUser] }); + + const out = finalizeScopeModel([appFile, modelsFile], { + hooks: { + resolveImportTarget: (targetRaw) => (targetRaw === 'models.ts' ? 'models.ts' : null), + }, + }); + + const appImports = out.imports.get(appFile.moduleScope) ?? []; + expect(appImports).toHaveLength(1); + expect(appImports[0]!.linkStatus).toBeUndefined(); + expect(appImports[0]!.targetFile).toBe('models.ts'); + expect(appImports[0]!.targetDefId).toBe('def:User'); + }); + + it('leaves imports unresolved when no resolveImportTarget is supplied (default hook)', () => { + // Default `resolveImportTarget: () => null` — every import ends up + // with `linkStatus: 'unresolved'`. This is the zero-provider case + // today; behavior is well-defined, not a crash. + const importOfUser: ParsedImport = { + kind: 'named', + localName: 'User', + importedName: 'User', + targetRaw: 'models.ts', + }; + const appFile = mkFile('app.ts', { parsedImports: [importOfUser] }); + + const out = finalizeScopeModel([appFile]); + const appImports = out.imports.get(appFile.moduleScope) ?? []; + expect(appImports).toHaveLength(1); + expect(appImports[0]!.linkStatus).toBe('unresolved'); + }); + + it('surfaces FinalizeStats for observability', () => { + const userClass = mkDef('def:User', 'models.ts', 'models.User'); + const modelsFile = mkFile('models.ts', { localDefs: [userClass] }); + const appFile = mkFile('app.ts', { + parsedImports: [ + { + kind: 'named', + localName: 'User', + importedName: 'User', + targetRaw: 'models.ts', + }, + ], + }); + const out = finalizeScopeModel([appFile, modelsFile], { + hooks: { resolveImportTarget: () => 'models.ts' }, + }); + expect(out.stats.totalFiles).toBe(2); + expect(out.stats.totalEdges).toBe(1); + expect(out.stats.linkedEdges).toBe(1); + expect(out.stats.unresolvedEdges).toBe(0); + }); +}); + +// ─── Integration with MutableSemanticModel ───────────────────────────────── + +describe('MutableSemanticModel.attachScopeIndexes', () => { + it('starts as undefined and accepts a one-shot attach', () => { + const model = createSemanticModel(); + expect(model.scopes).toBeUndefined(); + + const indexes = finalizeScopeModel([]); + model.attachScopeIndexes(indexes); + + expect(model.scopes).toBe(indexes); + expect(model.scopes!.stats.totalFiles).toBe(0); + }); + + it('freezes the attached bundle (callers cannot mutate after attach)', () => { + const model = createSemanticModel(); + const indexes: ScopeResolutionIndexes = finalizeScopeModel([]); + model.attachScopeIndexes(indexes); + + expect(Object.isFrozen(model.scopes)).toBe(true); + }); + + it('throws on a second attach without clear()', () => { + const model = createSemanticModel(); + model.attachScopeIndexes(finalizeScopeModel([])); + expect(() => model.attachScopeIndexes(finalizeScopeModel([]))).toThrowError(/already attached/); + }); + + it('clear() resets the bundle, enabling re-attach', () => { + const model = createSemanticModel(); + model.attachScopeIndexes(finalizeScopeModel([])); + model.clear(); + expect(model.scopes).toBeUndefined(); + // Second attach now succeeds. + model.attachScopeIndexes(finalizeScopeModel([])); + expect(model.scopes).toBeDefined(); + }); +});