From 1bf9fb4ef1221884b1f043dcebc0296fe8108002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 18 Apr 2026 17:58:26 +0100 Subject: [PATCH] feat(shared): ClassRegistry / MethodRegistry / FieldRegistry + 7-step lookup (#917, RFC #909 Ring 2 SHARED) (#963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- gitnexus-shared/src/index.ts | 32 + .../registries/class-registry.ts | 41 ++ .../scope-resolution/registries/context.ts | 110 +++ .../scope-resolution/registries/evidence.ts | 191 +++++ .../registries/field-registry.ts | 43 ++ .../registries/lookup-core.ts | 447 ++++++++++++ .../registries/lookup-qualified.ts | 71 ++ .../registries/method-registry.ts | 54 ++ .../scope-resolution/registries/tie-breaks.ts | 76 ++ .../unit/scope-resolution/registries.test.ts | 669 ++++++++++++++++++ 10 files changed, 1734 insertions(+) create mode 100644 gitnexus-shared/src/scope-resolution/registries/class-registry.ts create mode 100644 gitnexus-shared/src/scope-resolution/registries/context.ts create mode 100644 gitnexus-shared/src/scope-resolution/registries/evidence.ts create mode 100644 gitnexus-shared/src/scope-resolution/registries/field-registry.ts create mode 100644 gitnexus-shared/src/scope-resolution/registries/lookup-core.ts create mode 100644 gitnexus-shared/src/scope-resolution/registries/lookup-qualified.ts create mode 100644 gitnexus-shared/src/scope-resolution/registries/method-registry.ts create mode 100644 gitnexus-shared/src/scope-resolution/registries/tie-breaks.ts create mode 100644 gitnexus/test/unit/scope-resolution/registries.test.ts diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index 723c4970b..255aa9345 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -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'; diff --git a/gitnexus-shared/src/scope-resolution/registries/class-registry.ts b/gitnexus-shared/src/scope-resolution/registries/class-registry.ts new file mode 100644 index 000000000..20a08e2b8 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/registries/class-registry.ts @@ -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); + }, + }; +} diff --git a/gitnexus-shared/src/scope-resolution/registries/context.ts b/gitnexus-shared/src/scope-resolution/registries/context.ts new file mode 100644 index 000000000..9adbbda2e --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/registries/context.ts @@ -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', +]); diff --git a/gitnexus-shared/src/scope-resolution/registries/evidence.ts b/gitnexus-shared/src/scope-resolution/registries/evidence.ts new file mode 100644 index 000000000..bacf6c30d --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/registries/evidence.ts @@ -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): 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, +): 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'; + } +} diff --git a/gitnexus-shared/src/scope-resolution/registries/field-registry.ts b/gitnexus-shared/src/scope-resolution/registries/field-registry.ts new file mode 100644 index 000000000..9e6a7aa0f --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/registries/field-registry.ts @@ -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); + }, + }; +} diff --git a/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts b/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts new file mode 100644 index 000000000..baea0d55e --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts @@ -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 { + 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(params.acceptedKinds); + const perCandidate = new Map(); + + // ── 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, + 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, + ctx: RegistryContext, + perCandidate: Map, +): boolean { + let currentId: ScopeId | null = startScope; + let depth = 0; + const visited = new Set(); + + 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, + 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, + params: CoreLookupParams, + ctx: RegistryContext, + perCandidate: Map, +): 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(); + 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, + 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, + perCandidate: Map, +): 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, + 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): readonly Resolution[] { + const resolutions: Resolution[] = []; + const tieKeys = new Map(); + + 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([]); diff --git a/gitnexus-shared/src/scope-resolution/registries/lookup-qualified.ts b/gitnexus-shared/src/scope-resolution/registries/lookup-qualified.ts new file mode 100644 index 000000000..21b630486 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/registries/lookup-qualified.ts @@ -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(params.acceptedKinds); + + const resolutions: Resolution[] = []; + const tieKeys = new Map(); + + 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([]); diff --git a/gitnexus-shared/src/scope-resolution/registries/method-registry.ts b/gitnexus-shared/src/scope-resolution/registries/method-registry.ts new file mode 100644 index 000000000..ed206d164 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/registries/method-registry.ts @@ -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); + }, + }; +} diff --git a/gitnexus-shared/src/scope-resolution/registries/tie-breaks.ts b/gitnexus-shared/src/scope-resolution/registries/tie-breaks.ts new file mode 100644 index 000000000..9d6f0dee9 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/registries/tie-breaks.ts @@ -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, +): 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', +}); diff --git a/gitnexus/test/unit/scope-resolution/registries.test.ts b/gitnexus/test/unit/scope-resolution/registries.test.ts new file mode 100644 index 000000000..d47badc78 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/registries.test.ts @@ -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 & { 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; + ownedDefs?: readonly SymbolDefinition[]; + typeBindings?: Record; +} + +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; + implsByInterface?: Record; + 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([]); + }); +});