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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user