GitNexus/gitnexus/test/unit/scope-resolution/module-scope-index.test.ts
Gergő Magyar ac2012e5ed
feat(shared): DefIndex / ModuleScopeIndex / QualifiedNameIndex (#913, RFC #909 Ring 2 SHARED) (#958)
Three flat O(1) indexes + pure build functions over per-file artifacts.
Contract-only; no runtime behavior change yet — consumers (#917 Registry
lookups, #915 SCC finalize, #919 ScopeExtractor) wire in later.

Each index follows the same shape:
  - build function: flat input list → frozen immutable index
  - public interface: readonly Map + get/has/size accessors
  - first-write-wins on id/filePath collisions (upstream bug signal)
  - pure, side-effect-free, safe to call repeatedly

DefIndex — the global "what is this id?" lookup
  gitnexus-shared/src/scope-resolution/def-index.ts
  buildDefIndex(defs: readonly SymbolDefinition[]): DefIndex
    byId: ReadonlyMap<DefId, SymbolDefinition>
  Consumed by Registry.lookup (#917) to materialize DefId[] hits back to
  full SymbolDefinition records.

ModuleScopeIndex — `filePath → moduleScopeId` for cross-file hops
  gitnexus-shared/src/scope-resolution/module-scope-index.ts
  buildModuleScopeIndex(entries): ModuleScopeIndex
    byFilePath: ReadonlyMap<string, ScopeId>
  Consumed by the SCC finalize link pass (#915) to resolve
  ImportEdge.targetFile to a concrete module scope in constant time.

QualifiedNameIndex — cross-kind qualified-name fast path
  gitnexus-shared/src/scope-resolution/qualified-name-index.ts
  buildQualifiedNameIndex(defs: readonly SymbolDefinition[]): QualifiedNameIndex
    byQualifiedName: ReadonlyMap<string, readonly DefId[]>
  Returns DefId[] (not a single DefId) because partial classes, method
  overloads, and cross-kind collisions can legitimately share a
  qualifiedName. Callers filter by acceptedKinds at the lookup site.
  Consumed by Registry.lookup qualified fast path + resolveTypeRef
  dotted fallback (#916, #917).

Barrel re-exports added to gitnexus-shared/src/index.ts so consumers
import from 'gitnexus-shared' rather than deep paths.

Tests (gitnexus/test/unit/scope-resolution/, 23 total):
  def-index.test.ts (6):
    empty, single def, multiple distinct, first-write-wins collision,
    missing id returns undefined, byId direct iteration
  module-scope-index.test.ts (6):
    empty, single entry, multiple files, first-write-wins on duplicate
    filePath, missing returns undefined, byFilePath direct iteration
  qualified-name-index.test.ts (11):
    empty, single qnamed def, partial classes accumulate, input-order
    preservation, qname separation, skip undefined/empty qname, pair
    dedup, cross-kind indexing, frozen-empty-array on miss, direct
    iteration

Verification:
  - gitnexus-shared + gitnexus build clean (tsc + scripts/build.js)
  - test/unit/scope-resolution: 23/23 pass
  - model + shadow + scope-resolution combined: 129/129 pass
  - No runtime consumer wiring yet — indexes are standalone library
    functions that #915, #917, #919 will import when ready

Depends on #910 (SymbolDefinition, DefId, ScopeId types — already on main).
Unblocks #915 (finalize algorithm), #917 (Registry.lookup), #919
(ScopeExtractor materialization).
2026-04-18 15:59:34 +01:00

62 lines
2.2 KiB
TypeScript

/**
* Unit tests for `buildModuleScopeIndex` / `ModuleScopeIndex`
* (RFC #909 Ring 2 SHARED #913).
*/
import { describe, it, expect } from 'vitest';
import { buildModuleScopeIndex, type ModuleScopeEntry, type ScopeId } from 'gitnexus-shared';
const entry = (filePath: string, moduleScopeId: ScopeId): ModuleScopeEntry => ({
filePath,
moduleScopeId,
});
describe('buildModuleScopeIndex', () => {
it('builds an empty index from no entries', () => {
const idx = buildModuleScopeIndex([]);
expect(idx.size).toBe(0);
expect(idx.get('src/app.ts')).toBeUndefined();
expect(idx.has('src/app.ts')).toBe(false);
});
it('round-trips a single entry', () => {
const idx = buildModuleScopeIndex([entry('src/app.ts', 'scope:src/app.ts#1:0-100:0:Module')]);
expect(idx.size).toBe(1);
expect(idx.has('src/app.ts')).toBe(true);
expect(idx.get('src/app.ts')).toBe('scope:src/app.ts#1:0-100:0:Module');
});
it('stores distinct files under their own scopes', () => {
const entries: ModuleScopeEntry[] = [
entry('src/a.ts', 'scope:a'),
entry('src/b.ts', 'scope:b'),
entry('src/c.ts', 'scope:c'),
];
const idx = buildModuleScopeIndex(entries);
expect(idx.size).toBe(3);
expect(idx.get('src/a.ts')).toBe('scope:a');
expect(idx.get('src/b.ts')).toBe('scope:b');
expect(idx.get('src/c.ts')).toBe('scope:c');
});
it('first-write-wins when the same filePath appears twice', () => {
const idx = buildModuleScopeIndex([
entry('src/app.ts', 'scope:first'),
entry('src/app.ts', 'scope:second'),
]);
expect(idx.size).toBe(1);
expect(idx.get('src/app.ts')).toBe('scope:first');
});
it('returns undefined for a missing filePath (no throw)', () => {
const idx = buildModuleScopeIndex([entry('src/a.ts', 'scope:a')]);
expect(idx.get('src/missing.ts')).toBeUndefined();
expect(idx.has('src/missing.ts')).toBe(false);
});
it('exposes byFilePath as the underlying read-only Map', () => {
const idx = buildModuleScopeIndex([entry('src/a.ts', 'scope:a'), entry('src/b.ts', 'scope:b')]);
const paths = Array.from(idx.byFilePath.keys()).sort();
expect(paths).toEqual(['src/a.ts', 'src/b.ts']);
});
});