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).
This commit is contained in:
Gergő Magyar 2026-04-18 15:59:34 +01:00 committed by GitHub
parent f73389eac3
commit ac2012e5ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 480 additions and 0 deletions

View file

@ -63,6 +63,14 @@ export {
} from './scope-resolution/language-classification.js';
export type { LanguageClassification } from './scope-resolution/language-classification.js';
// Core indexes over per-file artifacts (RFC §3.1; Ring 2 SHARED #913)
export { buildDefIndex } from './scope-resolution/def-index.js';
export type { DefIndex } from './scope-resolution/def-index.js';
export { buildModuleScopeIndex } from './scope-resolution/module-scope-index.js';
export type { ModuleScopeIndex, ModuleScopeEntry } from './scope-resolution/module-scope-index.js';
export { buildQualifiedNameIndex } from './scope-resolution/qualified-name-index.js';
export type { QualifiedNameIndex } from './scope-resolution/qualified-name-index.js';
// Shadow-mode diff + aggregation (RFC §6.3; Ring 2 SHARED #918)
export { diffResolutions } from './scope-resolution/shadow/diff.js';
export type {

View file

@ -0,0 +1,62 @@
/**
* `DefIndex` O(1) `DefId → SymbolDefinition` materialization.
*
* The global "what is this id?" lookup. Every per-kind registry (ClassRegistry,
* MethodRegistry, FieldRegistry) returns `DefId[]` and resolves them back to
* full `SymbolDefinition` records through this index one central hash map,
* one allocation per def.
*
* Part of RFC #909 Ring 2 SHARED #913.
*
* Consumed by: #917 (`Registry.lookup` implementations), #915 (SCC finalize).
*/
import type { SymbolDefinition } from './symbol-definition.js';
import type { DefId } from './types.js';
export interface DefIndex {
readonly byId: ReadonlyMap<DefId, SymbolDefinition>;
readonly size: number;
get(id: DefId): SymbolDefinition | undefined;
has(id: DefId): boolean;
}
/**
* Build a `DefIndex` from a flat list of `SymbolDefinition` records.
*
* **Collision policy: first-write-wins.** `DefId` is meant to be unique
* (`nodeId` is the stable graph identifier), so a collision indicates an
* upstream bug most likely the same symbol parsed twice or a duplicate
* commit into the pipeline. Rather than silently overwriting with a later
* definition that may be partial or wrong, the first record wins and
* subsequent records for the same id are dropped. Pipeline bugs surface
* later as `has(id) === true` but the def looking older than expected,
* which is easier to debug than a silent overwrite.
*
* Pure function safe to call repeatedly; no side effects.
*/
export function buildDefIndex(defs: readonly SymbolDefinition[]): DefIndex {
const byId = new Map<DefId, SymbolDefinition>();
for (const def of defs) {
if (byId.has(def.nodeId)) continue; // first-write-wins
byId.set(def.nodeId, def);
}
return freezeIndex(byId);
}
// ─── Internal ───────────────────────────────────────────────────────────────
function freezeIndex(byId: Map<DefId, SymbolDefinition>): DefIndex {
return {
byId,
get size() {
return byId.size;
},
get(id: DefId): SymbolDefinition | undefined {
return byId.get(id);
},
has(id: DefId): boolean {
return byId.has(id);
},
};
}

View file

@ -0,0 +1,65 @@
/**
* `ModuleScopeIndex` O(1) `filePath → moduleScopeId` lookup.
*
* Every file parsed produces exactly one `Module` scope at its root. The
* finalize algorithm needs to resolve `ImportEdge.targetFile` to a concrete
* module scope id in constant time during the link pass; this index is that
* mapping.
*
* Part of RFC #909 Ring 2 SHARED #913.
*
* Consumed by: #915 (SCC finalize link pass), #923 (shadow harness when
* resolving callsite file enclosing module).
*/
import type { ScopeId } from './types.js';
export interface ModuleScopeIndex {
readonly byFilePath: ReadonlyMap<string, ScopeId>;
readonly size: number;
get(filePath: string): ScopeId | undefined;
has(filePath: string): boolean;
}
export interface ModuleScopeEntry {
readonly filePath: string;
readonly moduleScopeId: ScopeId;
}
/**
* Build a `ModuleScopeIndex` from a flat list of `{ filePath, moduleScopeId }`
* pairs.
*
* **Collision policy: first-write-wins.** A file should appear exactly once
* in a single ingestion run; collisions indicate the same file was parsed
* twice or a `filePath` normalization bug upstream. Dropping the later
* entry preserves the first-stable id the rest of the pipeline may already
* have registered against.
*
* Pure function safe to call repeatedly; no side effects.
*/
export function buildModuleScopeIndex(entries: readonly ModuleScopeEntry[]): ModuleScopeIndex {
const byFilePath = new Map<string, ScopeId>();
for (const { filePath, moduleScopeId } of entries) {
if (byFilePath.has(filePath)) continue; // first-write-wins
byFilePath.set(filePath, moduleScopeId);
}
return freezeIndex(byFilePath);
}
// ─── Internal ───────────────────────────────────────────────────────────────
function freezeIndex(byFilePath: Map<string, ScopeId>): ModuleScopeIndex {
return {
byFilePath,
get size() {
return byFilePath.size;
},
get(filePath: string): ScopeId | undefined {
return byFilePath.get(filePath);
},
has(filePath: string): boolean {
return byFilePath.has(filePath);
},
};
}

View file

@ -0,0 +1,92 @@
/**
* `QualifiedNameIndex` O(1) `qualifiedName → DefId[]` lookup across all kinds.
*
* Cross-kind fast path for qualified-name resolution
* (`lookupQualified(qname, scope, params)` in RFC §4.5). Class, method,
* field, and namespace defs all contribute to a single index here; consumers
* filter the returned `DefId[]` by `p.acceptedKinds` at the call site.
*
* Returns `DefId[]` (not a single `DefId`) because multiple defs can legally
* share a qualified name partial classes in C#, method overloads, or
* accidental cross-kind collisions. The lookup caller filters to the expected
* kind(s) and ranks the survivors.
*
* Part of RFC #909 Ring 2 SHARED #913.
*
* Consumed by: #917 (`Registry.lookup` qualified fast path, `resolveTypeRef`
* dotted fallback via #916).
*/
import type { SymbolDefinition } from './symbol-definition.js';
import type { DefId } from './types.js';
export interface QualifiedNameIndex {
readonly byQualifiedName: ReadonlyMap<string, readonly DefId[]>;
readonly size: number;
/** Returns all `DefId`s registered under this qualified name; empty frozen
* array on miss so callers can iterate without null checks. */
get(qualifiedName: string): readonly DefId[];
has(qualifiedName: string): boolean;
}
/**
* Build a `QualifiedNameIndex` from a flat list of `SymbolDefinition` records.
*
* Only defs with a non-empty `qualifiedName` contribute; defs without one are
* silently skipped (not every kind carries a qualified name anonymous or
* top-level symbols, dynamic-unresolved imports, etc.).
*
* **Duplicate policy: appended in input order.** Each unique `(qname, DefId)`
* pair contributes at most once repeated entries for the same pair are
* deduplicated. Distinct `DefId`s sharing a `qname` accumulate in insertion
* order (stable output for deterministic lookup ranking at the call site).
*
* Pure function safe to call repeatedly; no side effects.
*/
export function buildQualifiedNameIndex(defs: readonly SymbolDefinition[]): QualifiedNameIndex {
const byQualifiedName = new Map<string, DefId[]>();
const seenPairs = new Set<string>();
for (const def of defs) {
const qname = def.qualifiedName;
if (qname === undefined || qname.length === 0) continue;
const pairKey = `${qname}\0${def.nodeId}`;
if (seenPairs.has(pairKey)) continue;
seenPairs.add(pairKey);
const bucket = byQualifiedName.get(qname);
if (bucket === undefined) {
byQualifiedName.set(qname, [def.nodeId]);
} else {
bucket.push(def.nodeId);
}
}
// Freeze bucket arrays so consumers can't mutate the index.
const frozen = new Map<string, readonly DefId[]>();
for (const [k, v] of byQualifiedName) {
frozen.set(k, Object.freeze(v.slice()));
}
return freezeIndex(frozen);
}
// ─── Internal ───────────────────────────────────────────────────────────────
const EMPTY: readonly DefId[] = Object.freeze([]);
function freezeIndex(byQualifiedName: Map<string, readonly DefId[]>): QualifiedNameIndex {
return {
byQualifiedName,
get size() {
return byQualifiedName.size;
},
get(qualifiedName: string): readonly DefId[] {
return byQualifiedName.get(qualifiedName) ?? EMPTY;
},
has(qualifiedName: string): boolean {
return byQualifiedName.has(qualifiedName);
},
};
}

View file

@ -0,0 +1,69 @@
/**
* Unit tests for `buildDefIndex` / `DefIndex` (RFC #909 Ring 2 SHARED #913).
*
* Covers: build-from-list, O(1) lookup contract, first-write-wins on
* duplicate `nodeId`, readonly surface.
*/
import { describe, it, expect } from 'vitest';
import { buildDefIndex, type SymbolDefinition } from 'gitnexus-shared';
const makeDef = (overrides: Partial<SymbolDefinition> = {}): SymbolDefinition => ({
nodeId: 'def:test',
filePath: 'src/test.ts',
type: 'Method',
...overrides,
});
describe('buildDefIndex', () => {
it('builds an empty index from an empty input', () => {
const idx = buildDefIndex([]);
expect(idx.size).toBe(0);
expect(idx.get('anything')).toBeUndefined();
expect(idx.has('anything')).toBe(false);
});
it('stores a single def and round-trips by nodeId', () => {
const def = makeDef({ nodeId: 'def:User.save' });
const idx = buildDefIndex([def]);
expect(idx.size).toBe(1);
expect(idx.has('def:User.save')).toBe(true);
expect(idx.get('def:User.save')).toBe(def); // reference identity
});
it('stores multiple defs under their distinct ids', () => {
const a = makeDef({ nodeId: 'def:A' });
const b = makeDef({ nodeId: 'def:B' });
const c = makeDef({ nodeId: 'def:C' });
const idx = buildDefIndex([a, b, c]);
expect(idx.size).toBe(3);
expect(idx.get('def:A')).toBe(a);
expect(idx.get('def:B')).toBe(b);
expect(idx.get('def:C')).toBe(c);
});
it('first-write-wins on duplicate nodeId', () => {
const first = makeDef({ nodeId: 'def:dup', returnType: 'Original' });
const second = makeDef({ nodeId: 'def:dup', returnType: 'Shadow' });
const idx = buildDefIndex([first, second]);
expect(idx.size).toBe(1);
expect(idx.get('def:dup')).toBe(first);
expect(idx.get('def:dup')?.returnType).toBe('Original');
});
it("returns undefined for a missing id (doesn't throw)", () => {
const idx = buildDefIndex([makeDef({ nodeId: 'def:A' })]);
expect(idx.get('def:missing')).toBeUndefined();
expect(idx.has('def:missing')).toBe(false);
});
it('exposes byId as the underlying read-only Map for direct iteration', () => {
const a = makeDef({ nodeId: 'def:A' });
const b = makeDef({ nodeId: 'def:B' });
const idx = buildDefIndex([a, b]);
const entries = Array.from(idx.byId.entries())
.map(([id]) => id)
.sort();
expect(entries).toEqual(['def:A', 'def:B']);
});
});

View file

@ -0,0 +1,62 @@
/**
* 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']);
});
});

View file

@ -0,0 +1,122 @@
/**
* Unit tests for `buildQualifiedNameIndex` / `QualifiedNameIndex`
* (RFC #909 Ring 2 SHARED #913).
*
* Covers: per-kind accumulation, multi-def-per-qname (partial classes /
* overloads), skipping defs without a qualifiedName, duplicate-pair dedup,
* and empty-bucket iteration guarantee.
*/
import { describe, it, expect } from 'vitest';
import { buildQualifiedNameIndex, type SymbolDefinition } from 'gitnexus-shared';
const makeDef = (overrides: Partial<SymbolDefinition> = {}): SymbolDefinition => ({
nodeId: 'def:test',
filePath: 'src/test.ts',
type: 'Class',
...overrides,
});
describe('buildQualifiedNameIndex', () => {
it('builds an empty index from no defs', () => {
const idx = buildQualifiedNameIndex([]);
expect(idx.size).toBe(0);
expect(idx.get('anything')).toEqual([]);
expect(idx.has('anything')).toBe(false);
});
it('indexes a single qualified-named def', () => {
const def = makeDef({ nodeId: 'def:app.User', qualifiedName: 'app.User' });
const idx = buildQualifiedNameIndex([def]);
expect(idx.size).toBe(1);
expect(idx.has('app.User')).toBe(true);
expect(idx.get('app.User')).toEqual(['def:app.User']);
});
it('accumulates distinct DefIds under the same qualified name (partial classes)', () => {
// C# partial classes: same qname, different files/nodeIds
const a = makeDef({
nodeId: 'def:app.User:Core',
qualifiedName: 'app.User',
filePath: 'src/User.Core.cs',
});
const b = makeDef({
nodeId: 'def:app.User:Api',
qualifiedName: 'app.User',
filePath: 'src/User.Api.cs',
});
const idx = buildQualifiedNameIndex([a, b]);
expect(idx.get('app.User')).toEqual(['def:app.User:Core', 'def:app.User:Api']);
});
it('preserves input order in the bucket', () => {
const a = makeDef({ nodeId: 'def:a', qualifiedName: 'app.Foo' });
const b = makeDef({ nodeId: 'def:b', qualifiedName: 'app.Foo' });
const c = makeDef({ nodeId: 'def:c', qualifiedName: 'app.Foo' });
const idx = buildQualifiedNameIndex([c, a, b]);
expect(idx.get('app.Foo')).toEqual(['def:c', 'def:a', 'def:b']);
});
it('separates defs that share a simple name but differ in qualifiedName', () => {
const appUser = makeDef({ nodeId: 'def:app.User', qualifiedName: 'app.User' });
const adminUser = makeDef({ nodeId: 'def:admin.User', qualifiedName: 'admin.User' });
const idx = buildQualifiedNameIndex([appUser, adminUser]);
expect(idx.get('app.User')).toEqual(['def:app.User']);
expect(idx.get('admin.User')).toEqual(['def:admin.User']);
});
it('skips defs that have no qualifiedName', () => {
const qnamed = makeDef({ nodeId: 'def:app.Foo', qualifiedName: 'app.Foo' });
const anon = makeDef({ nodeId: 'def:anon', qualifiedName: undefined });
const idx = buildQualifiedNameIndex([qnamed, anon]);
expect(idx.size).toBe(1);
expect(idx.get('app.Foo')).toEqual(['def:app.Foo']);
expect(idx.has('')).toBe(false);
});
it('skips defs with an empty-string qualifiedName', () => {
const empty = makeDef({ nodeId: 'def:empty', qualifiedName: '' });
const idx = buildQualifiedNameIndex([empty]);
expect(idx.size).toBe(0);
expect(idx.has('')).toBe(false);
});
it('deduplicates exact (qname, DefId) pairs when the same def appears twice in input', () => {
const def = makeDef({ nodeId: 'def:app.Foo', qualifiedName: 'app.Foo' });
const idx = buildQualifiedNameIndex([def, def]);
expect(idx.get('app.Foo')).toEqual(['def:app.Foo']); // not duplicated
});
it('indexes across heterogeneous kinds (Class + Method + Field may share qname convention)', () => {
const klass = makeDef({
nodeId: 'def:class:app.User',
type: 'Class',
qualifiedName: 'app.User',
});
const method = makeDef({
nodeId: 'def:method:app.User.save',
type: 'Method',
qualifiedName: 'app.User.save',
});
const idx = buildQualifiedNameIndex([klass, method]);
expect(idx.size).toBe(2);
expect(idx.get('app.User')).toEqual(['def:class:app.User']);
expect(idx.get('app.User.save')).toEqual(['def:method:app.User.save']);
});
it('returns a frozen empty array (not undefined) for misses so callers can iterate safely', () => {
const idx = buildQualifiedNameIndex([makeDef({ qualifiedName: 'app.Foo' })]);
const miss = idx.get('app.Missing');
expect(miss).toEqual([]);
expect(() => (miss as unknown as string[]).push('x')).toThrow();
});
it('exposes byQualifiedName as a read-only Map for direct iteration', () => {
const idx = buildQualifiedNameIndex([
makeDef({ nodeId: 'def:A', qualifiedName: 'app.A' }),
makeDef({ nodeId: 'def:B', qualifiedName: 'app.B' }),
]);
const names = Array.from(idx.byQualifiedName.keys()).sort();
expect(names).toEqual(['app.A', 'app.B']);
});
});