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
5 changes: 4 additions & 1 deletion gitnexus-shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export type {
ParsedTypeBinding,
WorkspaceIndex,
Callsite,
ScopeLookup,
} from './scope-resolution/types.js';

// Evidence + tie-break constants (RFC Appendix A, Appendix B)
Expand All @@ -71,8 +72,10 @@ export { buildQualifiedNameIndex } from './scope-resolution/qualified-name-index
export type { QualifiedNameIndex } from './scope-resolution/qualified-name-index.js';

// Strict type-reference resolver (RFC §4.6; Ring 2 SHARED #916)
// `ScopeLookup` is defined in `./scope-resolution/types.js` and exported
// from the type-export block above — not from this module.
export { resolveTypeRef } from './scope-resolution/resolve-type-ref.js';
export type { ResolveTypeRefContext, ScopeLookup } from './scope-resolution/resolve-type-ref.js';
export type { ResolveTypeRefContext } 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';
Expand Down
4 changes: 2 additions & 2 deletions gitnexus-shared/src/scope-resolution/def-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,12 @@ export function buildDefIndex(defs: readonly SymbolDefinition[]): DefIndex {
if (byId.has(def.nodeId)) continue; // first-write-wins
byId.set(def.nodeId, def);
}
return freezeIndex(byId);
return wrapIndex(byId);
}

// ─── Internal ───────────────────────────────────────────────────────────────

function freezeIndex(byId: Map<DefId, SymbolDefinition>): DefIndex {
function wrapIndex(byId: Map<DefId, SymbolDefinition>): DefIndex {
return {
byId,
get size() {
Expand Down
12 changes: 10 additions & 2 deletions gitnexus-shared/src/scope-resolution/method-dispatch-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ export interface MethodDispatchInput {
* returned.
*
* Repeated IDs in the output are deduplicated automatically.
*
* **Call-count contract.** `implementsOf` is invoked **once per
* occurrence** of an owner in `input.owners`, not once per unique
* owner. Duplicate owners therefore re-invoke it; dedup happens at
* the bucket layer (after the callback returns). Callers with
* expensive `implementsOf` implementations should pass a deduplicated
* `owners` list. `computeMro`, by contrast, is memoized by the first-
* write-wins policy and fires at most once per unique owner.
*/
readonly implementsOf: (ownerDefId: DefId) => readonly DefId[];
}
Expand Down Expand Up @@ -113,14 +121,14 @@ export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDisp
implsByInterfaceDefId.set(ifaceId, Object.freeze(owners.slice()));
}

return freezeIndex(mroByOwnerDefId, implsByInterfaceDefId);
return wrapIndex(mroByOwnerDefId, implsByInterfaceDefId);
}

// ─── Internal ───────────────────────────────────────────────────────────────

const EMPTY: readonly DefId[] = Object.freeze([]);

function freezeIndex(
function wrapIndex(
mroByOwnerDefId: Map<DefId, readonly DefId[]>,
implsByInterfaceDefId: Map<DefId, readonly DefId[]>,
): MethodDispatchIndex {
Expand Down
12 changes: 10 additions & 2 deletions gitnexus-shared/src/scope-resolution/module-scope-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ export interface ModuleScopeEntry {
* entry preserves the first-stable id the rest of the pipeline may already
* have registered against.
*
* **Caller contract: filePath keys must be pre-normalized.** This index
* keys on the raw `filePath` string and does NOT canonicalize separators,
* case, or trailing slashes. Callers upstream of this function must agree
* on a canonical form (typically repo-root-relative, POSIX separators,
* no trailing slash) before constructing entries — otherwise `C:\foo\bar.ts`,
* `C:/foo/bar.ts`, and `foo/bar.ts` will all hash to distinct buckets and
* `get()` will miss.
*
* Pure function — safe to call repeatedly; no side effects.
*/
export function buildModuleScopeIndex(entries: readonly ModuleScopeEntry[]): ModuleScopeIndex {
Expand All @@ -44,12 +52,12 @@ export function buildModuleScopeIndex(entries: readonly ModuleScopeEntry[]): Mod
if (byFilePath.has(filePath)) continue; // first-write-wins
byFilePath.set(filePath, moduleScopeId);
}
return freezeIndex(byFilePath);
return wrapIndex(byFilePath);
}

// ─── Internal ───────────────────────────────────────────────────────────────

function freezeIndex(byFilePath: Map<string, ScopeId>): ModuleScopeIndex {
function wrapIndex(byFilePath: Map<string, ScopeId>): ModuleScopeIndex {
return {
byFilePath,
get size() {
Expand Down
16 changes: 14 additions & 2 deletions gitnexus-shared/src/scope-resolution/position-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ export interface PositionIndex {
* Innermost scope containing `(line, col)` in `filePath`, or `undefined`
* when nothing contains it (position before file start, after file end,
* or filePath not indexed).
*
* **Touching-boundary semantics.** Ranges are inclusive on both ends.
* When two sibling scopes share a boundary point — e.g.
* `[5:0, 10:0]` and `[10:0, 15:0]`, which is legal under `ScopeTree`'s
* non-overlap invariant — a query at the shared point `(10, 0)` is
* contained by **both**. The innermost-wins tie-break rule applies as
* usual: since neither is nested inside the other, the one that
* **starts latest** wins, i.e. the **right** sibling. The mechanism
* is the backward scan through the start-position-sorted array (see
* `findLastStartLteIndex` below) — both siblings land before the
* upper-bound cursor, and the right sibling is scanned first. Queries at non-boundary positions between them naturally
* fall to the unique containing scope.
*/
atPosition(filePath: string, line: number, col: number): ScopeId | undefined;
}
Expand Down Expand Up @@ -69,7 +81,7 @@ export function buildPositionIndex(scopes: readonly Scope[]): PositionIndex {
bucket.sort(compareEntry);
}

return freezeIndex(entriesByFile, seen.size);
return wrapIndex(entriesByFile, seen.size);
}

// ─── Internals ──────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -128,7 +140,7 @@ function findLastStartLteIndex(arr: readonly Entry[], line: number, col: number)
return lo - 1;
}

function freezeIndex(entriesByFile: Map<string, Entry[]>, size: number): PositionIndex {
function wrapIndex(entriesByFile: Map<string, Entry[]>, size: number): PositionIndex {
return {
get size() {
return size;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,14 @@ export function buildQualifiedNameIndex(defs: readonly SymbolDefinition[]): Qual
frozen.set(k, Object.freeze(v.slice()));
}

return freezeIndex(frozen);
return wrapIndex(frozen);
}

// ─── Internal ───────────────────────────────────────────────────────────────

const EMPTY: readonly DefId[] = Object.freeze([]);

function freezeIndex(byQualifiedName: Map<string, readonly DefId[]>): QualifiedNameIndex {
function wrapIndex(byQualifiedName: Map<string, readonly DefId[]>): QualifiedNameIndex {
return {
byQualifiedName,
get size() {
Expand Down
5 changes: 5 additions & 0 deletions gitnexus-shared/src/scope-resolution/registries/evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,11 @@ function getOriginWeight(origin: NonNullable<RawSignals['origin']>): number {
case 'global-qualified':
return EvidenceWeights.globalQualified;
case 'global-name':
// Reserved for Ring 3 byName global index. `lookupCore` today only
// emits `'global-qualified'` (via `lookupQualified`, dotted-name
// fallback); no code path constructs `origin: 'global-name'` yet.
// Kept here so the Appendix A weight stays live and `composeEvidence`
// remains exhaustive over the origin union.
return EvidenceWeights.globalName;
}
}
Expand Down
28 changes: 21 additions & 7 deletions gitnexus-shared/src/scope-resolution/registries/lookup-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,8 +318,9 @@ function lookupReceiverType(
// callers pre-resolve if they want the richer semantics.
const candidateIds = ctx.qualifiedNames.get(typeRef.rawName);
if (candidateIds.length === 1) return candidateIds[0];
// If ambiguous or missing, try a name-match among class-like defs —
// but only when the rawName has no dots (simple name).
// Ambiguous (≥ 2) or missing (0) — caller must pre-resolve via
// `resolveTypeRef` (#916) if they want the richer semantics. We
// intentionally do NOT re-implement a simple-name fallback here.
return undefined;
}
currentId = scope.parent;
Expand Down Expand Up @@ -358,17 +359,30 @@ function recordTypeBindingHit(
receiverOwner: DefId,
): void {
const state = ensureCandidate(perCandidate, def);
// Only replace if this hit is shallower (smaller MRO depth).
if (
state.signals.typeBindingMroDepth === undefined ||
mroDepth < state.signals.typeBindingMroDepth
) {
const existingMroDepth = state.signals.typeBindingMroDepth;
const firstHit = existingMroDepth === undefined;
// Only replace if this hit is shallower (smaller MRO depth). The local
// const lets TS narrow to `number` in the `else` branch so no `!`
// assertion is needed.
if (firstHit || mroDepth < existingMroDepth) {
state.signals.typeBindingMroDepth = mroDepth;
state.tieBreakKey.mroDepth = mroDepth;
}
if (def.ownerId === receiverOwner) {
state.signals.ownerMatch = true;
}
// Pure type-binding candidates (no lexical hit) would otherwise keep the
// `ensureCandidate` default `tieBreakKey.origin === 'local'`, making the
// Appendix B cascade lump them with local-origin candidates. Demote them
// to `'import'` — the strongest non-local origin — only when no earlier
// phase set an origin for this candidate. Lexical hits from Step 1 set
// `signals.origin` before Step 2 runs, so the guard skips them; Step 3
// (`seedFromOwnerScopedContributor`) runs AFTER Step 2 and unconditionally
// overrides `tieBreakKey.origin` back to `'local'` for direct-owner
// members, so any same-def overlap still ends up ranked correctly.
if (firstHit && state.signals.origin === undefined) {
state.tieBreakKey.origin = 'import';
}
}

// ─── Step 3 implementation ─────────────────────────────────────────────────
Expand Down
18 changes: 7 additions & 11 deletions gitnexus-shared/src/scope-resolution/resolve-type-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,22 +38,12 @@

import type { NodeLabel } from '../graph/types.js';
import type { SymbolDefinition } from './symbol-definition.js';
import type { BindingRef, Scope, ScopeId, TypeRef } from './types.js';
import type { BindingRef, ScopeId, ScopeLookup, TypeRef } from './types.js';
import type { DefIndex } from './def-index.js';
import type { QualifiedNameIndex } from './qualified-name-index.js';

// ─── Public contracts ───────────────────────────────────────────────────────

/**
* Minimal scope-lookup contract required by `resolveTypeRef`. Implemented by
* the `ScopeTree` from #912; declared here so #916 can ship as a standalone
* piece without a hard dependency on the full scope-tree implementation. Any
* structure that hands back a `Scope` by `ScopeId` satisfies this contract.
*/
export interface ScopeLookup {
getScope(id: ScopeId): Scope | undefined;
}

/**
* All inputs `resolveTypeRef` needs from the semantic model. Bundled into a
* context object so the call site stays short and the interface is stable as
Expand Down Expand Up @@ -83,6 +73,12 @@ const STRICT_ORIGINS: ReadonlySet<BindingRef['origin']> = new Set<BindingRef['or
* container, not a value type. `Function` / `Method` / `Variable` are
* excluded by design: a `rawName` bound to them at a strict origin is a
* *shadowing* binding, which the algorithm short-circuits to `null`.
*
* `'Type'` (the generic `NodeLabel` value) is also excluded — verified
* against `gitnexus/src/core/ingestion/` at the time of writing, no
* production extractor emits `type: 'Type'` for annotation-relevant
* symbols. Should a future extractor start emitting it, add `'Type'`
* here and add a test asserting the new path.
*/
const TYPE_KINDS: ReadonlySet<NodeLabel> = new Set<NodeLabel>([
'Class',
Expand Down
8 changes: 4 additions & 4 deletions gitnexus-shared/src/scope-resolution/scope-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@
* pointers would be a category error — a `File` scope is not the
* parent of another file's scopes; imports do that job.)
*
* Satisfies the `ScopeLookup` contract from #916 (`resolve-type-ref`), so
* `resolveTypeRef` can take a `ScopeTree` directly without adapters.
* Satisfies the `ScopeLookup` contract (defined in `./types.js`), so
* `resolveTypeRef` (#916) and the scope-aware registries (#917) can take a
* `ScopeTree` directly without adapters.
*
* Immutable surface: `byId` is a `ReadonlyMap`; children arrays are
* `Object.freeze`d; miss lookups return a shared frozen empty array.
*/

import type { Scope, ScopeId, Range } from './types.js';
import type { ScopeLookup } from './resolve-type-ref.js';
import type { Scope, ScopeId, ScopeLookup, Range } from './types.js';

// ─── Public contract ────────────────────────────────────────────────────────

Expand Down
7 changes: 5 additions & 2 deletions gitnexus-shared/src/scope-resolution/shadow/aggregate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
* Pure functions; no I/O. The harness persists per-run JSON; the dashboard
* reads `.gitnexus/shadow-parity/latest.json` and renders.
*
* Related types — `ShadowAgreement`, `ShadowCallsite`, `ShadowDiff` — are
* defined alongside `diffResolutions` in `./diff.ts` and re-exported
* through the top-level `gitnexus-shared` barrel. Consumers import all
* three from `gitnexus-shared`, not from this module.
*
* Part of RFC #909 Ring 2 SHARED — #918.
*/

Expand Down Expand Up @@ -181,5 +186,3 @@ function buildOverallRow(
const parity = resolved > 0 ? bothAgree / resolved : 0;
return { totalCalls, bothAgree, onlyLegacy, onlyNew, bothDisagree, bothEmpty, parity };
}

export type { ShadowAgreement, ShadowDiff };
12 changes: 12 additions & 0 deletions gitnexus-shared/src/scope-resolution/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,18 @@ export type WorkspaceIndex = unknown;
// The former opaque placeholder lived here during Ring 1; removed now that
// the concrete type exists. Consumers import from `gitnexus-shared` directly.

/**
* Minimal scope-lookup contract: map a `ScopeId` back to its `Scope` record.
*
* Lives in the data-model layer so both `ScopeTree` (§3.1) and
* `resolveTypeRef` / `Registry.lookup` (§4) can depend on it without
* inverting each other. `ScopeTree` is the canonical implementation;
* tests and future alternative containers may supply their own.
*/
export interface ScopeLookup {
getScope(id: ScopeId): Scope | undefined;
}

/** Call-site description passed to `arityCompatibility`. */
export interface Callsite {
/** Number of arguments at the call site. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,20 +59,25 @@ describe('buildMethodDispatchIndex', () => {
});

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].
// D(B, C) where B(A), C(A). Classical C3 keeps A last because the
// merge step defers A until both B and C have been emitted.
// 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.
// Same class hierarchy as the C3 case, but the BFS walker visits
// A before C via the B→A edge. Expected MRO differs from C3: [B, A, C].
// This test proves the materializer preserves whatever ordering the
// per-language `computeMro` callback produces — NOT that C3 and BFS
// produce identical output.
const idx = buildMethodDispatchIndex(
input(['def:D'], { 'def:D': ['def:B', 'def:C', 'def:A'] }),
input(['def:D'], { 'def:D': ['def:B', 'def:A', 'def:C'] }),
);
expect(idx.mroFor('def:D')).toEqual(['def:B', 'def:C', 'def:A']);
expect(idx.mroFor('def:D')).toEqual(['def:B', 'def:A', 'def:C']);
});

it('records a Ruby-style kind-aware ancestry verbatim', () => {
Expand Down Expand Up @@ -133,19 +138,33 @@ describe('buildMethodDispatchIndex', () => {

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.
// should not re-invoke `computeMro` for existing MRO, and should not
// create duplicate implementor entries.
//
// NOTE on `implementsOf` call count: the builder calls `implementsOf`
// ONCE PER OCCURRENCE of an owner in `input.owners`, not once per
// unique owner. Duplicate owners therefore re-invoke `implementsOf`;
// the dedup lives at the bucket layer (via `implsSeen`), not the
// callback layer. Callers with expensive `implementsOf` callbacks
// should dedupe `input.owners` upfront. This counter assertion pins
// that contract so a future refactor can't silently collapse the
// second call without updating the docstring.
let mroCalls = 0;
let implementsOfCalls = 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] ?? [],
implementsOf: (o) => {
implementsOfCalls++;
return impls[o] ?? [];
},
});
expect(mroCalls).toBe(1);
expect(mroCalls).toBe(1); // MRO dedup is at the callback layer (first-write-wins)
expect(implementsOfCalls).toBe(2); // implementsOf fires per occurrence; dedup at bucket
expect(idx.mroFor('def:A')).toEqual(['def:B']);
expect(idx.implementorsOf('def:I')).toEqual(['def:A']);
});
Expand Down
18 changes: 18 additions & 0 deletions gitnexus/test/unit/scope-resolution/position-index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,24 @@ describe('buildPositionIndex', () => {
expect(idx.atPosition('a.ts', 30, 0)).toBe('scope:b');
expect(idx.atPosition('a.ts', 22, 0)).toBe('scope:mod'); // gap between siblings
});

it('returns the right (later-start) sibling when two siblings share a boundary point', () => {
// Legal touching-boundary scenario per ScopeTree's non-overlap rule:
// [5:0..10:0] and [10:0..15:0] meet at (10, 0) but do not overlap
// (rangesOverlap treats end == start as "touches, not overlaps").
// A query AT the shared point is contained by BOTH siblings; the
// innermost-wins comparator breaks the tie by start position ASC:
// the right sibling (starts at 10:0) is scanned first during the
// backward pass and wins. See `atPosition` JSDoc.
const idx = buildPositionIndex([
mkScope('scope:mod', 'a.ts', 'Module', r(1, 0, 100, 0)),
mkScope('scope:left', 'a.ts', 'Block', r(5, 0, 10, 0), 'scope:mod'),
mkScope('scope:right', 'a.ts', 'Block', r(10, 0, 15, 0), 'scope:mod'),
]);
expect(idx.atPosition('a.ts', 10, 0)).toBe('scope:right'); // shared boundary
expect(idx.atPosition('a.ts', 7, 0)).toBe('scope:left'); // inside left only
expect(idx.atPosition('a.ts', 12, 0)).toBe('scope:right'); // inside right only
});
});

describe('multi-file isolation', () => {
Expand Down
Loading
Loading