Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions gitnexus-shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
62 changes: 62 additions & 0 deletions gitnexus-shared/src/scope-resolution/def-index.ts
Original file line number Diff line number Diff line change
@@ -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);
},
};
}
65 changes: 65 additions & 0 deletions gitnexus-shared/src/scope-resolution/module-scope-index.ts
Original file line number Diff line number Diff line change
@@ -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);
},
};
}
92 changes: 92 additions & 0 deletions gitnexus-shared/src/scope-resolution/qualified-name-index.ts
Original file line number Diff line number Diff line change
@@ -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);
},
};
}
69 changes: 69 additions & 0 deletions gitnexus/test/unit/scope-resolution/def-index.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
62 changes: 62 additions & 0 deletions gitnexus/test/unit/scope-resolution/module-scope-index.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
Loading
Loading