mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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:
parent
f73389eac3
commit
ac2012e5ed
7 changed files with 480 additions and 0 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
62
gitnexus-shared/src/scope-resolution/def-index.ts
Normal file
62
gitnexus-shared/src/scope-resolution/def-index.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
65
gitnexus-shared/src/scope-resolution/module-scope-index.ts
Normal file
65
gitnexus-shared/src/scope-resolution/module-scope-index.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
92
gitnexus-shared/src/scope-resolution/qualified-name-index.ts
Normal file
92
gitnexus-shared/src/scope-resolution/qualified-name-index.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
69
gitnexus/test/unit/scope-resolution/def-index.test.ts
Normal file
69
gitnexus/test/unit/scope-resolution/def-index.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
122
gitnexus/test/unit/scope-resolution/qualified-name-index.test.ts
Normal file
122
gitnexus/test/unit/scope-resolution/qualified-name-index.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue