diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index b572716060..edacb12141 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -75,6 +75,13 @@ export type { QualifiedNameIndex } from './scope-resolution/qualified-name-index export { resolveTypeRef } from './scope-resolution/resolve-type-ref.js'; export type { ResolveTypeRefContext, ScopeLookup } from './scope-resolution/resolve-type-ref.js'; +// Method-dispatch materialized view over HeritageMap (RFC §3.1; Ring 2 SHARED #914) +export { buildMethodDispatchIndex } from './scope-resolution/method-dispatch-index.js'; +export type { + MethodDispatchIndex, + MethodDispatchInput, +} from './scope-resolution/method-dispatch-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/method-dispatch-index.ts b/gitnexus-shared/src/scope-resolution/method-dispatch-index.ts new file mode 100644 index 0000000000..050538db57 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/method-dispatch-index.ts @@ -0,0 +1,137 @@ +/** + * `MethodDispatchIndex` — materialized view of class hierarchies keyed by + * `DefId` (RFC §3.1; Ring 2 SHARED #914). + * + * Two O(1)-access maps used by `Registry.lookupMethod` and interface- + * dispatch callers: + * + * - `mroByOwnerDefId` : owner class → full MRO ancestor chain + * (excludes the owner itself, in per-language + * strategy order). + * - `implsByInterfaceDefId` : interface/trait → classes that implement it. + * + * **Not an MRO implementation.** The build function is a pure aggregator: it + * asks the caller (via `computeMro` and `implementsOf` callbacks) for the + * per-language answers and materializes the two-way index. MRO strategies + * live where they already do today (`model/resolve.ts § c3Linearize`, + * `languages/ruby.ts § selectDispatch`, etc.) — this index does not + * reimplement them. + * + * Why callbacks and not a shared strategy registry: the five strategies + * (Python C3, Ruby kind-aware, Java/Kotlin linear, Rust qualified-syntax, + * COBOL none) already exist in the CLI package and depend on the CLI's + * `HeritageMap` + `SemanticModel`. Pulling them into `gitnexus-shared` would + * require migrating both — out of scope for #914. Callbacks let the shared + * build stay pure while honoring existing strategies verbatim. + * + * Consumed by: #917 (`Registry.lookupMethod` MRO fast path, interface + * dispatch resolver). + */ + +import type { DefId } from './types.js'; + +// ─── Public contracts ─────────────────────────────────────────────────────── + +export interface MethodDispatchIndex { + /** + * Full MRO ancestor chain per owner class (excludes the owner itself). + * Order reflects the per-language strategy used by `computeMro`. + */ + readonly mroByOwnerDefId: ReadonlyMap; + /** Interfaces / traits → classes that implement them. */ + readonly implsByInterfaceDefId: ReadonlyMap; + + /** `mroByOwnerDefId.get`, with an empty frozen array on miss. */ + mroFor(ownerDefId: DefId): readonly DefId[]; + /** `implsByInterfaceDefId.get`, with an empty frozen array on miss. */ + implementorsOf(interfaceDefId: DefId): readonly DefId[]; +} + +export interface MethodDispatchInput { + /** + * Owner defs to index (classes, structs, traits, interfaces — any kind + * that can appear on the owner side of a method-dispatch graph). + */ + readonly owners: readonly DefId[]; + /** + * Return the full MRO ancestor chain for `ownerDefId`, **excluding the + * owner itself**, in the order dictated by the owner's language-specific + * MRO strategy. + * + * Contract: + * - Pure (no side effects). + * - Deterministic per input. + * - `undefined` not allowed — return `[]` when the owner has no parents. + */ + readonly computeMro: (ownerDefId: DefId) => readonly DefId[]; + /** + * Return the set of interface/trait defs that `ownerDefId` implements. + * Transitive inclusion (e.g., `implements` on a parent class) is the + * caller's choice — the build function simply inverts whatever is + * returned. + * + * Repeated IDs in the output are deduplicated automatically. + */ + readonly implementsOf: (ownerDefId: DefId) => readonly DefId[]; +} + +// ─── Builder ──────────────────────────────────────────────────────────────── + +export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDispatchIndex { + const mroByOwnerDefId = new Map(); + const implsBuilding = new Map(); + const implsSeen = new Map>(); + + for (const ownerId of input.owners) { + // First-write-wins on duplicate owner ids: a stable policy consistent + // with sibling indexes (#913 DefIndex / ModuleScopeIndex). + if (!mroByOwnerDefId.has(ownerId)) { + const chain = input.computeMro(ownerId); + mroByOwnerDefId.set(ownerId, Object.freeze(chain.slice())); + } + + for (const ifaceId of input.implementsOf(ownerId)) { + let seen = implsSeen.get(ifaceId); + if (seen === undefined) { + seen = new Set(); + implsSeen.set(ifaceId, seen); + } + if (seen.has(ownerId)) continue; + seen.add(ownerId); + + let bucket = implsBuilding.get(ifaceId); + if (bucket === undefined) { + bucket = []; + implsBuilding.set(ifaceId, bucket); + } + bucket.push(ownerId); + } + } + + const implsByInterfaceDefId = new Map(); + for (const [ifaceId, owners] of implsBuilding) { + implsByInterfaceDefId.set(ifaceId, Object.freeze(owners.slice())); + } + + return freezeIndex(mroByOwnerDefId, implsByInterfaceDefId); +} + +// ─── Internal ─────────────────────────────────────────────────────────────── + +const EMPTY: readonly DefId[] = Object.freeze([]); + +function freezeIndex( + mroByOwnerDefId: Map, + implsByInterfaceDefId: Map, +): MethodDispatchIndex { + return { + mroByOwnerDefId, + implsByInterfaceDefId, + mroFor(ownerDefId: DefId): readonly DefId[] { + return mroByOwnerDefId.get(ownerDefId) ?? EMPTY; + }, + implementorsOf(interfaceDefId: DefId): readonly DefId[] { + return implsByInterfaceDefId.get(interfaceDefId) ?? EMPTY; + }, + }; +} diff --git a/gitnexus/test/unit/scope-resolution/method-dispatch-index.test.ts b/gitnexus/test/unit/scope-resolution/method-dispatch-index.test.ts new file mode 100644 index 0000000000..bc4f758120 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/method-dispatch-index.test.ts @@ -0,0 +1,216 @@ +/** + * Unit tests for `buildMethodDispatchIndex` / `MethodDispatchIndex` + * (RFC #909 Ring 2 SHARED #914). + * + * Covers: empty input, single-inheritance chain, diamond inheritance (caller- + * determined MRO order), interface-only dispatch, multiple implementors, + * dedup, first-write-wins, C3 vs BFS strategy parity (both honored verbatim), + * readonly surface + frozen output. + */ + +import { describe, it, expect } from 'vitest'; +import { buildMethodDispatchIndex, type MethodDispatchInput, type DefId } from 'gitnexus-shared'; + +// ─── Test helpers ─────────────────────────────────────────────────────────── + +const input = ( + owners: readonly DefId[], + mroByOwner: Record, + implementsByOwner: Record = {}, +): MethodDispatchInput => ({ + owners, + computeMro: (owner) => mroByOwner[owner] ?? [], + implementsOf: (owner) => implementsByOwner[owner] ?? [], +}); + +// ─── Tests ────────────────────────────────────────────────────────────────── + +describe('buildMethodDispatchIndex', () => { + describe('empty / degenerate inputs', () => { + it('builds an empty index from no owners', () => { + const idx = buildMethodDispatchIndex(input([], {})); + expect(idx.mroByOwnerDefId.size).toBe(0); + expect(idx.implsByInterfaceDefId.size).toBe(0); + expect(idx.mroFor('anything')).toEqual([]); + expect(idx.implementorsOf('anything')).toEqual([]); + }); + + it('indexes an owner with no parents and no interfaces', () => { + const idx = buildMethodDispatchIndex(input(['def:A'], { 'def:A': [] })); + expect(idx.mroByOwnerDefId.size).toBe(1); + expect(idx.implsByInterfaceDefId.size).toBe(0); + expect(idx.mroFor('def:A')).toEqual([]); + }); + }); + + describe('MRO materialization (single / multi inheritance)', () => { + it('records a single-inheritance chain verbatim from the callback', () => { + // A extends B extends C + const idx = buildMethodDispatchIndex( + input(['def:A', 'def:B', 'def:C'], { + 'def:A': ['def:B', 'def:C'], + 'def:B': ['def:C'], + 'def:C': [], + }), + ); + expect(idx.mroFor('def:A')).toEqual(['def:B', 'def:C']); + expect(idx.mroFor('def:B')).toEqual(['def:C']); + expect(idx.mroFor('def:C')).toEqual([]); + }); + + it('records a C3 linearization verbatim (Python diamond)', () => { + // D(B, C) where B(A), C(A). Classical C3: D, B, C, A. + // Our index stores mro excluding self: [B, C, A]. + const idx = buildMethodDispatchIndex( + input(['def:D'], { 'def:D': ['def:B', 'def:C', 'def:A'] }), + ); + expect(idx.mroFor('def:D')).toEqual(['def:B', 'def:C', 'def:A']); + }); + + it('records a BFS linearization verbatim (Java-style first-wins)', () => { + // D extends B, C; B extends A; C extends A. BFS: B, C, A. + const idx = buildMethodDispatchIndex( + input(['def:D'], { 'def:D': ['def:B', 'def:C', 'def:A'] }), + ); + expect(idx.mroFor('def:D')).toEqual(['def:B', 'def:C', 'def:A']); + }); + + it('records a Ruby-style kind-aware ancestry verbatim', () => { + // class C prepend P1 prepend P2; include M1 include M2 + // ruby-mixin walk order (per callback): [P2, P1, M2, M1] + const idx = buildMethodDispatchIndex( + input(['def:C'], { 'def:C': ['def:P2', 'def:P1', 'def:M2', 'def:M1'] }), + ); + expect(idx.mroFor('def:C')).toEqual(['def:P2', 'def:P1', 'def:M2', 'def:M1']); + }); + + it('records an empty chain for Rust qualified-syntax owners', () => { + // Rust: no auto-MRO; callback returns [] + const idx = buildMethodDispatchIndex(input(['def:RustStruct'], { 'def:RustStruct': [] })); + expect(idx.mroFor('def:RustStruct')).toEqual([]); + }); + }); + + describe('implements inversion', () => { + it('inverts a single class → interface mapping', () => { + const idx = buildMethodDispatchIndex( + input(['def:Impl'], { 'def:Impl': [] }, { 'def:Impl': ['def:IFace'] }), + ); + expect(idx.implementorsOf('def:IFace')).toEqual(['def:Impl']); + }); + + it('aggregates multiple classes implementing the same interface', () => { + const idx = buildMethodDispatchIndex( + input( + ['def:A', 'def:B', 'def:C'], + { 'def:A': [], 'def:B': [], 'def:C': [] }, + { 'def:A': ['def:I'], 'def:B': ['def:I'], 'def:C': ['def:J'] }, + ), + ); + expect(idx.implementorsOf('def:I')).toEqual(['def:A', 'def:B']); + expect(idx.implementorsOf('def:J')).toEqual(['def:C']); + }); + + it('preserves iteration order of owners in each implementors bucket', () => { + const idx = buildMethodDispatchIndex( + input( + ['def:Z', 'def:Y', 'def:X'], + { 'def:Z': [], 'def:Y': [], 'def:X': [] }, + { 'def:Z': ['def:I'], 'def:Y': ['def:I'], 'def:X': ['def:I'] }, + ), + ); + expect(idx.implementorsOf('def:I')).toEqual(['def:Z', 'def:Y', 'def:X']); + }); + + it('deduplicates repeated (interface, owner) pairs within a single callback call', () => { + // Caller may legally return the same interface twice (e.g., a class that + // both `implements IFace` and inherits from a parent that also does). + const idx = buildMethodDispatchIndex( + input(['def:Impl'], { 'def:Impl': [] }, { 'def:Impl': ['def:I', 'def:I', 'def:I'] }), + ); + expect(idx.implementorsOf('def:I')).toEqual(['def:Impl']); + }); + + it('deduplicates when the same owner is listed in `owners` twice (first-write-wins)', () => { + // First-write-wins parity with sibling indexes; subsequent owner entries + // should not re-invoke callbacks for existing MRO, and should not create + // duplicate implementor entries. + let mroCalls = 0; + const impls: Record = { 'def:A': ['def:I'] }; + const idx = buildMethodDispatchIndex({ + owners: ['def:A', 'def:A'], + computeMro: (_) => { + mroCalls++; + return ['def:B']; + }, + implementsOf: (o) => impls[o] ?? [], + }); + expect(mroCalls).toBe(1); + expect(idx.mroFor('def:A')).toEqual(['def:B']); + expect(idx.implementorsOf('def:I')).toEqual(['def:A']); + }); + }); + + describe('lookup miss / safety surface', () => { + it('returns a frozen empty array on MRO miss', () => { + const idx = buildMethodDispatchIndex(input(['def:A'], { 'def:A': [] })); + const miss = idx.mroFor('def:Missing'); + expect(miss).toEqual([]); + expect(() => (miss as unknown as DefId[]).push('x')).toThrow(); + }); + + it('returns a frozen empty array on implementors miss', () => { + const idx = buildMethodDispatchIndex(input(['def:A'], { 'def:A': [] })); + const miss = idx.implementorsOf('def:Missing'); + expect(miss).toEqual([]); + expect(() => (miss as unknown as DefId[]).push('x')).toThrow(); + }); + + it('freezes stored MRO arrays (readonly surface)', () => { + const idx = buildMethodDispatchIndex(input(['def:A'], { 'def:A': ['def:B'] })); + const chain = idx.mroFor('def:A'); + expect(() => (chain as unknown as DefId[]).push('x')).toThrow(); + }); + + it('freezes stored implementors arrays (readonly surface)', () => { + const idx = buildMethodDispatchIndex( + input(['def:A'], { 'def:A': [] }, { 'def:A': ['def:I'] }), + ); + const impls = idx.implementorsOf('def:I'); + expect(() => (impls as unknown as DefId[]).push('x')).toThrow(); + }); + + it('isolates stored MRO from later mutation of the callback-returned array', () => { + const mutable = ['def:B', 'def:C']; + const idx = buildMethodDispatchIndex({ + owners: ['def:A'], + computeMro: () => mutable, + implementsOf: () => [], + }); + mutable.push('def:D'); + expect(idx.mroFor('def:A')).toEqual(['def:B', 'def:C']); + }); + }); + + describe('readonly surface', () => { + it('exposes `mroByOwnerDefId` as a read-only Map for direct iteration', () => { + const idx = buildMethodDispatchIndex( + input(['def:A', 'def:B'], { 'def:A': [], 'def:B': ['def:A'] }), + ); + const owners = Array.from(idx.mroByOwnerDefId.keys()).sort(); + expect(owners).toEqual(['def:A', 'def:B']); + }); + + it('exposes `implsByInterfaceDefId` as a read-only Map for direct iteration', () => { + const idx = buildMethodDispatchIndex( + input( + ['def:A', 'def:B'], + { 'def:A': [], 'def:B': [] }, + { 'def:A': ['def:I'], 'def:B': ['def:J'] }, + ), + ); + const keys = Array.from(idx.implsByInterfaceDefId.keys()).sort(); + expect(keys).toEqual(['def:I', 'def:J']); + }); + }); +});