From c1cd52fba9772e00eb9af7b56c83ba43655832b9 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sat, 18 Apr 2026 15:40:17 +0100 Subject: [PATCH] feat(shared): DefIndex / ModuleScopeIndex / QualifiedNameIndex (#913, RFC #909 Ring 2 SHARED) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 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 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). --- gitnexus-shared/src/index.ts | 8 ++ .../src/scope-resolution/def-index.ts | 62 +++++++++ .../scope-resolution/module-scope-index.ts | 65 ++++++++++ .../scope-resolution/qualified-name-index.ts | 92 +++++++++++++ .../unit/scope-resolution/def-index.test.ts | 69 ++++++++++ .../module-scope-index.test.ts | 62 +++++++++ .../qualified-name-index.test.ts | 122 ++++++++++++++++++ 7 files changed, 480 insertions(+) create mode 100644 gitnexus-shared/src/scope-resolution/def-index.ts create mode 100644 gitnexus-shared/src/scope-resolution/module-scope-index.ts create mode 100644 gitnexus-shared/src/scope-resolution/qualified-name-index.ts create mode 100644 gitnexus/test/unit/scope-resolution/def-index.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/module-scope-index.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/qualified-name-index.test.ts diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index 4dc93bc8f4..0f4bf8e299 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -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 { diff --git a/gitnexus-shared/src/scope-resolution/def-index.ts b/gitnexus-shared/src/scope-resolution/def-index.ts new file mode 100644 index 0000000000..bc27f773ed --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/def-index.ts @@ -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; + 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(); + 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): 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); + }, + }; +} diff --git a/gitnexus-shared/src/scope-resolution/module-scope-index.ts b/gitnexus-shared/src/scope-resolution/module-scope-index.ts new file mode 100644 index 0000000000..a71c5d3d74 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/module-scope-index.ts @@ -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; + 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(); + 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): 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); + }, + }; +} diff --git a/gitnexus-shared/src/scope-resolution/qualified-name-index.ts b/gitnexus-shared/src/scope-resolution/qualified-name-index.ts new file mode 100644 index 0000000000..64dbd9630e --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/qualified-name-index.ts @@ -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; + 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(); + const seenPairs = new Set(); + + 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(); + 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): 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); + }, + }; +} diff --git a/gitnexus/test/unit/scope-resolution/def-index.test.ts b/gitnexus/test/unit/scope-resolution/def-index.test.ts new file mode 100644 index 0000000000..3b13d4c2dc --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/def-index.test.ts @@ -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 => ({ + 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']); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/module-scope-index.test.ts b/gitnexus/test/unit/scope-resolution/module-scope-index.test.ts new file mode 100644 index 0000000000..08bc29ac1e --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/module-scope-index.test.ts @@ -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']); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/qualified-name-index.test.ts b/gitnexus/test/unit/scope-resolution/qualified-name-index.test.ts new file mode 100644 index 0000000000..77fc5ecc76 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/qualified-name-index.test.ts @@ -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 => ({ + 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']); + }); +});