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
7 changes: 7 additions & 0 deletions gitnexus-shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
137 changes: 137 additions & 0 deletions gitnexus-shared/src/scope-resolution/method-dispatch-index.ts
Original file line number Diff line number Diff line change
@@ -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<DefId, readonly DefId[]>;
/** Interfaces / traits → classes that implement them. */
readonly implsByInterfaceDefId: ReadonlyMap<DefId, readonly DefId[]>;

/** `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<DefId, readonly DefId[]>();
const implsBuilding = new Map<DefId, DefId[]>();
const implsSeen = new Map<DefId, Set<DefId>>();

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<DefId>();
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<DefId, readonly DefId[]>();
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<DefId, readonly DefId[]>,
implsByInterfaceDefId: Map<DefId, readonly DefId[]>,
): MethodDispatchIndex {
return {
mroByOwnerDefId,
implsByInterfaceDefId,
mroFor(ownerDefId: DefId): readonly DefId[] {
return mroByOwnerDefId.get(ownerDefId) ?? EMPTY;
},
implementorsOf(interfaceDefId: DefId): readonly DefId[] {
return implsByInterfaceDefId.get(interfaceDefId) ?? EMPTY;
},
};
}
216 changes: 216 additions & 0 deletions gitnexus/test/unit/scope-resolution/method-dispatch-index.test.ts
Original file line number Diff line number Diff line change
@@ -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<DefId, readonly DefId[]>,
implementsByOwner: Record<DefId, readonly DefId[]> = {},
): 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<DefId, readonly DefId[]> = { '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']);
});
});
});
Loading