mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-17 23:52:36 +00:00
* chore(shared): apply Ring 2 SHARED review follow-ups in one diff Aggregates all actionable follow-ups from the 9 Ring 2 SHARED PRs (#949–#963) before proceeding to Ring 2 PKG. No behavior changes; docstring edits, test refinements, and one structural cleanup. ## #913 (DefIndex / ModuleScopeIndex / QualifiedNameIndex) - Rename `freezeIndex` → `wrapIndex` across all three index builders. The old name implied `Object.freeze` on the wrapper, which we never applied; `wrapIndex` more accurately describes the lightweight readonly-interface wrap. Safety surface (frozen bucket arrays, frozen miss-empty array, readonly Maps) is unchanged. - Document in `buildModuleScopeIndex` JSDoc that callers must pre-normalize `filePath` keys (no path-separator canonicalization happens here). Prevents silent cross-platform misses. - Add an explicit hit-path freeze assertion in `qualified-name-index.test.ts` (the existing test covered only the miss-path `EMPTY` array). ## #914 (MethodDispatchIndex) - Differentiate the C3 and BFS test cases: both tests now use distinct MRO orderings so they prove the materializer stores whatever order the `computeMro` callback produces (not that C3 and BFS yield identical output). - Add `implementsOfCalls` counter in the first-write-wins test, and document the call-count contract in `MethodDispatchInput.implementsOf` JSDoc: `implementsOf` fires **per occurrence** in `input.owners` (not per unique owner); `computeMro` fires at most once per unique owner. Callers with expensive `implementsOf` implementations should pre-dedupe `owners`. ## #916 (resolveTypeRef) - Document the deliberate exclusion of `'Type'` from `TYPE_KINDS` (verified no extractor in `gitnexus/src/core/ingestion/` emits `type: 'Type'` for annotation-relevant symbols). - Rename the namespace-origin test from `'resolves ...'` to `'returns null for a namespace-origin binding whose def is not a type kind'`, matching the failure-case intent. ## #918 (shadow diff + aggregate) - Remove the partial re-export `export type { ShadowAgreement, ShadowDiff };` from `aggregate.ts` — it omitted `ShadowCallsite` and diverged from the top-level barrel. Consumers import all three from the `gitnexus-shared` entry point. - Fix the invalid `'wildcard'` evidence kind in `diff.test.ts` fixture (that kind is not a valid `ResolutionEvidence.kind`). Replaced with `'global-name'`, a real kind the test treats identically. ## #912 (ScopeTree / PositionIndex / makeScopeId) - Document the touching-boundary semantics on `PositionIndex.atPosition`: when siblings share a boundary point, the right (later-start) sibling wins per the existing innermost-wins sort contract. - Resolve the layer-inversion flagged by review: move `ScopeLookup` from `resolve-type-ref.ts` to `types.ts` (its natural home in the data-model layer). `scope-tree.ts` now imports `ScopeLookup` from `types.js` directly; the old re-export from `resolve-type-ref.ts` is removed per repo convention (`feedback_no_reexport`). Barrel export moved alongside. ## #917 (ClassRegistry / MethodRegistry / FieldRegistry) - Replace the dangling "try a name-match among class-like defs" comment in `lookupReceiverType` with explicit prose that callers must pre-resolve via `resolveTypeRef` if they want richer semantics. No behavior change — the function already returned `undefined` on ambiguous/missing qnames. - Fix `tieBreakKey.origin` default for pure Step-2 candidates. Type-binding-only hits no longer falsely inherit `'local'` from `ensureCandidate`'s neutral default; they now demote to `'import'` on their first type-binding hit, and only a later Step-1 lexical hit can upgrade them back to `'local'`. Keeps the Appendix B cascade faithful to the true origin. - Document `'global-name'` in `evidence.ts`: currently reserved for Ring 3's byName global index; `lookupCore` never emits it today. The weight stays live so `composeEvidence` remains exhaustive over the origin union. - Rename the mislabeled Step-7 test from `'confidence DESC is the primary key'` (which actually tested hard-shadow baseline) to `'inner scope shadows outer, yielding single result'`, and add a separate test that actually exercises multi-candidate confidence ordering (local vs wildcard at the same scope). ## Verification - `tsc --noEmit` clean (both `gitnexus-shared` and `gitnexus`) - `gitnexus-shared` build clean - Combined scope-resolution / model / shadow suite: **260/260 pass** (+1 from the new multi-candidate ordering test in #917) ## Not addressed (non-actionable) - #949 CI "failure with zero failing tests": pre-existing Swift Node 22 grammar flake unrelated to #910 scope. - #950: the two non-blocking findings were already addressed in follow-up commit `cbac32ba` (ParsedImport discriminated union + `ScopeId | null` on the two hooks). - #915: the five in-scope findings were already addressed in follow-up commit `54515a7e` (dead code, unused params, multi-hop docs, cap-hit test, stats granularity). - #915 LanguageProvider.resolveImportTarget signature divergence + `findDefById` O(F×D) perf: tracked separately as follow-up issues for the Ring 3 migration window. * chore(shared): address ce:review findings on the follow-up diff ce:review (interactive) on PR #964 surfaced two P2s and several P3s. This commit applies all `safe_auto` fixes + both manual tests in-line so the PR ships with a cleaner review trail. ## P2 fixes - **Complete `freezeIndex` → `wrapIndex` rename.** The prior commit renamed 3 of 5 sibling index files; `method-dispatch-index.ts` and `position-index.ts` still carried the old name. Now all 5 helpers use the consistent `wrapIndex` naming. (maintainability + project-standards reviewers both flagged this.) - **Add regression tests for the `recordTypeBindingHit` origin demotion.** The prior commit introduced the `tieBreakKey.origin = 'import'` demotion for Step-2-only candidates without a direct test. Added: - `registries.test.ts`: two Step-2-only siblings under the same interface, asserting deterministic DefId.localeCompare tie-break AND the stronger invariant that composeEvidence never emits a where-found signal for Step-2-only candidates (no `signals.origin`). - `position-index.test.ts`: touching-boundary test proving the right-sibling-wins rule documented in the new JSDoc. (testing + kieran-typescript + api-contract reviewers all flagged these gaps.) ## P3 fixes - Fix wrong comment in `recordTypeBindingHit` that claimed Step 1 could later upgrade a demoted origin. Step 1 runs BEFORE Step 2 — the actual upgrade path is Step 3 (`seedFromOwnerScopedContributor`). Comment now describes execution order correctly. - Fix inaccurate "re-exported there" comment in `index.ts`. `types.ts` *defines* ScopeLookup natively; it's not a re-export. Phrasing now says "defined in types.ts and exported from the type-export block above — not from this module." - Update stale `scope-tree.ts` file-header prose that still referenced `ScopeLookup` as living in #916/resolve-type-ref.ts. Now points to `./types.js` with a cross-ref to both #916 and #917 consumers. - Expand `atPosition` touching-boundary JSDoc to name the mechanism (backward scan through start-sorted array) so readers can trace the binary-search code to the claim. - Add breadcrumb to `aggregate.ts` module header pointing future readers to `./diff.ts` / the top-level barrel for `ShadowAgreement`, `ShadowCallsite`, and `ShadowDiff`. - Remove unnecessary non-null assertion in `recordTypeBindingHit`. Local `const existingMroDepth = ...` lets TS narrow to `number` in the else-branch, eliminating the `!` without behavior change. ## Verification - `tsc --noEmit` clean (both `gitnexus-shared` and `gitnexus`) - `gitnexus-shared` build clean - Combined scope-resolution / model / shadow suite: **262/262 pass** (+2 from the new origin-demotion + touching-boundary regression tests)
209 lines
8.8 KiB
TypeScript
209 lines
8.8 KiB
TypeScript
/**
|
|
* 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
|
|
});
|
|
|
|
it('returns the right (later-start) sibling when two siblings share a boundary point', () => {
|
|
// Legal touching-boundary scenario per ScopeTree's non-overlap rule:
|
|
// [5:0..10:0] and [10:0..15:0] meet at (10, 0) but do not overlap
|
|
// (rangesOverlap treats end == start as "touches, not overlaps").
|
|
// A query AT the shared point is contained by BOTH siblings; the
|
|
// innermost-wins comparator breaks the tie by start position ASC:
|
|
// the right sibling (starts at 10:0) is scanned first during the
|
|
// backward pass and wins. See `atPosition` JSDoc.
|
|
const idx = buildPositionIndex([
|
|
mkScope('scope:mod', 'a.ts', 'Module', r(1, 0, 100, 0)),
|
|
mkScope('scope:left', 'a.ts', 'Block', r(5, 0, 10, 0), 'scope:mod'),
|
|
mkScope('scope:right', 'a.ts', 'Block', r(10, 0, 15, 0), 'scope:mod'),
|
|
]);
|
|
expect(idx.atPosition('a.ts', 10, 0)).toBe('scope:right'); // shared boundary
|
|
expect(idx.atPosition('a.ts', 7, 0)).toBe('scope:left'); // inside left only
|
|
expect(idx.atPosition('a.ts', 12, 0)).toBe('scope:right'); // inside right only
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|
|
});
|