feat(shared): ClassRegistry / MethodRegistry / FieldRegistry + 7-step lookup (#917, RFC #909 Ring 2 SHARED) (#963)

Capstone of Ring 2 SHARED. Implements RFC §4 — the shared, scope-aware
resolution surface the rest of the semantic model feeds into.

## Modules (`gitnexus-shared/src/scope-resolution/registries/`)

  - `context.ts`         — `RegistryContext` bundling ScopeTree / DefIndex
                           / QualifiedNameIndex / ModuleScopeIndex /
                           MethodDispatchIndex + provider hooks.
                           Narrows Ring 1's opaque `RegistryContributor`
                           to concrete `OwnerScopedContributor`.
  - `tie-breaks.ts`      — `compareByConfidenceWithTiebreaks`, the RFC
                           Appendix B cascade: confidence DESC → scope
                           depth ASC → MRO depth ASC → ORIGIN_PRIORITY
                           ASC → DefId.localeCompare.
  - `evidence.ts`        — `composeEvidence(signals)` / `confidenceFromEvidence`.
                           Translates raw walk signals into the typed
                           `ResolutionEvidence[]` using authoritative
                           `EvidenceWeights`. No magic numbers.
  - `lookup-qualified.ts`— RFC §4.5. Qualified-name fast path consumed
                           by `resolveTypeRef` dotted fallback and by
                           Step 6 of lookup-core.
  - `lookup-core.ts`     — The 7-step canonical algorithm. Pure. Param-
                           eterized by `CoreLookupParams`.
  - `{class,method,field}-registry.ts`
                         — Thin wrappers over `lookupCore` that fix
                           `acceptedKinds` + `useReceiverTypeBinding` per
                           kind. `buildClassRegistry` / `buildMethodRegistry`
                           / `buildFieldRegistry` factory functions.

## RFC §4.2 algorithm contract (honored verbatim)

  1. Lexical scope-chain walk. Hard shadow on any `scope.bindings.has(name)`
     regardless of kind survivorship.
  2. Type-binding resolution (methods/fields only, opt-in via
     `useReceiverTypeBinding`). MRO walk via `MethodDispatchIndex.mroFor`.
     MRO-depth-decayed weight via `typeBindingWeightAtDepth`.
  3. Owner-scoped contributor — when the caller knows the receiver owner,
     its direct members merge in as `origin: 'local'`.
  4. Kind filter — `acceptedKinds` per registry; `kind-match` evidence
     at weight 0 is always emitted for debuggability.
  5. Arity filter — `provider.arityCompatibility` per candidate. When at
     least one compatible candidate exists, incompatibles are dropped;
     otherwise the −0.15 penalty alone disambiguates (they stay in the
     result, just ranked lower).
  6. Global fallback — fires only when Steps 1-3 produced NO candidates
     AND the name is dotted. Delegates to `lookupQualified`.
  7. Rank + tie-break — evidence list sorted by the Appendix B cascade.

## §4.7 invariants asserted in tests

  - No tier vocabulary in the return type (`Resolution`, not `TierXResult`).
  - Confidence is per-candidate (not per-tier).
  - Shadowing is a HARD filter; globals are consulted ONLY when lexically
    empty.
  - Caller can read `[0]` for one-shot answers.
  - `Resolution.confidence` is capped at 1.0.
  - `kind-match` is always emitted (weight 0).

## Unresolved-import + dynamic-unresolved evidence shape

  - `BindingRef.via.linkStatus === 'unresolved'` applies the
    `unlinkedImportMultiplier` (0.5×) to the where-found signal only.
    Corroborators (`arity-match`, `owner-match`, `type-binding`) remain
    unaffected — the RFC §4v2 capped-signal rule applies per-signal, not
    per-candidate.
  - `BindingRef.via.kind === 'dynamic-unresolved'` adds a degraded
    `dynamic-import-unresolved` evidence signal at weight 0.02.

## Tests (28 in registries.test.ts, 259/259 combined)

Organized per RFC §4.2 step so a regression localizes to the step it broke:

  - Step 1: local + walk-to-parent + hard-shadow + origin=import
  - Step 2: explicit receiver type-binding + MRO depth decay on ancestor
  - Step 3: owner-scoped contributor + owner-match
  - Step 5: drop-incompatible-when-compatible-exists + soft-penalty-when-all-
            incompatible + unknown-when-no-provider
  - Step 6: global-qualified fires only when lexically empty + never for
            non-dotted names + not consulted when lexical hit exists
  - Step 7: tie-break cascade (inner shadows outer; defId.localeCompare
            final)
  - Corroborators: unresolved-import 0.5× cap per-signal + dynamic-
            unresolved 0.02 degraded signal
  - §4.5: lookupQualified kind filter + empty on miss + deterministic defId
          order for partial classes
  - §4.7: invariants — confidence per-candidate, capped at 1.0, kind-match
          always present, [0]-for-one-shot

## Known follow-up optimizations

`collectOwnedMembers` in `lookup-core.ts` iterates `defs.byId.values()`
for each MRO hop — O(D) per call. Acceptable for Ring 2 fixtures; a
by-owner index should land before Ring 3 migrates large-workspace
languages. Tracked alongside the existing `findDefById` follow-up from
#915 review.

## Module placement

All under `gitnexus-shared/src/scope-resolution/registries/` — consistent
with the Ring 2 SHARED folder layout (#912/#913/#914/#915/#916/#918).
Slight deviation from the issue's `gitnexus-shared/src/registries/`
suggestion for consistency with siblings.

## Part of

- Parent: #909
- Depends on (code): #910, #911, #912, #913, #914, #915, #916, #918.
- Closes the Ring 2 SHARED delivery band. Unblocks Ring 2 PKG (#919–#925
  bridges to the gitnexus/ CLI package) and Ring 3 language migrations.
This commit is contained in:
Gergő Magyar 2026-04-18 17:58:26 +01:00 committed by GitHub
parent a9a5e1c388
commit 1bf9fb4ef1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1734 additions and 0 deletions

View file

@ -92,6 +92,38 @@ export type {
FinalizeStats,
} from './scope-resolution/finalize-algorithm.js';
// Scope-aware registries + 7-step lookup (RFC §4; Ring 2 SHARED #917)
export { buildClassRegistry } from './scope-resolution/registries/class-registry.js';
export type { ClassRegistry } from './scope-resolution/registries/class-registry.js';
export { buildMethodRegistry } from './scope-resolution/registries/method-registry.js';
export type {
MethodRegistry,
MethodLookupOptions,
} from './scope-resolution/registries/method-registry.js';
export { buildFieldRegistry } from './scope-resolution/registries/field-registry.js';
export type {
FieldRegistry,
FieldLookupOptions,
} from './scope-resolution/registries/field-registry.js';
export { lookupCore } from './scope-resolution/registries/lookup-core.js';
export type { CoreLookupParams } from './scope-resolution/registries/lookup-core.js';
export { lookupQualified } from './scope-resolution/registries/lookup-qualified.js';
export type { LookupQualifiedParams } from './scope-resolution/registries/lookup-qualified.js';
export { composeEvidence, confidenceFromEvidence } from './scope-resolution/registries/evidence.js';
export type { RawSignals } from './scope-resolution/registries/evidence.js';
export {
compareByConfidenceWithTiebreaks,
CONFIDENCE_EPSILON,
} from './scope-resolution/registries/tie-breaks.js';
export type { TieBreakKey } from './scope-resolution/registries/tie-breaks.js';
export { CLASS_KINDS, METHOD_KINDS, FIELD_KINDS } from './scope-resolution/registries/context.js';
export type {
RegistryContext,
RegistryProviders,
OwnerScopedContributor,
ArityVerdict,
} from './scope-resolution/registries/context.js';
// Scope tree spine + position lookup (RFC §2.2 + §3.1; Ring 2 SHARED #912)
export { makeScopeId, clearScopeIdInternPool } from './scope-resolution/scope-id.js';
export type { ScopeIdInput } from './scope-resolution/scope-id.js';

View file

@ -0,0 +1,41 @@
/**
* `ClassRegistry` scope-aware lookup for class-like symbols
* (RFC §4.4; Ring 2 SHARED #917).
*
* Thin wrapper over `lookupCore`, specialized for class kinds:
*
* - `acceptedKinds` = Class / Interface / Enum / Struct / Union /
* Trait / TypeAlias / Typedef / Record / Delegate / Annotation /
* Template / Namespace.
* - `useReceiverTypeBinding` is **false** classes are resolved by
* name through the lexical chain + global qualified fallback, not
* via a receiver type.
* - Arity filter is not applicable (classes are not called with
* argument counts at lookup time).
*/
import type { Resolution, ScopeId } from '../types.js';
import { lookupCore, type CoreLookupParams } from './lookup-core.js';
import { CLASS_KINDS, type RegistryContext } from './context.js';
export interface ClassRegistry {
/**
* Look up a class-like symbol by simple or dotted name anchored at
* `scope`. Returns a confidence-ranked `Resolution[]`; consume `[0]`
* for the best answer.
*/
lookup(name: string, scope: ScopeId): readonly Resolution[];
}
export function buildClassRegistry(ctx: RegistryContext): ClassRegistry {
const params: CoreLookupParams = {
acceptedKinds: CLASS_KINDS,
useReceiverTypeBinding: false,
ownerScopedContributor: null,
};
return {
lookup(name: string, scope: ScopeId) {
return lookupCore(name, scope, params, ctx);
},
};
}

View file

@ -0,0 +1,110 @@
/**
* `RegistryContext` the injected state required by the scope-aware
* registry lookups (RFC §4; Ring 2 SHARED #917).
*
* Bundles every Ring 2 index + every provider hook the 7-step algorithm
* might consult. Threaded through `lookupCore` and the three public
* registries unchanged; construction is the caller's responsibility
* (typically once per workspace-indexing pass in Ring 2 PKG).
*
* The design intent is **pure-logic in `gitnexus-shared`, data + hooks
* supplied by the caller**. Nothing here loads files, parses AST, or
* reaches into the CLI package.
*/
import type { NodeLabel } from '../../graph/types.js';
import type { SymbolDefinition } from '../symbol-definition.js';
import type { Callsite, DefId } from '../types.js';
import type { DefIndex } from '../def-index.js';
import type { QualifiedNameIndex } from '../qualified-name-index.js';
import type { ModuleScopeIndex } from '../module-scope-index.js';
import type { ScopeTree } from '../scope-tree.js';
import type { MethodDispatchIndex } from '../method-dispatch-index.js';
// ─── Provider hooks consumed by the registries ─────────────────────────────
export interface RegistryProviders {
/**
* Language-specific arity compatibility between a callsite and a candidate
* `def`. Mirrors `LanguageProvider.arityCompatibility` from #911. Optional:
* when absent, every candidate receives `'unknown'` (neutral signal).
*/
arityCompatibility?(callsite: Callsite, def: SymbolDefinition): ArityVerdict;
}
export type ArityVerdict = 'compatible' | 'unknown' | 'incompatible';
// ─── Owner-scoped contributor (concrete shape for `RegistryContributor`) ────
/**
* Per-owner membership view plugged into `LookupParams.ownerScopedContributor`.
*
* When the caller knows a receiver is of type `Owner` (e.g., after
* resolving an explicit receiver or via `self`), it can supply the
* `Owner`'s own member bucket here. `lookupCore` treats hits from this
* contributor as `origin: 'local'` inside the owner's body scope
* strongest-visibility evidence, unaffected by the scope-chain hop
* deduction that punishes outer-scope hits.
*
* Ring 1's `RegistryContributor = unknown` opaque placeholder is narrowed
* to this concrete shape here in Ring 2 SHARED (#917).
*/
export interface OwnerScopedContributor {
/** The owner (class/struct/trait/interface) that bounds this view. */
readonly ownerDefId: DefId;
/**
* Methods / fields directly declared on the owner, keyed by simple name.
* Return empty array on miss; implementations should NOT walk the MRO
* that's `MethodDispatchIndex`'s job, handled in the type-binding step.
*/
byName(name: string): readonly SymbolDefinition[];
}
// ─── Top-level context threaded through every lookup ───────────────────────
export interface RegistryContext {
readonly scopes: ScopeTree;
readonly defs: DefIndex;
readonly qualifiedNames: QualifiedNameIndex;
readonly moduleScopes: ModuleScopeIndex;
/**
* Method-dispatch index; required for method/field registries that
* honor `useReceiverTypeBinding`. Omit for class-only lookups.
*/
readonly methodDispatch?: MethodDispatchIndex;
readonly providers: RegistryProviders;
}
// ─── Per-kind default `acceptedKinds` sets ─────────────────────────────────
//
// Exported so the three public registries stay declarative (each one just
// points at the right constant + passes it to `lookupCore`).
export const CLASS_KINDS: readonly NodeLabel[] = Object.freeze([
'Class',
'Interface',
'Enum',
'Struct',
'Union',
'Trait',
'TypeAlias',
'Typedef',
'Record',
'Delegate',
'Annotation',
'Template',
'Namespace',
]);
export const METHOD_KINDS: readonly NodeLabel[] = Object.freeze([
'Method',
'Function',
'Constructor',
]);
export const FIELD_KINDS: readonly NodeLabel[] = Object.freeze([
'Variable',
'Property',
'Const',
'Static',
]);

View file

@ -0,0 +1,191 @@
/**
* `composeEvidence` translate accumulated raw signals per candidate
* into a `ResolutionEvidence[]` using the authoritative `EvidenceWeights`
* map (RFC §4.3 + Appendix A; Ring 2 SHARED #917).
*
* Each `RawSignals` record describes what was observed about a candidate
* during the 7-step walk: where it was found, at what depth, whether
* anything corroborates it. This module turns those raw facts into the
* typed evidence list attached to the outgoing `Resolution`.
*
* **Every weight comes from `EvidenceWeights`.** No inline magic numbers.
* Extends issue #429 (centralize hardcoded confidence values).
*
* **Confidence compose rule.** Signals add; the sum is capped at 1.0 at
* the call site (inside `lookupCore`). This module only emits the list;
* it does NOT compute the capped sum so callers can inspect per-signal
* contributions for debugging.
*/
import type { BindingRef, ResolutionEvidence } from '../types.js';
import { EvidenceWeights, typeBindingWeightAtDepth } from '../evidence-weights.js';
/**
* Raw signals observed for a single candidate during the 7-step walk.
* Optional fields encode "this signal did not fire"; presence encodes
* "emit an evidence record".
*/
export interface RawSignals {
// ── Where-found ────────────────────────────────────────────────────────
/** Visibility origin of the binding that produced this candidate. */
readonly origin?: BindingRef['origin'] | 'global-qualified' | 'global-name';
/** Depth at which the binding was found (hops up from start scope). */
readonly scopeChainDepth?: number;
/** `ImportEdge` that brought the name in; present when origin is a non-local. */
readonly viaUnlinkedImport?: boolean;
// ── Type-binding path ──────────────────────────────────────────────────
/** Set when the candidate came via the receiver's type-binding MRO walk. */
readonly typeBindingMroDepth?: number;
// ── Corroborators ──────────────────────────────────────────────────────
/** `def.ownerId === resolvedReceiver.def.nodeId`. */
readonly ownerMatch?: boolean;
/** Always fires for candidates that pass `acceptedKinds`; weight 0. */
readonly kindMatch: true;
// ── Arity ──────────────────────────────────────────────────────────────
readonly arityVerdict?: 'compatible' | 'unknown' | 'incompatible';
// ── Dynamic-unresolved passthrough ─────────────────────────────────────
/** Candidate flows through a `kind: 'dynamic-unresolved'` ImportEdge. */
readonly dynamicUnresolved?: boolean;
}
/**
* Compose the raw signals into a stable `ResolutionEvidence[]` list.
*
* Emission order mirrors the `EvidenceWeights` layout: where-found
* type-binding corroborators arity degraded. Stable order makes
* the per-signal contributions easy to reason about in tests and in the
* shadow-mode parity dashboard.
*/
export function composeEvidence(signals: RawSignals): readonly ResolutionEvidence[] {
const out: ResolutionEvidence[] = [];
// ── Where-found visibility ─────────────────────────────────────────────
if (signals.origin !== undefined) {
const baseWeight = getOriginWeight(signals.origin);
const capped = signals.viaUnlinkedImport
? baseWeight * EvidenceWeights.unlinkedImportMultiplier
: baseWeight;
const evidenceKind = whereFoundEvidenceKind(signals.origin);
out.push({
kind: evidenceKind,
weight: capped,
...(signals.viaUnlinkedImport
? { note: `via unresolved import (${EvidenceWeights.unlinkedImportMultiplier}× cap)` }
: {}),
});
}
// ── Scope-chain depth deduction (per-hop, only meaningful for lexical
// hits where scopeChainDepth ≥ 1). Depth 0 = no deduction; depth N ≥ 1
// emits a single `scope-chain` evidence with the accumulated penalty.
if (signals.scopeChainDepth !== undefined && signals.scopeChainDepth > 0) {
out.push({
kind: 'scope-chain',
weight: EvidenceWeights.scopeChainPerDepth * signals.scopeChainDepth,
note: `depth=${signals.scopeChainDepth}`,
});
}
// ── Type-binding / MRO path ────────────────────────────────────────────
if (signals.typeBindingMroDepth !== undefined) {
out.push({
kind: 'type-binding',
weight: typeBindingWeightAtDepth(signals.typeBindingMroDepth),
note: `mroDepth=${signals.typeBindingMroDepth}`,
});
}
// ── Owner match (explanatory for debug) ────────────────────────────────
if (signals.ownerMatch === true) {
out.push({
kind: 'owner-match',
weight: EvidenceWeights.ownerMatch,
});
}
// ── Kind match (always present; weight 0; retained for debuggability) ──
out.push({
kind: 'kind-match',
weight: EvidenceWeights.kindMatch,
});
// ── Arity ──────────────────────────────────────────────────────────────
if (signals.arityVerdict !== undefined) {
const weight =
signals.arityVerdict === 'compatible'
? EvidenceWeights.arityMatchCompatible
: signals.arityVerdict === 'incompatible'
? EvidenceWeights.arityMatchIncompatible
: EvidenceWeights.arityMatchUnknown;
out.push({
kind: 'arity-match',
weight,
note: signals.arityVerdict,
});
}
// ── Dynamic-unresolved (degraded signal) ───────────────────────────────
if (signals.dynamicUnresolved === true) {
out.push({
kind: 'dynamic-import-unresolved',
weight: EvidenceWeights.dynamicImportUnresolved,
});
}
return out;
}
/**
* Sum evidence weights and clamp to `[0, 1]`. Separate from `composeEvidence`
* so tests and the parity dashboard can inspect the raw evidence list.
*/
export function confidenceFromEvidence(evidence: readonly ResolutionEvidence[]): number {
let sum = 0;
for (const e of evidence) sum += e.weight;
if (sum < 0) return 0;
if (sum > 1) return 1;
return sum;
}
// ─── Internal ───────────────────────────────────────────────────────────────
function getOriginWeight(origin: NonNullable<RawSignals['origin']>): number {
switch (origin) {
case 'local':
return EvidenceWeights.local;
case 'import':
return EvidenceWeights.import;
case 'reexport':
return EvidenceWeights.reexport;
case 'namespace':
return EvidenceWeights.namespace;
case 'wildcard':
return EvidenceWeights.wildcard;
case 'global-qualified':
return EvidenceWeights.globalQualified;
case 'global-name':
return EvidenceWeights.globalName;
}
}
function whereFoundEvidenceKind(
origin: NonNullable<RawSignals['origin']>,
): ResolutionEvidence['kind'] {
switch (origin) {
case 'local':
return 'local';
case 'import':
case 'reexport':
case 'namespace':
case 'wildcard':
return 'import';
case 'global-qualified':
return 'global-qualified';
case 'global-name':
return 'global-name';
}
}

View file

@ -0,0 +1,43 @@
/**
* `FieldRegistry` scope-aware lookup for field / property / variable
* access (RFC §4.4; Ring 2 SHARED #917).
*
* Thin wrapper over `lookupCore`, specialized for data-member kinds:
*
* - `acceptedKinds` = Variable / Property / Const / Static.
* - `useReceiverTypeBinding` is **true** fields are resolved against
* the receiver type's MRO first, then via the lexical chain for
* free variables.
* - `callsite` is not meaningful for field access (no arity), but the
* `explicitReceiver` and `ownerScopedContributor` knobs are.
*/
import type { Resolution, ScopeId } from '../types.js';
import { lookupCore, type CoreLookupParams } from './lookup-core.js';
import type { OwnerScopedContributor, RegistryContext } from './context.js';
import { FIELD_KINDS } from './context.js';
export interface FieldLookupOptions {
readonly explicitReceiver?: { readonly name: string };
readonly ownerScopedContributor?: OwnerScopedContributor;
}
export interface FieldRegistry {
lookup(name: string, scope: ScopeId, options?: FieldLookupOptions): readonly Resolution[];
}
export function buildFieldRegistry(ctx: RegistryContext): FieldRegistry {
return {
lookup(name: string, scope: ScopeId, options: FieldLookupOptions = {}) {
const params: CoreLookupParams = {
acceptedKinds: FIELD_KINDS,
useReceiverTypeBinding: true,
ownerScopedContributor: options.ownerScopedContributor ?? null,
...(options.explicitReceiver !== undefined
? { explicitReceiver: options.explicitReceiver }
: {}),
};
return lookupCore(name, scope, params, ctx);
},
};
}

View file

@ -0,0 +1,447 @@
/**
* `lookupCore` the shared 7-step canonical resolution algorithm
* (RFC §4.2; Ring 2 SHARED #917).
*
* Pure function. Given a name, a starting scope, and per-kind parameters,
* walks lexical scopes + optional type-binding MRO + optional owner
* contributor + global qualified-name fallback, and returns a ranked
* `Resolution[]` with per-candidate evidence.
*
* All three public registries (`ClassRegistry` / `MethodRegistry` /
* `FieldRegistry`) dispatch into this function, differing only in the
* parameters they pass. The CHOICE of which steps fire is expressed
* through `LookupParams`, not through different algorithms per kind.
*
* ## Algorithm (RFC §4.2, verbatim names)
*
* **Step 1 Lexical scope-chain walk.** From `startScope`, walk
* parent-ward. At each scope, consult `scope.bindings.get(name)`:
* - Filter candidates whose `def.type ∈ acceptedKinds`.
* - For each surviving candidate, record a raw signal with the
* binding's origin + the current scope-chain depth.
* - **Hard shadow.** If `bindings.get(name)` is non-empty (including
* non-kind-matching candidates), stop walking. The name is
* lexically bound here; outer scopes are not consulted.
*
* **Step 2 Type-binding resolution.** When `useReceiverTypeBinding`
* is true, resolve the receiver's type at `startScope` (from
* `scope.typeBindings`), then walk the MRO via
* `MethodDispatchIndex.mroFor(ownerDefId)`. Membership per owner comes
* through `RegistryContext.methodDispatch` + owner lookups into
* `scope.ownedDefs`; each hit records a raw signal with the owner's
* MRO depth.
*
* **Step 3 Owner-scoped contributor.** When
* `params.ownerScopedContributor` is present, merge its `byName(name)`
* hits with `origin: 'local'` (they are declared directly on the
* receiver). Distinct from Step 2 Step 2 walks the MRO; Step 3 only
* looks at the directly-declared owner members.
*
* **Step 4 Kind filter (emit `kind-match` evidence).** Already
* applied during Steps 1-3; this step just adds a `kind-match` signal
* at weight 0 to every candidate for debuggability (so the evidence
* array is self-describing).
*
* **Step 5 Arity filter.** Call `providers.arityCompatibility(callsite,
* def)` per surviving candidate. Verdicts: `compatible` / `unknown` /
* `incompatible`. If at least one candidate is `compatible`, drop
* `incompatible` ones. Otherwise keep all (the penalty weight alone
* will rank them lower but they remain in the result).
*
* **Step 6 Global fallback.** When Steps 1-3 produced **no**
* candidates and the name contains a `.`, consult the
* `QualifiedNameIndex` via `lookupQualified` see §4.5. The `scope`
* argument is NOT passed here because global lookup is scope-agnostic.
*
* **Step 7 Rank + tie-break.** Compose evidence, compute confidence
* (sum capped at 1.0), sort by the RFC Appendix B cascade.
*
* ## What this module does NOT do
*
* - No AST reads (pure data in, pure data out).
* - No `gitnexus/` imports.
* - No language switches. Language-specific behavior flows exclusively
* through `providers.*` and the `params` object.
* - No caching. Callers that want memoization can wrap this function.
*/
import type { NodeLabel } from '../../graph/types.js';
import type { SymbolDefinition } from '../symbol-definition.js';
import type {
BindingRef,
Callsite,
DefId,
LookupParams,
Resolution,
Scope,
ScopeId,
} from '../types.js';
import type { OriginForTieBreak } from '../origin-priority.js';
import { composeEvidence, confidenceFromEvidence, type RawSignals } from './evidence.js';
import { compareByConfidenceWithTiebreaks, type TieBreakKey } from './tie-breaks.js';
import { lookupQualified } from './lookup-qualified.js';
import type { ArityVerdict, OwnerScopedContributor, RegistryContext } from './context.js';
// ─── Public entry point ─────────────────────────────────────────────────────
/** Extended `LookupParams` narrowing `ownerScopedContributor` to the concrete shape. */
export interface CoreLookupParams extends Omit<LookupParams, 'ownerScopedContributor'> {
readonly ownerScopedContributor: OwnerScopedContributor | null;
/** Call-site description forwarded to `arityCompatibility`. Optional — for non-call lookups. */
readonly callsite?: Callsite;
}
/**
* Run the 7-step lookup. Returns a non-empty `Resolution[]` when any
* candidate was found; an empty array otherwise. Callers consume `[0]`
* for the best answer and optionally inspect the rest for alternates.
*/
export function lookupCore(
name: string,
startScope: ScopeId,
params: CoreLookupParams,
ctx: RegistryContext,
): readonly Resolution[] {
const acceptedKinds = new Set<NodeLabel>(params.acceptedKinds);
const perCandidate = new Map<DefId, CandidateState>();
// ── Step 1: lexical scope-chain walk ──────────────────────────────────
const lexicalShadowed = walkLexicalChain(name, startScope, acceptedKinds, ctx, perCandidate);
// ── Step 2: type-binding / MRO walk (methods/fields) ──────────────────
if (params.useReceiverTypeBinding && ctx.methodDispatch !== undefined) {
walkReceiverTypeBinding(name, startScope, acceptedKinds, params, ctx, perCandidate);
}
// ── Step 3: owner-scoped contributor ──────────────────────────────────
if (params.ownerScopedContributor !== null) {
seedFromOwnerScopedContributor(
name,
params.ownerScopedContributor,
acceptedKinds,
perCandidate,
);
}
// ── Step 4: kind-match evidence (emitted by composeEvidence directly) ──
// Handled inside `composeEvidence`.
// ── Step 5: arity filter ──────────────────────────────────────────────
if (params.callsite !== undefined) {
applyArityFilter(params.callsite, perCandidate, ctx);
}
// ── Step 6: global fallback (only when Steps 1-3 produced nothing) ──
if (perCandidate.size === 0 && !lexicalShadowed && name.includes('.')) {
const globals = lookupQualified(name, { acceptedKinds: params.acceptedKinds }, ctx);
if (globals.length > 0) return globals;
}
if (perCandidate.size === 0) return EMPTY;
// ── Step 7: compose evidence + rank ──────────────────────────────────
return rankCandidates(perCandidate);
}
// ─── Internal state ────────────────────────────────────────────────────────
interface CandidateState {
readonly def: SymbolDefinition;
readonly signals: MutableRawSignals;
readonly tieBreakKey: MutableTieBreakKey;
}
interface MutableRawSignals {
origin?: BindingRef['origin'] | 'global-qualified' | 'global-name';
scopeChainDepth?: number;
viaUnlinkedImport?: boolean;
typeBindingMroDepth?: number;
ownerMatch?: boolean;
kindMatch: true;
arityVerdict?: ArityVerdict;
dynamicUnresolved?: boolean;
}
interface MutableTieBreakKey {
scopeDepth: number;
mroDepth: number;
origin: OriginForTieBreak;
}
function ensureCandidate(
perCandidate: Map<DefId, CandidateState>,
def: SymbolDefinition,
): CandidateState {
const existing = perCandidate.get(def.nodeId);
if (existing !== undefined) return existing;
const fresh: CandidateState = {
def,
signals: { kindMatch: true },
tieBreakKey: { scopeDepth: 0, mroDepth: 0, origin: 'local' },
};
perCandidate.set(def.nodeId, fresh);
return fresh;
}
// ─── Step 1 implementation ─────────────────────────────────────────────────
/**
* Walk the lexical scope chain from `startScope` upward. Returns `true`
* iff a scope with any `bindings.get(name)` entries was found the
* caller uses this to decide whether to run the global fallback.
*/
function walkLexicalChain(
name: string,
startScope: ScopeId,
acceptedKinds: ReadonlySet<NodeLabel>,
ctx: RegistryContext,
perCandidate: Map<DefId, CandidateState>,
): boolean {
let currentId: ScopeId | null = startScope;
let depth = 0;
const visited = new Set<ScopeId>();
while (currentId !== null) {
if (visited.has(currentId)) return false;
visited.add(currentId);
const scope: Scope | undefined = ctx.scopes.getScope(currentId);
if (scope === undefined) return false;
const bindings = scope.bindings.get(name);
if (bindings !== undefined && bindings.length > 0) {
for (const binding of bindings) {
if (!acceptedKinds.has(binding.def.type)) continue;
recordLexicalHit(perCandidate, binding, depth);
}
return true; // hard shadow regardless of kind-filter survivorship
}
currentId = scope.parent;
depth++;
}
return false;
}
function recordLexicalHit(
perCandidate: Map<DefId, CandidateState>,
binding: BindingRef,
scopeChainDepth: number,
): void {
const state = ensureCandidate(perCandidate, binding.def);
state.signals.origin = binding.origin;
state.signals.scopeChainDepth = scopeChainDepth;
if (binding.via?.linkStatus === 'unresolved') {
state.signals.viaUnlinkedImport = true;
}
if (binding.via?.kind === 'dynamic-unresolved') {
state.signals.dynamicUnresolved = true;
}
state.tieBreakKey.scopeDepth = scopeChainDepth;
state.tieBreakKey.origin = binding.origin as OriginForTieBreak;
}
// ─── Step 2 implementation ─────────────────────────────────────────────────
function walkReceiverTypeBinding(
name: string,
startScope: ScopeId,
acceptedKinds: ReadonlySet<NodeLabel>,
params: CoreLookupParams,
ctx: RegistryContext,
perCandidate: Map<DefId, CandidateState>,
): void {
const ownerDefId = resolveReceiverOwner(startScope, params, ctx);
if (ownerDefId === undefined) return;
if (ctx.methodDispatch === undefined) return;
const ownerDef = ctx.defs.get(ownerDefId);
if (ownerDef === undefined) return;
// Walk the owner itself at depth 0, then its MRO chain.
const walk: DefId[] = [ownerDefId, ...ctx.methodDispatch.mroFor(ownerDefId)];
for (let mroDepth = 0; mroDepth < walk.length; mroDepth++) {
const currentOwnerId = walk[mroDepth]!;
const members = collectOwnedMembers(currentOwnerId, name, ctx);
for (const def of members) {
if (!acceptedKinds.has(def.type)) continue;
recordTypeBindingHit(perCandidate, def, mroDepth, ownerDefId);
}
}
}
function resolveReceiverOwner(
startScope: ScopeId,
params: CoreLookupParams,
ctx: RegistryContext,
): DefId | undefined {
// Explicit receiver: consult the callsite scope's typeBindings for the
// named receiver; the attached TypeRef identifies the owner. Without a
// ready resolveTypeRef call (that module is separate), we do a direct
// lookup and trust the caller to have populated the binding.
if (params.explicitReceiver !== undefined) {
return lookupReceiverType(startScope, params.explicitReceiver.name, ctx);
}
// Implicit `self` / `this` — the scope's typeBindings should carry it.
for (const implicitName of IMPLICIT_RECEIVERS) {
const owner = lookupReceiverType(startScope, implicitName, ctx);
if (owner !== undefined) return owner;
}
return undefined;
}
const IMPLICIT_RECEIVERS: readonly string[] = Object.freeze(['self', 'this']);
function lookupReceiverType(
startScope: ScopeId,
receiverName: string,
ctx: RegistryContext,
): DefId | undefined {
let currentId: ScopeId | null = startScope;
const visited = new Set<ScopeId>();
while (currentId !== null) {
if (visited.has(currentId)) return undefined;
visited.add(currentId);
const scope = ctx.scopes.getScope(currentId);
if (scope === undefined) return undefined;
const typeRef = scope.typeBindings.get(receiverName);
if (typeRef !== undefined) {
// rawName must resolve to a def via qualifiedNames; if it doesn't, we
// can't claim the receiver type. No fallback — that's what
// `resolveTypeRef` would do, but we keep this path lean and let
// 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).
return undefined;
}
currentId = scope.parent;
}
return undefined;
}
function collectOwnedMembers(
ownerDefId: DefId,
memberName: string,
ctx: RegistryContext,
): readonly SymbolDefinition[] {
// An owner's members are defs whose `ownerId === ownerDefId` and whose
// simple name matches `memberName`. We iterate `defs.byId` — O(D) per
// call today. A future by-owner index would make this O(K); tracked as
// a follow-up optimization before Ring 3 flips go production.
const out: SymbolDefinition[] = [];
for (const def of ctx.defs.byId.values()) {
if (def.ownerId !== ownerDefId) continue;
if (simpleNameOf(def) !== memberName) continue;
out.push(def);
}
return out;
}
function simpleNameOf(def: SymbolDefinition): string | undefined {
if (def.qualifiedName === undefined || def.qualifiedName.length === 0) return undefined;
const dot = def.qualifiedName.lastIndexOf('.');
return dot === -1 ? def.qualifiedName : def.qualifiedName.slice(dot + 1);
}
function recordTypeBindingHit(
perCandidate: Map<DefId, CandidateState>,
def: SymbolDefinition,
mroDepth: number,
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
) {
state.signals.typeBindingMroDepth = mroDepth;
state.tieBreakKey.mroDepth = mroDepth;
}
if (def.ownerId === receiverOwner) {
state.signals.ownerMatch = true;
}
}
// ─── Step 3 implementation ─────────────────────────────────────────────────
function seedFromOwnerScopedContributor(
name: string,
contributor: OwnerScopedContributor,
acceptedKinds: ReadonlySet<NodeLabel>,
perCandidate: Map<DefId, CandidateState>,
): void {
for (const def of contributor.byName(name)) {
if (!acceptedKinds.has(def.type)) continue;
const state = ensureCandidate(perCandidate, def);
// Treat the contributor's direct membership as `origin: 'local'` —
// strongest visibility, no scope-chain penalty.
state.signals.origin = 'local';
state.signals.scopeChainDepth = 0;
state.signals.ownerMatch = def.ownerId === contributor.ownerDefId;
state.tieBreakKey.origin = 'local';
}
}
// ─── Step 5 implementation ─────────────────────────────────────────────────
function applyArityFilter(
callsite: Callsite,
perCandidate: Map<DefId, CandidateState>,
ctx: RegistryContext,
): void {
const arityFn = ctx.providers.arityCompatibility;
if (arityFn === undefined) {
// No provider → record 'unknown' for every candidate; keeps signal
// shape uniform for composeEvidence.
for (const state of perCandidate.values()) {
state.signals.arityVerdict = 'unknown';
}
return;
}
let anyCompatible = false;
for (const state of perCandidate.values()) {
const verdict = arityFn(callsite, state.def);
state.signals.arityVerdict = verdict;
if (verdict === 'compatible') anyCompatible = true;
}
if (!anyCompatible) return;
// Filter: when at least one compatible candidate exists, drop incompatibles.
for (const [defId, state] of perCandidate) {
if (state.signals.arityVerdict === 'incompatible') {
perCandidate.delete(defId);
}
}
}
// ─── Step 7 implementation ─────────────────────────────────────────────────
function rankCandidates(perCandidate: Map<DefId, CandidateState>): readonly Resolution[] {
const resolutions: Resolution[] = [];
const tieKeys = new Map<string, TieBreakKey>();
for (const state of perCandidate.values()) {
const evidence = composeEvidence(state.signals as RawSignals);
const confidence = confidenceFromEvidence(evidence);
resolutions.push({ def: state.def, confidence, evidence });
tieKeys.set(state.def.nodeId, { ...state.tieBreakKey });
}
resolutions.sort((a, b) => compareByConfidenceWithTiebreaks(a, b, tieKeys));
return Object.freeze(resolutions);
}
// ─── Constants ──────────────────────────────────────────────────────────────
const EMPTY: readonly Resolution[] = Object.freeze([]);

View file

@ -0,0 +1,71 @@
/**
* `lookupQualified` qualified-name fast path (RFC §4.5; Ring 2 SHARED #917).
*
* Consults `QualifiedNameIndex` directly, filters by `acceptedKinds`, and
* returns `Resolution[]` with `origin: 'global-qualified'` evidence. Used by:
*
* - `resolveTypeRef` dotted fallback (#916)
* - `Registry.lookup` Step 6 when no lexical candidate survived
* - Explicit dotted identifiers in Cypher / MCP tools where the caller
* knows the target's canonical qualified name
*
* **Strict + deterministic.** No receiver-type resolution, no scope walk.
* Every surviving candidate gets the same base confidence (from
* `EvidenceWeights.globalQualified`), then the tie-break cascade
* disambiguates.
*/
import type { NodeLabel } from '../../graph/types.js';
import type { Resolution } from '../types.js';
import { composeEvidence, confidenceFromEvidence } from './evidence.js';
import { compareByConfidenceWithTiebreaks, type TieBreakKey } from './tie-breaks.js';
import type { RegistryContext } from './context.js';
export interface LookupQualifiedParams {
readonly acceptedKinds: readonly NodeLabel[];
}
/**
* Look up a canonical qualified name (e.g., `app.models.User`) across all
* defs, filtered by `acceptedKinds`. Returns an empty array when the name
* is not indexed or no candidate matches the kind filter.
*
* Callers consume `[0]` for the strict single-return answer; the remainder
* carries alternate candidates (partial classes, overloads, accidental
* cross-kind hits) ordered by the tie-break cascade.
*/
export function lookupQualified(
qualifiedName: string,
params: LookupQualifiedParams,
ctx: RegistryContext,
): readonly Resolution[] {
const defIds = ctx.qualifiedNames.get(qualifiedName);
if (defIds.length === 0) return EMPTY;
const acceptedKinds = new Set<NodeLabel>(params.acceptedKinds);
const resolutions: Resolution[] = [];
const tieKeys = new Map<string, TieBreakKey>();
for (const defId of defIds) {
const def = ctx.defs.get(defId);
if (def === undefined) continue;
if (!acceptedKinds.has(def.type)) continue;
const evidence = composeEvidence({ origin: 'global-qualified', kindMatch: true });
const confidence = confidenceFromEvidence(evidence);
resolutions.push({ def, confidence, evidence });
tieKeys.set(def.nodeId, {
scopeDepth: 0,
mroDepth: 0,
origin: 'global-qualified',
});
}
if (resolutions.length === 0) return EMPTY;
resolutions.sort((a, b) => compareByConfidenceWithTiebreaks(a, b, tieKeys));
return Object.freeze(resolutions);
}
const EMPTY: readonly Resolution[] = Object.freeze([]);

View file

@ -0,0 +1,54 @@
/**
* `MethodRegistry` scope-aware lookup for method / function / constructor
* dispatch (RFC §4.4; Ring 2 SHARED #917).
*
* Thin wrapper over `lookupCore`, specialized for callable kinds:
*
* - `acceptedKinds` = Method / Function / Constructor.
* - `useReceiverTypeBinding` is **true** the type-binding + MRO walk
* (Step 2) is the primary evidence path for receiver-dispatched calls.
* - `callsite.arity` flows through to `provider.arityCompatibility`
* when provided. When the provider is absent, arity evidence is
* `unknown` (neutral signal).
*/
import type { Callsite, Resolution, ScopeId } from '../types.js';
import { lookupCore, type CoreLookupParams } from './lookup-core.js';
import type { OwnerScopedContributor, RegistryContext } from './context.js';
import { METHOD_KINDS } from './context.js';
/**
* Extra per-call parameters that vary across call sites but NOT across
* registries. Kept as a separate shape so `MethodRegistry.lookup` stays
* concise while still exposing the explicit-receiver + owner-contributor +
* arity knobs the RFC algorithm needs.
*/
export interface MethodLookupOptions {
/** Call-site arity for `provider.arityCompatibility`. */
readonly callsite?: Callsite;
/** Explicit receiver (e.g., `user` in `user.save()`). See §4.1. */
readonly explicitReceiver?: { readonly name: string };
/** Optional per-owner contributor (Step 3). */
readonly ownerScopedContributor?: OwnerScopedContributor;
}
export interface MethodRegistry {
lookup(name: string, scope: ScopeId, options?: MethodLookupOptions): readonly Resolution[];
}
export function buildMethodRegistry(ctx: RegistryContext): MethodRegistry {
return {
lookup(name: string, scope: ScopeId, options: MethodLookupOptions = {}) {
const params: CoreLookupParams = {
acceptedKinds: METHOD_KINDS,
useReceiverTypeBinding: true,
ownerScopedContributor: options.ownerScopedContributor ?? null,
...(options.callsite !== undefined ? { callsite: options.callsite } : {}),
...(options.explicitReceiver !== undefined
? { explicitReceiver: options.explicitReceiver }
: {}),
};
return lookupCore(name, scope, params, ctx);
},
};
}

View file

@ -0,0 +1,76 @@
/**
* `compareByConfidenceWithTiebreaks` the RFC §4.2 Step 7 total order
* over `Resolution` candidates (Ring 2 SHARED #917).
*
* Primary key is confidence (DESC). Remaining ties within `CONFIDENCE_EPSILON`
* fall through a deterministic cascade so the same inputs always produce
* the same winner, independent of insertion order.
*
* Tie-break cascade (per RFC Appendix B):
*
* 1. confidence DESC (primary)
* 2. scope depth ASC (nearer lexical scope wins)
* 3. MRO depth ASC (nearer class in hierarchy wins)
* 4. `ORIGIN_PRIORITY` ASC (local > import > > global-name)
* 5. DefId.localeCompare (final deterministic tiebreaker)
*
* The per-candidate inputs needed beyond `Resolution.confidence`
* `scopeDepth`, `mroDepth`, `origin` are supplied via a sidecar
* `TieBreakKey` so the comparator stays pure and `Resolution` itself
* doesn't need to carry book-keeping fields.
*/
import { ORIGIN_PRIORITY, type OriginForTieBreak } from '../origin-priority.js';
import type { Resolution } from '../types.js';
export const CONFIDENCE_EPSILON = 0.001;
/** Side-information per candidate used for secondary tie-breaks. */
export interface TieBreakKey {
readonly scopeDepth: number;
readonly mroDepth: number;
readonly origin: OriginForTieBreak;
}
/**
* Pure comparator suitable for `Array.prototype.sort`. Return value follows
* the JavaScript convention: negative `a` wins, positive `b` wins.
*
* **Important:** `keys` is keyed by `Resolution.def.nodeId`, not by array
* index stable across reorderings. Missing keys fall back to neutral
* values (`scopeDepth: 0`, `mroDepth: 0`, `origin: 'local'`), which means
* the tie-break degrades gracefully to defId-lexicographic ordering when
* side-info is unavailable. That keeps the total order deterministic
* even on malformed inputs.
*/
export function compareByConfidenceWithTiebreaks(
a: Resolution,
b: Resolution,
keys: ReadonlyMap<string, TieBreakKey>,
): number {
// Primary: confidence DESC, treating values within epsilon as equal.
const delta = b.confidence - a.confidence;
if (Math.abs(delta) >= CONFIDENCE_EPSILON) return delta < 0 ? -1 : 1;
const ka = keys.get(a.def.nodeId) ?? DEFAULT_KEY;
const kb = keys.get(b.def.nodeId) ?? DEFAULT_KEY;
// Secondary: scope depth ASC.
if (ka.scopeDepth !== kb.scopeDepth) return ka.scopeDepth - kb.scopeDepth;
// Tertiary: MRO depth ASC.
if (ka.mroDepth !== kb.mroDepth) return ka.mroDepth - kb.mroDepth;
// Quaternary: ORIGIN_PRIORITY ASC.
const po = ORIGIN_PRIORITY[ka.origin] - ORIGIN_PRIORITY[kb.origin];
if (po !== 0) return po;
// Final: DefId lexicographic, locale-aware for deterministic cross-platform output.
return a.def.nodeId.localeCompare(b.def.nodeId);
}
const DEFAULT_KEY: TieBreakKey = Object.freeze({
scopeDepth: 0,
mroDepth: 0,
origin: 'local',
});

View file

@ -0,0 +1,669 @@
/**
* Unit tests for the scope-aware registries (RFC §4; Ring 2 SHARED #917).
*
* Tests are organized per RFC §4.2 step so a regression localizes to the
* step it broke:
*
* §4.2 Step 1 lexical scope-chain walk + shadowing
* §4.2 Step 2 type-binding / MRO walk (method/field registries)
* §4.2 Step 3 owner-scoped contributor
* §4.2 Step 4 kind filter + kind-match evidence
* §4.2 Step 5 arity filter
* §4.2 Step 6 global-qualified fallback
* §4.2 Step 7 rank + tie-break cascade
* §4.5 lookupQualified helper
* §4.7 invariants
*
* Corroborators (owner-match, unresolved-import cap, dynamic-unresolved)
* get their own sections.
*/
import { describe, it, expect } from 'vitest';
import {
buildClassRegistry,
buildFieldRegistry,
buildMethodRegistry,
buildDefIndex,
buildMethodDispatchIndex,
buildModuleScopeIndex,
buildQualifiedNameIndex,
buildScopeTree,
lookupCore,
lookupQualified,
EvidenceWeights,
type BindingRef,
type ImportEdge,
type Range,
type RegistryContext,
type Resolution,
type Scope,
type ScopeId,
type ScopeKind,
type SymbolDefinition,
type TypeRef,
} from 'gitnexus-shared';
// ─── Test helpers ───────────────────────────────────────────────────────────
const r = (startLine: number, startCol: number, endLine: number, endCol: number): Range => ({
startLine,
startCol,
endLine,
endCol,
});
const mkDef = (overrides: Partial<SymbolDefinition> & { nodeId: string }): SymbolDefinition => ({
nodeId: overrides.nodeId,
filePath: overrides.filePath ?? 'x.ts',
type: overrides.type ?? 'Class',
...overrides,
});
const mkBinding = (
def: SymbolDefinition,
origin: BindingRef['origin'],
via?: ImportEdge,
): BindingRef => ({ def, origin, ...(via !== undefined ? { via } : {}) });
interface ScopeSpec {
id: ScopeId;
parent: ScopeId | null;
kind?: ScopeKind;
range?: Range;
filePath?: string;
bindings?: Record<string, readonly BindingRef[]>;
ownedDefs?: readonly SymbolDefinition[];
typeBindings?: Record<string, TypeRef>;
}
const mkScope = (s: ScopeSpec): Scope => ({
id: s.id,
parent: s.parent,
kind: s.kind ?? 'Module',
range: s.range ?? r(1, 0, 1000, 0),
filePath: s.filePath ?? 'x.ts',
bindings: new Map(Object.entries(s.bindings ?? {})),
ownedDefs: s.ownedDefs ?? [],
imports: [],
typeBindings: new Map(Object.entries(s.typeBindings ?? {})),
});
const typeRef = (rawName: string, declaredAtScope: ScopeId): TypeRef => ({
rawName,
declaredAtScope,
source: 'parameter-annotation',
});
function makeCtx(
scopes: Scope[],
defs: SymbolDefinition[],
opts: {
mro?: Record<string, readonly string[]>;
implsByInterface?: Record<string, readonly string[]>;
arity?: (
callsite: { arity: number },
def: SymbolDefinition,
) => 'compatible' | 'unknown' | 'incompatible';
} = {},
): RegistryContext {
const defIndex = buildDefIndex(defs);
const qualifiedNameIndex = buildQualifiedNameIndex(defs);
const moduleScopes = buildModuleScopeIndex(
scopes
.filter((s) => s.kind === 'Module')
.map((s) => ({ filePath: s.filePath, moduleScopeId: s.id })),
);
const owners = Array.from(new Set(defs.map((d) => d.nodeId)));
const methodDispatch = buildMethodDispatchIndex({
owners,
computeMro: (owner) => opts.mro?.[owner] ?? [],
implementsOf: (owner) => {
const out: string[] = [];
for (const [iface, impls] of Object.entries(opts.implsByInterface ?? {})) {
if (impls.includes(owner)) out.push(iface);
}
return out;
},
});
return {
scopes: buildScopeTree(scopes),
defs: defIndex,
qualifiedNames: qualifiedNameIndex,
moduleScopes,
methodDispatch,
providers: opts.arity !== undefined ? { arityCompatibility: opts.arity } : {},
};
}
const evidenceOfKind = (res: Resolution, kind: string) => res.evidence.find((e) => e.kind === kind);
// ─── §4.2 Step 1 — lexical scope-chain walk + shadowing ────────────────────
describe('Step 1: lexical scope-chain walk', () => {
it('finds a class declared at the start scope with origin=local', () => {
const userClass = mkDef({ nodeId: 'def:User', type: 'Class' });
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { User: [mkBinding(userClass, 'local')] },
});
const ctx = makeCtx([mod], [userClass]);
const registry = buildClassRegistry(ctx);
const results = registry.lookup('User', 'scope:m');
expect(results).toHaveLength(1);
expect(results[0]!.def).toBe(userClass);
expect(evidenceOfKind(results[0]!, 'local')?.weight).toBe(EvidenceWeights.local);
});
it('walks parent scopes when the name is not bound at the start scope', () => {
const userClass = mkDef({ nodeId: 'def:User', type: 'Class' });
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { User: [mkBinding(userClass, 'import')] },
});
const fn = mkScope({
id: 'scope:f',
parent: 'scope:m',
kind: 'Function',
range: r(2, 0, 10, 0),
});
const ctx = makeCtx([mod, fn], [userClass]);
const results = buildClassRegistry(ctx).lookup('User', 'scope:f');
expect(results[0]!.def).toBe(userClass);
const scopeChain = evidenceOfKind(results[0]!, 'scope-chain');
expect(scopeChain?.weight).toBe(EvidenceWeights.scopeChainPerDepth * 1);
});
it('enforces hard shadow: outer bindings are ignored once a name is bound at an inner scope', () => {
const outerClass = mkDef({ nodeId: 'def:outer', type: 'Class' });
const innerVar = mkDef({ nodeId: 'def:inner', type: 'Variable' });
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { User: [mkBinding(outerClass, 'local')] },
});
const fn = mkScope({
id: 'scope:f',
parent: 'scope:m',
kind: 'Function',
range: r(2, 0, 10, 0),
bindings: { User: [mkBinding(innerVar, 'local')] },
});
const ctx = makeCtx([mod, fn], [outerClass, innerVar]);
// Inner binding is a Variable (not a Class) → class registry returns empty.
const results = buildClassRegistry(ctx).lookup('User', 'scope:f');
expect(results).toEqual([]);
});
it('emits origin=import evidence when the binding is imported', () => {
const userClass = mkDef({ nodeId: 'def:User', type: 'Class' });
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { User: [mkBinding(userClass, 'import')] },
});
const ctx = makeCtx([mod], [userClass]);
const res = buildClassRegistry(ctx).lookup('User', 'scope:m');
expect(evidenceOfKind(res[0]!, 'import')?.weight).toBe(EvidenceWeights.import);
});
});
// ─── §4.2 Step 5 — arity filter ────────────────────────────────────────────
describe('Step 5: arity filter', () => {
it('drops incompatible candidates when at least one compatible candidate exists', () => {
const save2 = mkDef({
nodeId: 'def:save-two',
type: 'Method',
qualifiedName: 'User.save',
parameterCount: 2,
});
const save1 = mkDef({
nodeId: 'def:save-one',
type: 'Method',
qualifiedName: 'User.save',
parameterCount: 1,
});
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { save: [mkBinding(save2, 'local'), mkBinding(save1, 'local')] },
});
const ctx = makeCtx([mod], [save2, save1], {
arity: (callsite, def) => {
const count = def.parameterCount ?? 0;
if (count === callsite.arity) return 'compatible';
return 'incompatible';
},
});
const results = buildMethodRegistry(ctx).lookup('save', 'scope:m', {
callsite: { arity: 1 },
});
expect(results).toHaveLength(1);
expect(results[0]!.def.nodeId).toBe('def:save-one');
expect(evidenceOfKind(results[0]!, 'arity-match')?.weight).toBe(
EvidenceWeights.arityMatchCompatible,
);
});
it('keeps incompatible candidates when no compatible candidate exists (soft penalty)', () => {
const save3 = mkDef({
nodeId: 'def:save-three',
type: 'Method',
qualifiedName: 'User.save',
parameterCount: 3,
});
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { save: [mkBinding(save3, 'local')] },
});
const ctx = makeCtx([mod], [save3], {
arity: () => 'incompatible',
});
const results = buildMethodRegistry(ctx).lookup('save', 'scope:m', {
callsite: { arity: 1 },
});
expect(results).toHaveLength(1);
expect(evidenceOfKind(results[0]!, 'arity-match')?.weight).toBe(
EvidenceWeights.arityMatchIncompatible,
);
});
it('records arity=unknown when the provider is missing (neutral signal)', () => {
const m = mkDef({ nodeId: 'def:m', type: 'Method', qualifiedName: 'C.m' });
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { m: [mkBinding(m, 'local')] },
});
const ctx = makeCtx([mod], [m]); // no arity provider
const results = buildMethodRegistry(ctx).lookup('m', 'scope:m', {
callsite: { arity: 7 },
});
expect(evidenceOfKind(results[0]!, 'arity-match')?.weight).toBe(
EvidenceWeights.arityMatchUnknown,
);
});
});
// ─── §4.2 Step 6 — global-qualified fallback ───────────────────────────────
describe('Step 6: global-qualified fallback', () => {
it('falls back to the qualified-name index when no lexical candidate is found', () => {
const cls = mkDef({ nodeId: 'def:app.User', qualifiedName: 'app.User', type: 'Class' });
const mod = mkScope({ id: 'scope:m', parent: null }); // no lexical binding
const ctx = makeCtx([mod], [cls]);
const results = buildClassRegistry(ctx).lookup('app.User', 'scope:m');
expect(results).toHaveLength(1);
expect(results[0]!.def).toBe(cls);
expect(evidenceOfKind(results[0]!, 'global-qualified')?.weight).toBe(
EvidenceWeights.globalQualified,
);
});
it('does NOT consult the global index when a lexical hit exists (shadowing)', () => {
const localCls = mkDef({ nodeId: 'def:local', type: 'Class' });
const globalCls = mkDef({
nodeId: 'def:global',
qualifiedName: 'other.User',
type: 'Class',
});
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { User: [mkBinding(localCls, 'local')] },
});
const ctx = makeCtx([mod], [localCls, globalCls]);
const results = buildClassRegistry(ctx).lookup('User', 'scope:m');
expect(results).toHaveLength(1);
expect(results[0]!.def).toBe(localCls);
});
it('does NOT apply the global fallback for non-dotted names', () => {
const cls = mkDef({ nodeId: 'def:x', qualifiedName: 'User', type: 'Class' });
const mod = mkScope({ id: 'scope:m', parent: null });
const ctx = makeCtx([mod], [cls]);
// 'User' has no dot → no qname fallback.
const results = buildClassRegistry(ctx).lookup('User', 'scope:m');
expect(results).toEqual([]);
});
});
// ─── §4.2 Step 7 — tie-breaks ──────────────────────────────────────────────
describe('Step 7: tie-break cascade', () => {
it('confidence DESC is the primary key', () => {
const nearClass = mkDef({ nodeId: 'def:near', type: 'Class' });
const farClass = mkDef({ nodeId: 'def:far', type: 'Class' });
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { User: [mkBinding(farClass, 'local')] },
});
const fn = mkScope({
id: 'scope:f',
parent: 'scope:m',
kind: 'Function',
range: r(2, 0, 10, 0),
bindings: { User: [mkBinding(nearClass, 'local')] },
});
const ctx = makeCtx([mod, fn], [nearClass, farClass]);
const results = buildClassRegistry(ctx).lookup('User', 'scope:f');
// Inner binding shadows; only the near class should appear.
expect(results).toHaveLength(1);
expect(results[0]!.def).toBe(nearClass);
});
it('breaks ties by DefId.localeCompare when all secondary keys are equal', () => {
const a = mkDef({ nodeId: 'def:aaa', type: 'Class' });
const b = mkDef({ nodeId: 'def:bbb', type: 'Class' });
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { User: [mkBinding(b, 'local'), mkBinding(a, 'local')] }, // reversed
});
const ctx = makeCtx([mod], [a, b]);
const results = buildClassRegistry(ctx).lookup('User', 'scope:m');
expect(results[0]!.def.nodeId).toBe('def:aaa');
expect(results[1]!.def.nodeId).toBe('def:bbb');
});
});
// ─── Corroborators: unresolved-import cap (per-signal) ─────────────────────
describe('unresolved-import cap (per-signal)', () => {
it('halves the import evidence weight when via.linkStatus is unresolved', () => {
const cls = mkDef({ nodeId: 'def:User', type: 'Class' });
const unresolvedEdge: ImportEdge = {
localName: 'User',
targetFile: null,
targetExportedName: 'User',
kind: 'named',
linkStatus: 'unresolved',
};
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { User: [mkBinding(cls, 'import', unresolvedEdge)] },
});
const ctx = makeCtx([mod], [cls]);
const results = buildClassRegistry(ctx).lookup('User', 'scope:m');
const importEv = evidenceOfKind(results[0]!, 'import');
expect(importEv?.weight).toBe(
EvidenceWeights.import * EvidenceWeights.unlinkedImportMultiplier,
);
});
it('leaves arity & owner-match signals unaffected by the unresolved-import cap', () => {
const m = mkDef({
nodeId: 'def:m',
type: 'Method',
qualifiedName: 'User.save',
parameterCount: 1,
});
const unresolved: ImportEdge = {
localName: 'save',
targetFile: null,
targetExportedName: 'save',
kind: 'named',
linkStatus: 'unresolved',
};
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { save: [mkBinding(m, 'import', unresolved)] },
});
const ctx = makeCtx([mod], [m], {
arity: () => 'compatible',
});
const results = buildMethodRegistry(ctx).lookup('save', 'scope:m', {
callsite: { arity: 1 },
});
expect(evidenceOfKind(results[0]!, 'arity-match')?.weight).toBe(
EvidenceWeights.arityMatchCompatible,
);
});
});
// ─── Corroborators: dynamic-unresolved degraded signal ─────────────────────
describe('dynamic-unresolved passthrough', () => {
it('emits a degraded dynamic-import-unresolved signal for dynamic edges', () => {
const cls = mkDef({ nodeId: 'def:X', type: 'Class' });
const dynEdge: ImportEdge = {
localName: 'X',
targetFile: null,
targetExportedName: '',
kind: 'dynamic-unresolved',
};
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { X: [mkBinding(cls, 'import', dynEdge)] },
});
const ctx = makeCtx([mod], [cls]);
const results = buildClassRegistry(ctx).lookup('X', 'scope:m');
expect(evidenceOfKind(results[0]!, 'dynamic-import-unresolved')?.weight).toBe(
EvidenceWeights.dynamicImportUnresolved,
);
});
});
// ─── lookupQualified helper (§4.5) ─────────────────────────────────────────
describe('lookupQualified (§4.5)', () => {
it('filters by acceptedKinds', () => {
const cls = mkDef({ nodeId: 'def:c', qualifiedName: 'app.User', type: 'Class' });
const fn = mkDef({ nodeId: 'def:f', qualifiedName: 'app.User', type: 'Function' });
const ctx = makeCtx([mkScope({ id: 'scope:m', parent: null })], [cls, fn]);
const results = lookupQualified('app.User', { acceptedKinds: ['Class'] }, ctx);
expect(results).toHaveLength(1);
expect(results[0]!.def).toBe(cls);
});
it('returns empty for unknown qualified names', () => {
const ctx = makeCtx([mkScope({ id: 'scope:m', parent: null })], []);
expect(lookupQualified('app.Ghost', { acceptedKinds: ['Class'] }, ctx)).toEqual([]);
});
it('orders multiple partial-class defs deterministically by defId', () => {
const a = mkDef({ nodeId: 'def:aaa', qualifiedName: 'app.User', type: 'Class' });
const b = mkDef({ nodeId: 'def:bbb', qualifiedName: 'app.User', type: 'Class' });
const ctx = makeCtx([mkScope({ id: 'scope:m', parent: null })], [b, a]);
const results = lookupQualified('app.User', { acceptedKinds: ['Class'] }, ctx);
expect(results.map((r) => r.def.nodeId)).toEqual(['def:aaa', 'def:bbb']);
});
});
// ─── lookupCore with owner-scoped contributor (Step 3) ─────────────────────
describe('Step 3: owner-scoped contributor', () => {
it('merges contributor hits as origin=local at the receiver scope', () => {
const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' });
const saveMethod = mkDef({
nodeId: 'def:User.save',
type: 'Method',
qualifiedName: 'User.save',
ownerId: 'def:User',
});
const mod = mkScope({ id: 'scope:m', parent: null });
const ctx = makeCtx([mod], [userClass, saveMethod]);
const results = buildMethodRegistry(ctx).lookup('save', 'scope:m', {
ownerScopedContributor: {
ownerDefId: 'def:User',
byName: (n) => (n === 'save' ? [saveMethod] : []),
},
});
expect(results).toHaveLength(1);
expect(results[0]!.def).toBe(saveMethod);
expect(evidenceOfKind(results[0]!, 'local')?.weight).toBe(EvidenceWeights.local);
expect(evidenceOfKind(results[0]!, 'owner-match')?.weight).toBe(EvidenceWeights.ownerMatch);
});
});
// ─── Step 2: type-binding / MRO walk ───────────────────────────────────────
describe('Step 2: type-binding + MRO walk', () => {
it('emits type-binding evidence with MRO-depth-decayed weight (explicit receiver)', () => {
const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' });
const saveMethod = mkDef({
nodeId: 'def:User.save',
type: 'Method',
qualifiedName: 'User.save',
ownerId: 'def:User',
});
const callScope = mkScope({
id: 'scope:call',
parent: null,
typeBindings: { user: typeRef('User', 'scope:call') },
});
const ctx = makeCtx([callScope], [userClass, saveMethod]);
const results = buildMethodRegistry(ctx).lookup('save', 'scope:call', {
explicitReceiver: { name: 'user' },
});
expect(results).toHaveLength(1);
expect(results[0]!.def).toBe(saveMethod);
const typeBinding = evidenceOfKind(results[0]!, 'type-binding');
expect(typeBinding?.weight).toBe(EvidenceWeights.typeBindingByMroDepth[0]);
});
it('walks up the MRO when the method is declared on an ancestor', () => {
const baseClass = mkDef({ nodeId: 'def:Base', type: 'Class', qualifiedName: 'Base' });
const derivedClass = mkDef({ nodeId: 'def:Derived', type: 'Class', qualifiedName: 'Derived' });
const saveOnBase = mkDef({
nodeId: 'def:Base.save',
type: 'Method',
qualifiedName: 'Base.save',
ownerId: 'def:Base',
});
const callScope = mkScope({
id: 'scope:call',
parent: null,
typeBindings: { d: typeRef('Derived', 'scope:call') },
});
const ctx = makeCtx([callScope], [baseClass, derivedClass, saveOnBase], {
mro: { 'def:Derived': ['def:Base'] },
});
const results = buildMethodRegistry(ctx).lookup('save', 'scope:call', {
explicitReceiver: { name: 'd' },
});
expect(results).toHaveLength(1);
expect(results[0]!.def).toBe(saveOnBase);
// MRO depth for Base when receiver is Derived = 1.
expect(evidenceOfKind(results[0]!, 'type-binding')?.weight).toBe(
EvidenceWeights.typeBindingByMroDepth[1],
);
});
});
// ─── §4.7 invariants ──────────────────────────────────────────────────────
describe('§4.7 invariants', () => {
it('Resolution has confidence per-candidate (not per-tier)', () => {
const cls = mkDef({ nodeId: 'def:c', type: 'Class' });
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { X: [mkBinding(cls, 'local')] },
});
const ctx = makeCtx([mod], [cls]);
const results = buildClassRegistry(ctx).lookup('X', 'scope:m');
expect(typeof results[0]!.confidence).toBe('number');
expect(results[0]!.confidence).toBeGreaterThan(0);
expect(results[0]!.confidence).toBeLessThanOrEqual(1);
});
it('Resolution confidence is capped at 1.0', () => {
const cls = mkDef({ nodeId: 'def:c', type: 'Class' });
const dummyVia: ImportEdge = {
localName: 'X',
targetFile: 't.ts',
targetExportedName: 'X',
kind: 'named',
};
const mod = mkScope({
id: 'scope:m',
parent: null,
// Same def bound via multiple origins — evidence may stack.
bindings: { X: [mkBinding(cls, 'local', dummyVia)] },
});
const ctx = makeCtx([mod], [cls], { arity: () => 'compatible' });
const results = buildClassRegistry(ctx).lookup('X', 'scope:m');
expect(results[0]!.confidence).toBeLessThanOrEqual(1);
});
it('kind-match evidence is always present (weight 0) for debuggability', () => {
const cls = mkDef({ nodeId: 'def:c', type: 'Class' });
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { X: [mkBinding(cls, 'local')] },
});
const ctx = makeCtx([mod], [cls]);
const results = buildClassRegistry(ctx).lookup('X', 'scope:m');
expect(evidenceOfKind(results[0]!, 'kind-match')).toBeDefined();
expect(evidenceOfKind(results[0]!, 'kind-match')!.weight).toBe(0);
});
it('caller can read [0] for one-shot answers', () => {
const cls = mkDef({ nodeId: 'def:c', type: 'Class' });
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { X: [mkBinding(cls, 'local')] },
});
const ctx = makeCtx([mod], [cls]);
const results = buildClassRegistry(ctx).lookup('X', 'scope:m');
expect(results[0]!.def).toBe(cls);
});
});
// ─── Misses ───────────────────────────────────────────────────────────────
describe('misses', () => {
it('returns empty for an unknown name with no lexical or global hit', () => {
const mod = mkScope({ id: 'scope:m', parent: null });
const ctx = makeCtx([mod], []);
expect(buildClassRegistry(ctx).lookup('Ghost', 'scope:m')).toEqual([]);
});
it('filters out candidates whose kind is not in acceptedKinds', () => {
const method = mkDef({ nodeId: 'def:m', type: 'Method' });
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { save: [mkBinding(method, 'local')] },
});
const ctx = makeCtx([mod], [method]);
// ClassRegistry excludes Method kind → empty.
expect(buildClassRegistry(ctx).lookup('save', 'scope:m')).toEqual([]);
// FieldRegistry also excludes Method → empty.
expect(buildFieldRegistry(ctx).lookup('save', 'scope:m')).toEqual([]);
});
});
// ─── lookupCore direct invocation ─────────────────────────────────────────
describe('lookupCore direct invocation', () => {
it('accepts an empty params surface and returns empty for an unknown name', () => {
const mod = mkScope({ id: 'scope:m', parent: null });
const ctx = makeCtx([mod], []);
const results = lookupCore(
'Ghost',
'scope:m',
{
acceptedKinds: ['Class'],
useReceiverTypeBinding: false,
ownerScopedContributor: null,
},
ctx,
);
expect(results).toEqual([]);
});
});