GitNexus/gitnexus/test/unit/scope-resolution/scope-tree.test.ts
ManniX-ITA 1e80285c47
fix(scope-resolution): allow same-range Module-as-parent for top-level scopes (closes #1086) (#1087)
* fix(scope-resolution): allow same-range Module-as-parent for top-level scopes (closes #1086)

When a C# file consists of a single top-level `namespace_declaration` that
ends exactly at EOF (no trailing newline, no leading content outside the
namespace's `{}` body), tree-sitter-c-sharp 0.23.1 reports identical byte
ranges for `compilation_unit` and `namespace_declaration`. Pre-fix the
scope-extractor parent-finder relied on strict containment, so the Module
was popped off the stack and the Namespace ended up with `parent === null`
→ `ScopeTreeInvariantError: non-module-requires-parent` →
`extractParsedFile` swallowed the throw and the whole file was dropped
from the registry-primary path. Cross-file IMPORTS / CALLS edges
originating in or terminating at that file vanished.

Hit on three real-world `*.Designer.cs` files in PersistentWindows
(`HotKeyWindow.Designer.cs`, `LaunchProcess.Designer.cs`,
`DbKeySelect.Designer.cs`) — all have the byte signature
`<BOM><CRLF>namespace ... { ... }<EOF>` (last hex = `... 7D 0D 0A 7D`).

The fix is a single carve-out in the parent-validity contract: a `Module`
may parent a same-range non-`Module` child. The relationship stays
acyclic because the carve-out is direction-asymmetric — only Module-as-
outer parents a same-range non-Module, never the reverse.

Two coordinated changes:

* `gitnexus/src/core/ingestion/scope-extractor.ts` — `pass1BuildScopes`
  now consults a new `canParentScope` helper instead of
  `rangeStrictlyContains` directly. Sort tie-breaker added so a same-
  range Module always sorts before a non-Module candidate, ensuring the
  Module lands on the parent-stack first regardless of tree-sitter
  capture iteration order.

* `gitnexus-shared/src/scope-resolution/scope-tree.ts` — `buildScopeTree`'s
  `parent-must-contain-child` check now uses the same `canParentScope`
  carve-out so the validator agrees with the extractor on what a
  well-formed parent edge looks like. Error message updated to spell
  out the new contract.

`rangeStrictlyContains` keeps its strict semantics in both files —
position-index lookups, hook-side range comparisons, and other call
sites are unchanged.

* `gitnexus/test/fixtures/lang-resolution/csharp-namespace-as-root-no-trailing-newline/`
  — minimal regression fixture mirroring the PersistentWindows shape:
  both `Models/User.cs` and `App/Program.cs` end exactly on the closing
  `}` of their namespace with no trailing newline. The trigger is shape-
  driven, not size-driven, so the fixture stays small (~250 bytes total).
* New `csharp.test.ts` describe block: scope extraction completes for
  both files, and the cross-file `IMPORTS` edge resolves through the
  scope-resolution path with `reason: 'csharp-scope: using'`.
* `scope-tree.test.ts`: replaced the prior "rejects child ranges
  identical to the parent" case with three new ones — non-Module parent
  still rejected at equal range; Module-as-parent of a same-range non-
  Module accepted (the #1086 carve-out); Module-as-parent of another
  Module still rejected (the asymmetry guard).

* `npx vitest run test/unit/scope-resolution test/integration/resolvers`
  → 2514 passed / 77 skipped / 0 failed (52 test files).
* `npx tsc --noEmit` clean in both `gitnexus/` and `gitnexus-shared/`.
* End-to-end on PersistentWindows (after rebuilding the Docker image
  with this branch): 3 prior `scope extraction failed for *.Designer.cs`
  warnings → 0. Pre-fix index numbers will be re-checked here once the
  branch is built and indexed; the existing post-#1082 baseline is
  1113 nodes / 2987 edges / 39 clusters / 97 flows.

`canParentScope` is language-agnostic. Other languages whose query emits
`(compilation_unit) @scope.module` plus a single same-range top-level
scope can naturally hit the same byte shape on minimal files; this fix
applies to all of them uniformly.

Refs: #1086 (issue with full root-cause analysis + 4-case empirical
repro through `extractParsedFile`).

* refactor(scope-resolution): export canParentScope from gitnexus-shared

Addresses #1087 review (medium): the helper was previously duplicated
byte-for-byte in `scope-extractor.ts` and `scope-tree.ts`. Per DoD
"single source of truth in shared", the contract piece belongs in
gitnexus-shared (Ring 2 SHARED #912) and the consuming layer should
import it. Eliminates the silent-drift surface where a future edit
to one copy would produce extractor/validator disagreement on what
a well-formed parent edge looks like.

Changes:
- gitnexus-shared/src/scope-resolution/scope-tree.ts: add `export`
  to `canParentScope`.
- gitnexus-shared/src/index.ts: re-export `canParentScope`.
- gitnexus/src/core/ingestion/scope-extractor.ts: remove the local
  `canParentScope` definition (and its now-unused local copy of
  `rangeStrictlyContains`), import from `gitnexus-shared`. The local
  `rangesEqual` stays — it's still used in capture-anchor logic at
  two unrelated sites.

Validation (per DoD §4.4 — both CLI and web consumers verified):
- npx tsc --noEmit clean in gitnexus/ and gitnexus-shared/
- cd gitnexus-web && npx tsc -b --noEmit clean
- gitnexus-shared `npm run build` clean
- Targeted: vitest run test/unit/scope-resolution test/integration/resolvers
  → 2522 passed / 0 failed / 77 skipped (54 files)
- Full suite: vitest run → 7238 passed / 1 failed / 97 skipped.
  The single failure is `test/unit/ignore-service.test.ts > warns
  on EACCES but does not throw`, which cannot run when uid=0 (root
  bypasses POSIX permission checks). Pre-existing on this branch
  before the refactor; unrelated to scope-resolution.
2026-04-27 11:06:54 +01:00

345 lines
12 KiB
TypeScript

/**
* 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 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(/contain child/i);
});
it('rejects child ranges identical to a non-Module parent', () => {
// Same-range parent-child is only legal when the parent is the
// file's Module (the universal-outer carve-out — see the
// namespace-as-root case below). For non-Module parents (Namespace,
// Class, Function, Block, …) the strict-containment rule still holds.
const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 10, 0) });
const ns = mkScope({
id: 'scope:ns',
parent: 'scope:m',
kind: 'Namespace',
range: r(2, 0, 9, 0),
});
const cls = mkScope({
id: 'scope:c',
parent: 'scope:ns',
kind: 'Class',
range: r(2, 0, 9, 0), // same as ns
});
expect(() => buildScopeTree([mod, ns, cls])).toThrowError(ScopeTreeInvariantError);
expect(() => buildScopeTree([mod, ns, cls])).toThrowError(/contain child/i);
});
it('accepts a same-range non-Module child whose parent is the Module (issue #1086)', () => {
// Triggered by C# files consisting of a single top-level
// `namespace_declaration` that ends exactly at EOF (no trailing
// newline, no leading content): tree-sitter reports identical byte
// ranges for `compilation_unit` and `namespace_declaration`. The
// Module is the universal outer of any file-level scope by language
// semantics, so equal ranges should not break the parent chain when
// the parent is the Module.
const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 10, 0) });
const ns = mkScope({
id: 'scope:ns',
parent: 'scope:m',
kind: 'Namespace',
range: r(1, 0, 10, 0), // exactly equal to the Module
});
expect(() => buildScopeTree([mod, ns])).not.toThrow();
const tree = buildScopeTree([mod, ns]);
expect(tree.getParent('scope:ns' as ScopeId)?.id).toBe('scope:m');
expect(tree.getChildren('scope:m' as ScopeId)).toEqual(['scope:ns']);
});
it('still rejects same-range Module-as-parent of another Module', () => {
// The carve-out is asymmetric: only Module-as-outer parents a
// same-range non-Module. Module-Module at equal ranges is rejected
// because two Modules would imply two roots / cyclic structure.
const m1 = mkScope({ id: 'scope:m1', parent: null, kind: 'Module', range: r(0, 0, 10, 0) });
const m2 = mkScope({
id: 'scope:m2',
parent: 'scope:m1',
kind: 'Module',
range: r(0, 0, 10, 0),
});
expect(() => buildScopeTree([m1, m2])).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);
});
});
});