mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
* 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.
345 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|