mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
feat(ingestion): finalize-orchestrator materializes ScopeResolutionIndexes (#921, RFC #909 Ring 2 PKG) (#970)
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<ScopeId, readonly ImportEdge[]>; readonly bindings: ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>; 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_<LANG>=true` can now expect `model.scopes` to be populated when the pipeline wires the orchestrator in
This commit is contained in:
parent
39b5d295c7
commit
25520e90a5
4 changed files with 533 additions and 0 deletions
196
gitnexus/src/core/ingestion/finalize-orchestrator.ts
Normal file
196
gitnexus/src/core/ingestion/finalize-orchestrator.ts
Normal file
|
|
@ -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<FinalizeHooks>;
|
||||
/**
|
||||
* 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<typeof collectReferenceSites>;
|
||||
|
||||
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>): FinalizeHooks {
|
||||
return {
|
||||
resolveImportTarget: partial.resolveImportTarget ?? (() => null),
|
||||
expandsWildcardTo: partial.expandsWildcardTo ?? (() => []),
|
||||
mergeBindings:
|
||||
partial.mergeBindings ??
|
||||
((
|
||||
existing: readonly BindingRef[],
|
||||
incoming: readonly BindingRef[],
|
||||
): readonly BindingRef[] => [...existing, ...incoming]),
|
||||
};
|
||||
}
|
||||
|
|
@ -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<ScopeId, readonly ImportEdge[]>;
|
||||
/** Merged bindings (local + imports + wildcards) per module scope. */
|
||||
readonly bindings: ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>;
|
||||
/** 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;
|
||||
}
|
||||
|
|
@ -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_<LANG>=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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<string, readonly BindingRef[]> = {},
|
||||
): 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> = {}): 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue