mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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:
parent
a9a5e1c388
commit
1bf9fb4ef1
10 changed files with 1734 additions and 0 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
110
gitnexus-shared/src/scope-resolution/registries/context.ts
Normal file
110
gitnexus-shared/src/scope-resolution/registries/context.ts
Normal 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',
|
||||
]);
|
||||
191
gitnexus-shared/src/scope-resolution/registries/evidence.ts
Normal file
191
gitnexus-shared/src/scope-resolution/registries/evidence.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
447
gitnexus-shared/src/scope-resolution/registries/lookup-core.ts
Normal file
447
gitnexus-shared/src/scope-resolution/registries/lookup-core.ts
Normal 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([]);
|
||||
|
|
@ -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([]);
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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',
|
||||
});
|
||||
669
gitnexus/test/unit/scope-resolution/registries.test.ts
Normal file
669
gitnexus/test/unit/scope-resolution/registries.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue