From 8cf9ae0e0d8a4425caaeb8d453517dc9f3e677f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 18 Apr 2026 16:41:38 +0100 Subject: [PATCH] feat(shared): ScopeTree + PositionIndex + makeScopeId (#912, RFC #909 Ring 2 SHARED) (#961) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the scope-tree spine and position-indexed lookup as pure logic in `gitnexus-shared`. Generalizes the `enclosingFunctions` pattern from closed PR #902 to arbitrary `ScopeKind`s. Three modules under `gitnexus-shared/src/scope-resolution/`: 1. `scope-id.ts` — `makeScopeId({filePath, range, kind})` builds the canonical RFC §2.2 shape `scope:{filePath}#{startLine}:{startCol}-{endLine}:{endCol}:{kind}` and interns the result through a process-local pool so repeated calls with structurally identical inputs return the same string reference. `clearScopeIdInternPool()` exported for test isolation. 2. `scope-tree.ts` — `buildScopeTree(scopes)` validates invariants and returns an immutable `ScopeTree`: - `getScope(id)` / `getParent(id)` / `getChildren(id)` / `getAncestors(id)` - Implements the `ScopeLookup` contract from #916, so `resolveTypeRef` can consume a `ScopeTree` directly (test included). Invariants enforced (throw `ScopeTreeInvariantError` on violation): - Non-Module scopes must have a parent. - Parent must exist in the supplied set. - Parent range STRICTLY contains child range (equal ranges rejected). - Sibling ranges under the same parent do not overlap. Ranges that merely touch at the boundary (`a.end == b.start`) are accepted. - Parent and child live in the same filePath. - Duplicate scope ids are rejected. 3. `position-index.ts` — `buildPositionIndex(scopes)` produces a `PositionIndex` with `atPosition(filePath, line, col)`. Per-file sorted array; binary-search the upper bound of `start ≤ query`, scan backward through the prefix, return the first containing hit. Complexity: `O(log N_file + D)` typical (D = lexical depth ≤ ~10); degrades to `O(N_file)` only under pathological inputs (many scopes starting at the same position). "Innermost wins" falls out of the sort + backward-scan contract because `ScopeTree`'s invariants guarantee that scopes containing a point form an ancestor chain. Types: - `ScopeTree` now exported from `scope-tree.ts`. The Ring 1 opaque placeholder in `types.ts` has been removed; LanguageProvider hooks that previously took `ScopeTree = unknown` now receive the concrete interface (CLI `tsc --noEmit` passes — no existing callers rely on the opaque shape). Tests (39, all passing): - scope-id: canonical shape · all six ScopeKinds encoded · identity equality (same inputs → same reference) · distinguished by filePath / range / kind · purity under repeated calls · intern-pool clear preserves canonical shape. - scope-tree: empty tree · single module · nested Module→Class→Function · multiple siblings input-order preserved · ScopeLookup integration with resolveTypeRef · frozen children and ancestor arrays · all six invariant violations (non-Module orphan, parent-not-found, parent doesn't contain, parent == child, siblings overlap, cross-file parent, duplicate id) · boundary-touching siblings accepted. - position-index: empty · unindexed filePath · before/after-file queries · start/end inclusivity · innermost-wins for nested / co- starting / co-ending / same-line scopes · sibling dispatch · multi- file isolation · size · id-dedup. Combined scope-resolution / model / shadow suite: 190/190 pass. `tsc --noEmit` clean in both `gitnexus-shared` and `gitnexus`. Closes part of #909. Unblocks #917 (`Registry.lookup` needs the scope spine); makes `ScopeLookup` in #916 concrete without API churn. --- gitnexus-shared/src/index.ts | 9 +- .../src/scope-resolution/position-index.ts | 154 +++++++++ .../src/scope-resolution/scope-id.ts | 57 ++++ .../src/scope-resolution/scope-tree.ts | 254 +++++++++++++++ gitnexus-shared/src/scope-resolution/types.ts | 9 +- .../scope-resolution/position-index.test.ts | 191 +++++++++++ .../unit/scope-resolution/scope-id.test.ts | 81 +++++ .../unit/scope-resolution/scope-tree.test.ts | 299 ++++++++++++++++++ 8 files changed, 1047 insertions(+), 7 deletions(-) create mode 100644 gitnexus-shared/src/scope-resolution/position-index.ts create mode 100644 gitnexus-shared/src/scope-resolution/scope-id.ts create mode 100644 gitnexus-shared/src/scope-resolution/scope-tree.ts create mode 100644 gitnexus/test/unit/scope-resolution/position-index.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/scope-id.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/scope-tree.test.ts diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index edacb1214..f73c18f73 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -47,7 +47,6 @@ export type { ParsedImport, ParsedTypeBinding, WorkspaceIndex, - ScopeTree, Callsite, } from './scope-resolution/types.js'; @@ -82,6 +81,14 @@ export type { MethodDispatchInput, } from './scope-resolution/method-dispatch-index.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'; +export { buildScopeTree, ScopeTreeInvariantError } from './scope-resolution/scope-tree.js'; +export type { ScopeTree } from './scope-resolution/scope-tree.js'; +export { buildPositionIndex } from './scope-resolution/position-index.js'; +export type { PositionIndex } from './scope-resolution/position-index.js'; + // Shadow-mode diff + aggregation (RFC §6.3; Ring 2 SHARED #918) export { diffResolutions } from './scope-resolution/shadow/diff.js'; export type { diff --git a/gitnexus-shared/src/scope-resolution/position-index.ts b/gitnexus-shared/src/scope-resolution/position-index.ts new file mode 100644 index 000000000..82954a19f --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/position-index.ts @@ -0,0 +1,154 @@ +/** + * `PositionIndex` — O(log N_file) scope-at-position lookup + * (RFC §3.1; Ring 2 SHARED #912). + * + * Per-file sorted array of `(range, scopeId)` entries, sorted by start + * position ASC (`startLine`, then `startCol`). `atPosition(filePath, line, + * col)` binary-searches for the last entry whose start ≤ (line, col), then + * scans backward through the sorted prefix and returns the first entry + * whose range contains the query position. + * + * **Why this works.** `ScopeTree`'s invariants (parent strictly contains + * child; siblings don't overlap) guarantee that the scopes containing a + * given point form an **ancestor chain**. When scanning backward through + * entries sorted by start position ASC, the first scope we find that + * contains the query is the innermost one — any deeper-starting scope + * that also contained the query would appear *later* in the sorted array, + * but we're only scanning entries with start ≤ query, so anything later + * necessarily starts after the query and can't contain it. + * + * Expected complexity: `O(log N_file + D)` where `D` is the lexical depth + * at the query position (typically ≤ 10). Worst-case degrades to `O(N_file)` + * only under pathological inputs (many scopes starting at the same line). + * + * **Line/column conventions.** Matches `Range` in `types.ts`: lines are + * 1-based, columns are 0-based. Ranges are **inclusive on both ends** — + * a scope whose `endLine:endCol` equals the query position still contains + * it. That matches how tree-sitter captures bodies (closing brace + * included) and how closed PR #902's `enclosingFunctions` behaved. + */ + +import type { Range, Scope, ScopeId } from './types.js'; + +export interface PositionIndex { + /** Total scope entries indexed across all files. */ + readonly size: number; + /** + * Innermost scope containing `(line, col)` in `filePath`, or `undefined` + * when nothing contains it (position before file start, after file end, + * or filePath not indexed). + */ + atPosition(filePath: string, line: number, col: number): ScopeId | undefined; +} + +/** + * Build a `PositionIndex` from a flat list of `Scope` records. + * + * Duplicate `id`s are tolerated and deduplicated — the caller's + * `ScopeTree.buildScopeTree` is the authoritative validator of scope + * identity, and the position index does not need to re-check that + * invariant. + */ +export function buildPositionIndex(scopes: readonly Scope[]): PositionIndex { + const entriesByFile = new Map(); + const seen = new Set(); + + for (const scope of scopes) { + if (seen.has(scope.id)) continue; + seen.add(scope.id); + + let bucket = entriesByFile.get(scope.filePath); + if (bucket === undefined) { + bucket = []; + entriesByFile.set(scope.filePath, bucket); + } + bucket.push({ id: scope.id, range: scope.range }); + } + + for (const bucket of entriesByFile.values()) { + bucket.sort(compareEntry); + } + + return freezeIndex(entriesByFile, seen.size); +} + +// ─── Internals ────────────────────────────────────────────────────────────── + +interface Entry { + readonly id: ScopeId; + readonly range: Range; +} + +/** + * Sort by start position ASC, breaking ties by end position DESC so that + * larger (outer) scopes appear before their smaller (inner) co-starting + * siblings in the array. Makes the backward-scan contract crisp: the + * first containing hit from the end of the scanned prefix is the + * innermost scope. + */ +function compareEntry(a: Entry, b: Entry): number { + if (a.range.startLine !== b.range.startLine) return a.range.startLine - b.range.startLine; + if (a.range.startCol !== b.range.startCol) return a.range.startCol - b.range.startCol; + if (a.range.endLine !== b.range.endLine) return b.range.endLine - a.range.endLine; + return b.range.endCol - a.range.endCol; +} + +/** Whether `(line, col)` is at or after `range`'s start. */ +function startIsAtOrBefore(range: Range, line: number, col: number): boolean { + if (range.startLine < line) return true; + if (range.startLine > line) return false; + return range.startCol <= col; +} + +/** Whether `(line, col)` is at or before `range`'s end (inclusive). */ +function endIsAtOrAfter(range: Range, line: number, col: number): boolean { + if (range.endLine > line) return true; + if (range.endLine < line) return false; + return range.endCol >= col; +} + +/** + * Return the largest index `i` in `arr` where `arr[i].range` starts at or + * before `(line, col)`. Returns `-1` if no entry starts ≤ the query. + * + * Classic "upper bound - 1" binary search: find the first entry that + * starts *after* the query, then step back one. + */ +function findLastStartLteIndex(arr: readonly Entry[], line: number, col: number): number { + let lo = 0; + let hi = arr.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (startIsAtOrBefore(arr[mid]!.range, line, col)) { + lo = mid + 1; + } else { + hi = mid; + } + } + return lo - 1; +} + +function freezeIndex(entriesByFile: Map, size: number): PositionIndex { + return { + get size() { + return size; + }, + atPosition(filePath: string, line: number, col: number): ScopeId | undefined { + const bucket = entriesByFile.get(filePath); + if (bucket === undefined || bucket.length === 0) return undefined; + + const endIdx = findLastStartLteIndex(bucket, line, col); + if (endIdx < 0) return undefined; + + // Scan backward; first containing hit is innermost (see file header). + for (let i = endIdx; i >= 0; i--) { + const entry = bucket[i]!; + if (endIsAtOrAfter(entry.range, line, col)) { + // `startIsAtOrBefore` is guaranteed true by the binary search. + return entry.id; + } + } + return undefined; + }, + }; +} diff --git a/gitnexus-shared/src/scope-resolution/scope-id.ts b/gitnexus-shared/src/scope-resolution/scope-id.ts new file mode 100644 index 000000000..b682468cc --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/scope-id.ts @@ -0,0 +1,57 @@ +/** + * `ScopeId` canonical constructor + string intern pool + * (RFC §2.2; Ring 2 SHARED #912). + * + * `ScopeId` is a deterministic string derived from the scope's file path, + * byte range, and kind: + * + * scope:{filePath}#{startLine}:{startCol}-{endLine}:{endCol}:{kind} + * + * Two scopes produced by reparsing the same file at the same positions are + * `===`-equal as strings. Beyond the canonical shape, `makeScopeId` also + * **interns** the string through a process-local pool, so repeated calls + * with structurally identical inputs return the same string reference — + * making `Map` lookups and cache keys identity-fast. + * + * The intern pool is unbounded. The number of distinct `ScopeId`s across a + * single indexing run is O(total scopes in workspace), which is bounded by + * source-text size and already in memory; interning adds no asymptotic + * pressure. `clearScopeIdInternPool` is exported for test isolation. + */ + +import type { Range } from './types.js'; +import type { ScopeId, ScopeKind } from './types.js'; + +/** Inputs required to construct a canonical `ScopeId`. */ +export interface ScopeIdInput { + readonly filePath: string; + readonly range: Range; + readonly kind: ScopeKind; +} + +/** + * Build a canonical `ScopeId` from its structural parts and intern it. + * + * Pure + referentially transparent: given the same input shape, always + * returns the same string reference for the lifetime of the pool. + */ +export function makeScopeId(input: ScopeIdInput): ScopeId { + const raw = `scope:${input.filePath}#${input.range.startLine}:${input.range.startCol}-${input.range.endLine}:${input.range.endCol}:${input.kind}`; + const existing = INTERN_POOL.get(raw); + if (existing !== undefined) return existing; + INTERN_POOL.set(raw, raw); + return raw; +} + +/** + * Drop the intern pool. Intended for test setup/teardown — production code + * should not need this, since the pool's memory usage is bounded by the + * number of live scopes and cleaning it mid-run would break identity + * equality for existing scope ids. + */ +export function clearScopeIdInternPool(): void { + INTERN_POOL.clear(); +} + +/** Internal: shared intern pool (process-local). */ +const INTERN_POOL = new Map(); diff --git a/gitnexus-shared/src/scope-resolution/scope-tree.ts b/gitnexus-shared/src/scope-resolution/scope-tree.ts new file mode 100644 index 000000000..705dfb731 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/scope-tree.ts @@ -0,0 +1,254 @@ +/** + * `ScopeTree` — the lexical-scope spine of the `SemanticModel` + * (RFC §2.2 + §3.1; Ring 2 SHARED #912). + * + * Generalizes the `enclosingFunctions` pattern from closed PR #902 to + * arbitrary `ScopeKind`s. Owns the (parent ↔ children) relationship + * derived from each `Scope.parent` pointer, and validates the structural + * invariants a well-formed scope tree must satisfy. + * + * Invariants enforced at build time (throw on violation): + * + * - Every non-`Module` scope has a non-null parent. + * - Every parent pointer references a scope that was also supplied to + * `buildScopeTree`. + * - Parent range **strictly contains** child range. + * - Sibling ranges under the same parent do not overlap. + * - Parent and child live in the same `filePath`. (Cross-file parent + * pointers would be a category error — a `File` scope is not the + * parent of another file's scopes; imports do that job.) + * + * Satisfies the `ScopeLookup` contract from #916 (`resolve-type-ref`), so + * `resolveTypeRef` can take a `ScopeTree` directly without adapters. + * + * Immutable surface: `byId` is a `ReadonlyMap`; children arrays are + * `Object.freeze`d; miss lookups return a shared frozen empty array. + */ + +import type { Scope, ScopeId, Range } from './types.js'; +import type { ScopeLookup } from './resolve-type-ref.js'; + +// ─── Public contract ──────────────────────────────────────────────────────── + +export interface ScopeTree extends ScopeLookup { + readonly size: number; + readonly byId: ReadonlyMap; + + getScope(id: ScopeId): Scope | undefined; + getParent(id: ScopeId): Scope | undefined; + /** Child `ScopeId`s of `id`, in input order. Frozen empty array on miss. */ + getChildren(id: ScopeId): readonly ScopeId[]; + /** + * Ancestor chain from the immediate parent up to (and including) the + * root module scope. Excludes the starting scope itself. Frozen empty + * array on miss / for a root scope. + */ + getAncestors(id: ScopeId): readonly ScopeId[]; + has(id: ScopeId): boolean; +} + +// ─── Build errors ─────────────────────────────────────────────────────────── + +/** + * Thrown by `buildScopeTree` when the input violates a structural + * invariant. Carries the offending ids + the invariant name so failed + * extraction pipelines can report actionable diagnostics. + */ +export class ScopeTreeInvariantError extends Error { + constructor( + readonly invariant: + | 'non-module-requires-parent' + | 'parent-not-found' + | 'parent-must-contain-child' + | 'sibling-ranges-overlap' + | 'parent-must-share-filepath' + | 'duplicate-scope-id', + message: string, + ) { + super(message); + this.name = 'ScopeTreeInvariantError'; + } +} + +// ─── Builder ─────────────────────────────────────────────────────────────── + +/** + * Build an immutable `ScopeTree` from a flat list of `Scope` records. + * + * Throws `ScopeTreeInvariantError` on the first invariant violation; a + * malformed tree is a bug in the extraction pipeline, not a data case for + * consumers to handle, so fail-fast is the correct posture. + */ +export function buildScopeTree(scopes: readonly Scope[]): ScopeTree { + const byId = new Map(); + const childrenById = new Map(); + + // ── Pass 1: collect by id + duplicate check ─────────────────────────── + for (const scope of scopes) { + if (byId.has(scope.id)) { + throw new ScopeTreeInvariantError( + 'duplicate-scope-id', + `Two scopes share id '${scope.id}'. Scope ids must be unique per tree.`, + ); + } + byId.set(scope.id, scope); + } + + // ── Pass 2: validate parent pointers + build children buckets ───────── + for (const scope of scopes) { + if (scope.parent === null) { + if (scope.kind !== 'Module') { + throw new ScopeTreeInvariantError( + 'non-module-requires-parent', + `Scope '${scope.id}' has kind '${scope.kind}' but no parent. Only 'Module' scopes may be root-level.`, + ); + } + continue; + } + + const parent = byId.get(scope.parent); + if (parent === undefined) { + throw new ScopeTreeInvariantError( + 'parent-not-found', + `Scope '${scope.id}' references parent '${scope.parent}' which is not part of this tree.`, + ); + } + if (parent.filePath !== scope.filePath) { + throw new ScopeTreeInvariantError( + 'parent-must-share-filepath', + `Scope '${scope.id}' (${scope.filePath}) has parent '${parent.id}' in a different file (${parent.filePath}). Parent/child scopes must share filePath.`, + ); + } + if (!rangeStrictlyContains(parent.range, scope.range)) { + throw new ScopeTreeInvariantError( + 'parent-must-contain-child', + `Parent scope '${parent.id}' at ${formatRange(parent.range)} does not strictly contain child '${scope.id}' at ${formatRange(scope.range)}.`, + ); + } + + let bucket = childrenById.get(parent.id); + if (bucket === undefined) { + bucket = []; + childrenById.set(parent.id, bucket); + } + bucket.push(scope.id); + } + + // ── Pass 3: sibling-overlap check ───────────────────────────────────── + for (const [parentId, childIds] of childrenById) { + if (childIds.length < 2) continue; + // Sort siblings by (startLine, startCol) for an O(n log n) pairwise + // scan instead of O(n²) all-pairs. + const children = childIds.map((id) => byId.get(id)!).slice(); + children.sort((a, b) => comparePosition(a.range, b.range)); + for (let i = 1; i < children.length; i++) { + const prev = children[i - 1]!; + const curr = children[i]!; + if (rangesOverlap(prev.range, curr.range)) { + throw new ScopeTreeInvariantError( + 'sibling-ranges-overlap', + `Sibling scopes under parent '${parentId}' overlap: '${prev.id}' ${formatRange(prev.range)} and '${curr.id}' ${formatRange(curr.range)}.`, + ); + } + } + } + + // Freeze children arrays so the surface is truly read-only. + const frozenChildren = new Map(); + for (const [parentId, childIds] of childrenById) { + frozenChildren.set(parentId, Object.freeze(childIds.slice())); + } + + return freezeTree(byId, frozenChildren); +} + +// ─── Internals ────────────────────────────────────────────────────────────── + +const EMPTY_CHILDREN: readonly ScopeId[] = Object.freeze([]); + +function freezeTree( + byId: Map, + childrenById: Map, +): ScopeTree { + return { + byId, + get size() { + return byId.size; + }, + getScope(id: ScopeId): Scope | undefined { + return byId.get(id); + }, + getParent(id: ScopeId): Scope | undefined { + const scope = byId.get(id); + if (scope === undefined || scope.parent === null) return undefined; + return byId.get(scope.parent); + }, + getChildren(id: ScopeId): readonly ScopeId[] { + return childrenById.get(id) ?? EMPTY_CHILDREN; + }, + getAncestors(id: ScopeId): readonly ScopeId[] { + const start = byId.get(id); + if (start === undefined || start.parent === null) return EMPTY_CHILDREN; + const out: ScopeId[] = []; + const visited = new Set([id]); + let cursor: ScopeId | null = start.parent; + while (cursor !== null && !visited.has(cursor)) { + visited.add(cursor); + out.push(cursor); + const next = byId.get(cursor); + cursor = next === undefined ? null : next.parent; + } + return Object.freeze(out); + }, + has(id: ScopeId): boolean { + return byId.has(id); + }, + }; +} + +/** + * `outer` strictly contains `inner` when `outer`'s start is at or before + * `inner`'s start, `outer`'s end is at or after `inner`'s end, and they are + * not the exact same range. Equal ranges are rejected — a child cannot + * occupy the exact same span as its parent. + */ +function rangeStrictlyContains(outer: Range, inner: Range): boolean { + if ( + outer.startLine === inner.startLine && + outer.startCol === inner.startCol && + outer.endLine === inner.endLine && + outer.endCol === inner.endCol + ) { + return false; + } + const outerStartsAtOrBefore = + outer.startLine < inner.startLine || + (outer.startLine === inner.startLine && outer.startCol <= inner.startCol); + const outerEndsAtOrAfter = + outer.endLine > inner.endLine || + (outer.endLine === inner.endLine && outer.endCol >= inner.endCol); + return outerStartsAtOrBefore && outerEndsAtOrAfter; +} + +/** + * Two ranges overlap when neither finishes before the other begins. Ranges + * that merely touch at a single boundary point (`a.end === b.start`) do + * NOT overlap — this matches tree-sitter's half-open-like range semantics + * and the typical "sibling blocks meet but don't overlap" pattern. + */ +function rangesOverlap(a: Range, b: Range): boolean { + const aEndsBeforeB = + a.endLine < b.startLine || (a.endLine === b.startLine && a.endCol <= b.startCol); + const bEndsBeforeA = + b.endLine < a.startLine || (b.endLine === a.startLine && b.endCol <= a.startCol); + return !(aEndsBeforeB || bEndsBeforeA); +} + +function comparePosition(a: Range, b: Range): number { + if (a.startLine !== b.startLine) return a.startLine - b.startLine; + return a.startCol - b.startCol; +} + +function formatRange(r: Range): string { + return `${r.startLine}:${r.startCol}-${r.endLine}:${r.endCol}`; +} diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index 84945dcd9..d04373a94 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -190,12 +190,9 @@ export interface ParsedTypeBinding { */ export type WorkspaceIndex = unknown; -/** - * Scope tree handle consumed by parse-phase hooks (`bindingScopeFor`, - * `importOwningScope`) to navigate the in-progress scope tree. Opaque - * placeholder in Ring 1; concretely typed in Ring 2 SHARED (#912). - */ -export type ScopeTree = unknown; +// `ScopeTree` is exported from `./scope-tree.js` as of Ring 2 SHARED (#912). +// The former opaque placeholder lived here during Ring 1; removed now that +// the concrete type exists. Consumers import from `gitnexus-shared` directly. /** Call-site description passed to `arityCompatibility`. */ export interface Callsite { diff --git a/gitnexus/test/unit/scope-resolution/position-index.test.ts b/gitnexus/test/unit/scope-resolution/position-index.test.ts new file mode 100644 index 000000000..8c3a410b1 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/position-index.test.ts @@ -0,0 +1,191 @@ +/** + * Unit tests for `buildPositionIndex` / `PositionIndex` + * (RFC #909 Ring 2 SHARED #912). + * + * Covers: empty input, single scope, nested scopes (innermost-wins), + * positions before/after all scopes, boundary positions (inclusive ends), + * multi-file isolation, and duplicate-scope-id dedup. + */ + +import { describe, it, expect } from 'vitest'; +import { + buildPositionIndex, + type Range, + type Scope, + type ScopeId, + type ScopeKind, +} from 'gitnexus-shared'; + +// ─── Test helpers ─────────────────────────────────────────────────────────── + +const r = (startLine: number, startCol: number, endLine: number, endCol: number): Range => ({ + startLine, + startCol, + endLine, + endCol, +}); + +const mkScope = ( + id: ScopeId, + filePath: string, + kind: ScopeKind, + range: Range, + parent: ScopeId | null = null, +): Scope => ({ + id, + parent, + kind, + range, + filePath, + bindings: new Map(), + ownedDefs: [], + imports: [], + typeBindings: new Map(), +}); + +// ─── Tests ────────────────────────────────────────────────────────────────── + +describe('buildPositionIndex', () => { + describe('empty / missing', () => { + it('returns undefined for any query on an empty index', () => { + const idx = buildPositionIndex([]); + expect(idx.size).toBe(0); + expect(idx.atPosition('src/any.ts', 1, 0)).toBeUndefined(); + }); + + it('returns undefined for unindexed filePaths', () => { + const idx = buildPositionIndex([mkScope('scope:m', 'a.ts', 'Module', r(1, 0, 10, 0))]); + expect(idx.atPosition('b.ts', 5, 0)).toBeUndefined(); + }); + + it('returns undefined for positions before any scope in the file', () => { + const idx = buildPositionIndex([mkScope('scope:m', 'a.ts', 'Module', r(5, 0, 10, 0))]); + expect(idx.atPosition('a.ts', 1, 0)).toBeUndefined(); + expect(idx.atPosition('a.ts', 4, 99)).toBeUndefined(); + }); + + it('returns undefined for positions after all scopes in the file', () => { + const idx = buildPositionIndex([mkScope('scope:m', 'a.ts', 'Module', r(1, 0, 10, 5))]); + expect(idx.atPosition('a.ts', 11, 0)).toBeUndefined(); + expect(idx.atPosition('a.ts', 10, 6)).toBeUndefined(); + }); + }); + + describe('single scope lookup', () => { + it('returns the scope id for a point inside its range', () => { + const idx = buildPositionIndex([mkScope('scope:m', 'a.ts', 'Module', r(1, 0, 10, 0))]); + expect(idx.atPosition('a.ts', 5, 4)).toBe('scope:m'); + }); + + it('includes the start boundary', () => { + const idx = buildPositionIndex([mkScope('scope:m', 'a.ts', 'Module', r(5, 2, 10, 0))]); + expect(idx.atPosition('a.ts', 5, 2)).toBe('scope:m'); + expect(idx.atPosition('a.ts', 5, 1)).toBeUndefined(); + }); + + it('includes the end boundary', () => { + const idx = buildPositionIndex([mkScope('scope:m', 'a.ts', 'Module', r(1, 0, 10, 5))]); + expect(idx.atPosition('a.ts', 10, 5)).toBe('scope:m'); + expect(idx.atPosition('a.ts', 10, 6)).toBeUndefined(); + }); + }); + + describe('innermost-containing wins', () => { + it('picks the innermost of nested scopes', () => { + // Module[1..100] ⊃ Class[5..80] ⊃ Function[10..60] ⊃ Block[20..50] + const idx = buildPositionIndex([ + mkScope('scope:mod', 'a.ts', 'Module', r(1, 0, 100, 0)), + mkScope('scope:cls', 'a.ts', 'Class', r(5, 0, 80, 0), 'scope:mod'), + mkScope('scope:fn', 'a.ts', 'Function', r(10, 0, 60, 0), 'scope:cls'), + mkScope('scope:blk', 'a.ts', 'Block', r(20, 0, 50, 0), 'scope:fn'), + ]); + expect(idx.atPosition('a.ts', 30, 0)).toBe('scope:blk'); // deepest + expect(idx.atPosition('a.ts', 15, 0)).toBe('scope:fn'); // inside fn, outside blk + expect(idx.atPosition('a.ts', 7, 0)).toBe('scope:cls'); // inside class body only + expect(idx.atPosition('a.ts', 2, 0)).toBe('scope:mod'); // module top + }); + + it('innermost wins when two scopes start at the same position', () => { + // Two scopes both start at line 5 col 0; outer ends at 50, inner at 20. + const idx = buildPositionIndex([ + mkScope('scope:outer', 'a.ts', 'Module', r(5, 0, 50, 0)), + mkScope('scope:inner', 'a.ts', 'Function', r(5, 0, 20, 0), 'scope:outer'), + ]); + expect(idx.atPosition('a.ts', 10, 0)).toBe('scope:inner'); // both contain; inner wins + expect(idx.atPosition('a.ts', 30, 0)).toBe('scope:outer'); // only outer contains + }); + + it('innermost wins when scopes share an end position but differ in start', () => { + const idx = buildPositionIndex([ + mkScope('scope:outer', 'a.ts', 'Module', r(1, 0, 50, 0)), + mkScope('scope:inner', 'a.ts', 'Function', r(30, 0, 50, 0), 'scope:outer'), + ]); + expect(idx.atPosition('a.ts', 40, 0)).toBe('scope:inner'); + expect(idx.atPosition('a.ts', 20, 0)).toBe('scope:outer'); + }); + + it('returns the sibling whose range contains the query, not the other', () => { + // Two non-overlapping siblings under the same parent. + const idx = buildPositionIndex([ + mkScope('scope:mod', 'a.ts', 'Module', r(1, 0, 100, 0)), + mkScope('scope:a', 'a.ts', 'Function', r(5, 0, 20, 0), 'scope:mod'), + mkScope('scope:b', 'a.ts', 'Function', r(25, 0, 40, 0), 'scope:mod'), + ]); + expect(idx.atPosition('a.ts', 10, 0)).toBe('scope:a'); + expect(idx.atPosition('a.ts', 30, 0)).toBe('scope:b'); + expect(idx.atPosition('a.ts', 22, 0)).toBe('scope:mod'); // gap between siblings + }); + }); + + describe('multi-file isolation', () => { + it('indexes each filePath independently — no cross-file hits', () => { + const idx = buildPositionIndex([ + mkScope('scope:a-mod', 'a.ts', 'Module', r(1, 0, 50, 0)), + mkScope('scope:b-mod', 'b.ts', 'Module', r(1, 0, 50, 0)), + ]); + expect(idx.atPosition('a.ts', 10, 0)).toBe('scope:a-mod'); + expect(idx.atPosition('b.ts', 10, 0)).toBe('scope:b-mod'); + }); + + it('counts all indexed scopes in `size`', () => { + const idx = buildPositionIndex([ + mkScope('scope:a-mod', 'a.ts', 'Module', r(1, 0, 50, 0)), + mkScope('scope:a-fn', 'a.ts', 'Function', r(10, 0, 20, 0), 'scope:a-mod'), + mkScope('scope:b-mod', 'b.ts', 'Module', r(1, 0, 50, 0)), + ]); + expect(idx.size).toBe(3); + }); + }); + + describe('column handling on the same line', () => { + it('handles a single-line scope across columns', () => { + const idx = buildPositionIndex([ + mkScope('scope:expr', 'a.ts', 'Expression', r(5, 10, 5, 20)), + ]); + expect(idx.atPosition('a.ts', 5, 10)).toBe('scope:expr'); // start inclusive + expect(idx.atPosition('a.ts', 5, 15)).toBe('scope:expr'); // middle + expect(idx.atPosition('a.ts', 5, 20)).toBe('scope:expr'); // end inclusive + expect(idx.atPosition('a.ts', 5, 9)).toBeUndefined(); + expect(idx.atPosition('a.ts', 5, 21)).toBeUndefined(); + }); + + it('handles nested scopes on the same line', () => { + const idx = buildPositionIndex([ + mkScope('scope:outer', 'a.ts', 'Expression', r(5, 0, 5, 30)), + mkScope('scope:inner', 'a.ts', 'Expression', r(5, 10, 5, 20), 'scope:outer'), + ]); + expect(idx.atPosition('a.ts', 5, 15)).toBe('scope:inner'); + expect(idx.atPosition('a.ts', 5, 5)).toBe('scope:outer'); + expect(idx.atPosition('a.ts', 5, 25)).toBe('scope:outer'); + }); + }); + + describe('robustness', () => { + it('deduplicates scopes with the same id', () => { + const s = mkScope('scope:dup', 'a.ts', 'Module', r(1, 0, 10, 0)); + const idx = buildPositionIndex([s, s, s]); + expect(idx.size).toBe(1); + expect(idx.atPosition('a.ts', 5, 0)).toBe('scope:dup'); + }); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/scope-id.test.ts b/gitnexus/test/unit/scope-resolution/scope-id.test.ts new file mode 100644 index 000000000..901c89868 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/scope-id.test.ts @@ -0,0 +1,81 @@ +/** + * Unit tests for `makeScopeId` (RFC #909 Ring 2 SHARED #912). + * + * Covers canonical shape, determinism across calls, string interning, + * and that different inputs produce different ids. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { makeScopeId, clearScopeIdInternPool, type Range, type ScopeKind } from 'gitnexus-shared'; + +const r = (startLine: number, startCol: number, endLine: number, endCol: number): Range => ({ + startLine, + startCol, + endLine, + endCol, +}); + +describe('makeScopeId', () => { + beforeEach(() => { + clearScopeIdInternPool(); + }); + + it('produces the canonical RFC §2.2 shape', () => { + const id = makeScopeId({ filePath: 'src/app.ts', range: r(1, 0, 100, 0), kind: 'Module' }); + expect(id).toBe('scope:src/app.ts#1:0-100:0:Module'); + }); + + it('encodes each ScopeKind verbatim in the id', () => { + const kinds: readonly ScopeKind[] = [ + 'Module', + 'Namespace', + 'Class', + 'Function', + 'Block', + 'Expression', + ]; + for (const kind of kinds) { + const id = makeScopeId({ filePath: 'f.ts', range: r(1, 0, 2, 0), kind }); + expect(id.endsWith(`:${kind}`)).toBe(true); + } + }); + + it('returns the SAME string reference for structurally identical inputs (interned)', () => { + const a = makeScopeId({ filePath: 'src/a.ts', range: r(5, 4, 10, 2), kind: 'Function' }); + const b = makeScopeId({ filePath: 'src/a.ts', range: r(5, 4, 10, 2), kind: 'Function' }); + expect(a).toBe(b); + // `Object.is` catches the same reference even for weird strings. + expect(Object.is(a, b)).toBe(true); + }); + + it('distinguishes ids that differ only by filePath', () => { + const a = makeScopeId({ filePath: 'src/a.ts', range: r(1, 0, 2, 0), kind: 'Module' }); + const b = makeScopeId({ filePath: 'src/b.ts', range: r(1, 0, 2, 0), kind: 'Module' }); + expect(a).not.toBe(b); + }); + + it('distinguishes ids that differ only by range', () => { + const a = makeScopeId({ filePath: 'f.ts', range: r(1, 0, 2, 0), kind: 'Function' }); + const b = makeScopeId({ filePath: 'f.ts', range: r(1, 0, 3, 0), kind: 'Function' }); + expect(a).not.toBe(b); + }); + + it('distinguishes ids that differ only by kind', () => { + const a = makeScopeId({ filePath: 'f.ts', range: r(1, 0, 2, 0), kind: 'Function' }); + const b = makeScopeId({ filePath: 'f.ts', range: r(1, 0, 2, 0), kind: 'Block' }); + expect(a).not.toBe(b); + }); + + it('is safe to call repeatedly (pure)', () => { + const inputs = { filePath: 'f.ts', range: r(1, 0, 5, 0), kind: 'Function' as const }; + const ids = Array.from({ length: 10 }, () => makeScopeId(inputs)); + expect(new Set(ids).size).toBe(1); + }); + + it('clearScopeIdInternPool drops the intern pool without changing id shape', () => { + const before = makeScopeId({ filePath: 'f.ts', range: r(1, 0, 2, 0), kind: 'Module' }); + clearScopeIdInternPool(); + const after = makeScopeId({ filePath: 'f.ts', range: r(1, 0, 2, 0), kind: 'Module' }); + expect(after).toBe(before); // same string value, canonical-by-construction + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/scope-tree.test.ts b/gitnexus/test/unit/scope-resolution/scope-tree.test.ts new file mode 100644 index 000000000..7173ba3fc --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/scope-tree.test.ts @@ -0,0 +1,299 @@ +/** + * Unit tests for `buildScopeTree` / `ScopeTree` (RFC #909 Ring 2 SHARED #912). + * + * Covers: empty tree, single-module tree, nested module→class→function, + * siblings, ancestors walk, children lookup, readonly surface, and all six + * invariant violations (non-module without parent, parent not found, parent + * doesn't contain child, siblings overlap, cross-file parent, duplicate id). + * Also confirms that a `ScopeTree` satisfies the `ScopeLookup` contract from + * #916 so `resolveTypeRef` can consume it directly. + */ + +import { describe, it, expect } from 'vitest'; +import { + buildScopeTree, + ScopeTreeInvariantError, + resolveTypeRef, + buildDefIndex, + buildQualifiedNameIndex, + type BindingRef, + type Range, + type Scope, + type ScopeId, + type ScopeKind, + type SymbolDefinition, +} from 'gitnexus-shared'; + +// ─── Test helpers ─────────────────────────────────────────────────────────── + +const r = (startLine: number, startCol: number, endLine: number, endCol: number): Range => ({ + startLine, + startCol, + endLine, + endCol, +}); + +interface ScopeFixture { + id: ScopeId; + parent: ScopeId | null; + kind: ScopeKind; + range: Range; + filePath?: string; + bindings?: Record; +} + +const mkScope = (f: ScopeFixture): Scope => ({ + id: f.id, + parent: f.parent, + kind: f.kind, + range: f.range, + filePath: f.filePath ?? 'src/test.ts', + bindings: new Map(Object.entries(f.bindings ?? {})), + ownedDefs: [], + imports: [], + typeBindings: new Map(), +}); + +// ─── Tests ────────────────────────────────────────────────────────────────── + +describe('buildScopeTree', () => { + describe('shape + lookup', () => { + it('builds an empty tree from no scopes', () => { + const tree = buildScopeTree([]); + expect(tree.size).toBe(0); + expect(tree.has('scope:missing')).toBe(false); + expect(tree.getScope('scope:missing')).toBeUndefined(); + expect(tree.getParent('scope:missing')).toBeUndefined(); + expect(tree.getChildren('scope:missing')).toEqual([]); + expect(tree.getAncestors('scope:missing')).toEqual([]); + }); + + it('round-trips a single module scope', () => { + const m = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 100, 0) }); + const tree = buildScopeTree([m]); + expect(tree.size).toBe(1); + expect(tree.has('scope:m')).toBe(true); + expect(tree.getScope('scope:m')).toBe(m); + expect(tree.getParent('scope:m')).toBeUndefined(); + expect(tree.getChildren('scope:m')).toEqual([]); + expect(tree.getAncestors('scope:m')).toEqual([]); + }); + + it('tracks parent/children for a nested module → class → function tree', () => { + const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 50, 0) }); + const cls = mkScope({ + id: 'scope:c', + parent: 'scope:m', + kind: 'Class', + range: r(5, 0, 40, 0), + }); + const fn = mkScope({ + id: 'scope:f', + parent: 'scope:c', + kind: 'Function', + range: r(10, 2, 30, 2), + }); + const tree = buildScopeTree([mod, cls, fn]); + + expect(tree.size).toBe(3); + expect(tree.getParent('scope:f')).toBe(cls); + expect(tree.getParent('scope:c')).toBe(mod); + expect(tree.getChildren('scope:m')).toEqual(['scope:c']); + expect(tree.getChildren('scope:c')).toEqual(['scope:f']); + expect(tree.getAncestors('scope:f')).toEqual(['scope:c', 'scope:m']); + expect(tree.getAncestors('scope:c')).toEqual(['scope:m']); + }); + + it('records multiple siblings in input order', () => { + const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 100, 0) }); + const fn1 = mkScope({ + id: 'scope:f1', + parent: 'scope:m', + kind: 'Function', + range: r(5, 0, 10, 0), + }); + const fn2 = mkScope({ + id: 'scope:f2', + parent: 'scope:m', + kind: 'Function', + range: r(15, 0, 20, 0), + }); + const fn3 = mkScope({ + id: 'scope:f3', + parent: 'scope:m', + kind: 'Function', + range: r(25, 0, 30, 0), + }); + const tree = buildScopeTree([mod, fn2, fn1, fn3]); // deliberately out of order + expect(tree.getChildren('scope:m')).toEqual(['scope:f2', 'scope:f1', 'scope:f3']); + }); + }); + + describe('ScopeLookup compatibility (#916)', () => { + it('resolveTypeRef can consume a ScopeTree directly', () => { + const userClass: SymbolDefinition = { + nodeId: 'def:User', + filePath: 'src/test.ts', + type: 'Class', + }; + const module = mkScope({ + id: 'scope:m', + parent: null, + kind: 'Module', + range: r(1, 0, 100, 0), + bindings: { User: [{ def: userClass, origin: 'local' }] }, + }); + const fn = mkScope({ + id: 'scope:f', + parent: 'scope:m', + kind: 'Function', + range: r(5, 0, 10, 0), + }); + + const tree = buildScopeTree([module, fn]); + const result = resolveTypeRef( + { rawName: 'User', declaredAtScope: 'scope:f', source: 'parameter-annotation' }, + { + scopes: tree, + defIndex: buildDefIndex([userClass]), + qualifiedNameIndex: buildQualifiedNameIndex([userClass]), + }, + ); + expect(result).toBe(userClass); + }); + }); + + describe('readonly surface', () => { + it('freezes children arrays', () => { + const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 50, 0) }); + const fn = mkScope({ + id: 'scope:f', + parent: 'scope:m', + kind: 'Function', + range: r(5, 0, 10, 0), + }); + const tree = buildScopeTree([mod, fn]); + const children = tree.getChildren('scope:m'); + expect(() => (children as unknown as ScopeId[]).push('x')).toThrow(); + }); + + it('freezes ancestor arrays', () => { + const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 50, 0) }); + const fn = mkScope({ + id: 'scope:f', + parent: 'scope:m', + kind: 'Function', + range: r(5, 0, 10, 0), + }); + const tree = buildScopeTree([mod, fn]); + const ancestors = tree.getAncestors('scope:f'); + expect(() => (ancestors as unknown as ScopeId[]).push('x')).toThrow(); + }); + }); + + describe('invariant violations', () => { + it('throws when a non-Module scope has a null parent', () => { + const orphan = mkScope({ + id: 'scope:f', + parent: null, + kind: 'Function', + range: r(1, 0, 5, 0), + }); + expect(() => buildScopeTree([orphan])).toThrowError(ScopeTreeInvariantError); + expect(() => buildScopeTree([orphan])).toThrowError(/Module/); + }); + + it('throws when a parent pointer references a scope not in the tree', () => { + const fn = mkScope({ + id: 'scope:f', + parent: 'scope:ghost', + kind: 'Function', + range: r(1, 0, 5, 0), + }); + expect(() => buildScopeTree([fn])).toThrowError(ScopeTreeInvariantError); + }); + + it('throws when a parent range does not strictly contain a child range', () => { + const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 10, 0) }); + const fn = mkScope({ + id: 'scope:f', + parent: 'scope:m', + kind: 'Function', + range: r(5, 0, 50, 0), // extends beyond the module + }); + expect(() => buildScopeTree([mod, fn])).toThrowError(ScopeTreeInvariantError); + expect(() => buildScopeTree([mod, fn])).toThrowError(/strictly contain/i); + }); + + it('rejects child ranges identical to the parent (not strictly contained)', () => { + const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 10, 0) }); + const fn = mkScope({ + id: 'scope:f', + parent: 'scope:m', + kind: 'Function', + range: r(1, 0, 10, 0), + }); + expect(() => buildScopeTree([mod, fn])).toThrowError(ScopeTreeInvariantError); + }); + + it('throws when sibling ranges overlap', () => { + const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 100, 0) }); + const a = mkScope({ + id: 'scope:a', + parent: 'scope:m', + kind: 'Function', + range: r(5, 0, 20, 0), + }); + const b = mkScope({ + id: 'scope:b', + parent: 'scope:m', + kind: 'Function', + range: r(15, 0, 30, 0), // overlaps with a + }); + expect(() => buildScopeTree([mod, a, b])).toThrowError(ScopeTreeInvariantError); + expect(() => buildScopeTree([mod, a, b])).toThrowError(/overlap/i); + }); + + it('accepts sibling ranges that merely touch at the boundary', () => { + const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 100, 0) }); + const a = mkScope({ + id: 'scope:a', + parent: 'scope:m', + kind: 'Block', + range: r(5, 0, 10, 0), + }); + const b = mkScope({ + id: 'scope:b', + parent: 'scope:m', + kind: 'Block', + range: r(10, 0, 15, 0), // touches a at 10:0 but does not overlap + }); + expect(() => buildScopeTree([mod, a, b])).not.toThrow(); + }); + + it('throws when parent and child live in different files', () => { + const mod = mkScope({ + id: 'scope:m', + parent: null, + kind: 'Module', + range: r(1, 0, 100, 0), + filePath: 'a.ts', + }); + const fn = mkScope({ + id: 'scope:f', + parent: 'scope:m', + kind: 'Function', + range: r(5, 0, 10, 0), + filePath: 'b.ts', + }); + expect(() => buildScopeTree([mod, fn])).toThrowError(ScopeTreeInvariantError); + expect(() => buildScopeTree([mod, fn])).toThrowError(/filePath/i); + }); + + it('throws on duplicate scope ids', () => { + const a = mkScope({ id: 'scope:dup', parent: null, kind: 'Module', range: r(1, 0, 10, 0) }); + const b = mkScope({ id: 'scope:dup', parent: null, kind: 'Module', range: r(1, 0, 10, 0) }); + expect(() => buildScopeTree([a, b])).toThrowError(ScopeTreeInvariantError); + }); + }); +});