feat(shared): ScopeTree + PositionIndex + makeScopeId (#912, RFC #909 Ring 2 SHARED) (#961)

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.
This commit is contained in:
Gergő Magyar
2026-04-18 16:41:38 +01:00
committed by GitHub
parent ac148612ab
commit 8cf9ae0e0d
8 changed files with 1047 additions and 7 deletions
+8 -1
View File
@@ -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 {
@@ -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<string, Entry[]>();
const seen = new Set<ScopeId>();
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<string, Entry[]>, 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;
},
};
}
@@ -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<ScopeId, ...>` 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<string, string>();
@@ -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<ScopeId, Scope>;
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<ScopeId, Scope>();
const childrenById = new Map<ScopeId, ScopeId[]>();
// ── 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<ScopeId, readonly ScopeId[]>();
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<ScopeId, Scope>,
childrenById: Map<ScopeId, readonly ScopeId[]>,
): 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<ScopeId>([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}`;
}
@@ -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 {
@@ -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');
});
});
});
@@ -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
});
});
@@ -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<string, readonly BindingRef[]>;
}
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);
});
});
});