mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-09 22:33:39 +00:00
* perf(scope-resolution): use owner-keyed lookup for Step 2 member resolution (#1656) * chore(autofix): apply prettier + eslint fixes via /autofix command * fix(scope-resolution): index Const/Static in FieldRegistry for Step 2 lookup Extend FieldRegistry to hold multiple defs per (owner, name), reconcile Const and Static into the owner-keyed index, and wire lookupAllByOwner through the production hook so Step 2 does not drop field kinds the registry never indexed. Pass explicitReceiver on read/write reference sites and document undefined-vs-empty hook semantics for defs fallback. Co-authored-by: Cursor <cursoragent@cursor.com> * perf(scope-resolution): centralize O(1) owned-member hook and guard hot path Extract lookupOwnedMembersByOwner for the production Step 2 hook so merges stay O(1) per registry with no defs.byId scan. Add a perf-contract unit test that throws if byId.values runs when the hook is wired. Reuse a frozen empty sentinel on double miss to avoid per-probe allocations. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: drop unused buildFieldRegistry import * chore(scope-resolution): apply ce-code-review safe_auto fixes - Drop unreachable return + unused values() capture in perf-contract trap (Finding #7) - Type lookupOwnedMembersByOwner ownerDefId as DefId (Finding #9) - Add Static-kind Step 2 lookup test mirroring the Const case (Finding #11) * docs(field-registry): document lookupFieldByOwner first-wins semantics Audit of all 6 production callers (call-processor.ts:2279, walkers.ts:535, receiver-bound-calls.ts:380+730, type-env.ts:627+631) confirms none depends on last-wins precedence — all treat the return as a generic 'field with this name owned by this class'. Clarify the JSDoc to surface the semantic change introduced when FieldRegistry moved from last-wins to append-order storage (ce-code-review finding #2). * test(scope-resolution): extend Step 2 perf contract to implicit-self, MRO, field paths Adds three sibling tests under the Step 2 perf contract describe block, each asserting defs.byId.values() does NOT execute when ownedMembersByOwner is wired: - implicit-self receiver via typeBindings.self (no explicitReceiver branch) - 2-level MRO chain (Child extends Parent, save resolves on Parent at depth 1) - FieldRegistry read via Step 2 (property lookup, separate registry path) Pins the perf invariant on every distinct entry into walkReceiverTypeBinding so a regression bypassing the hook on any sub-path now fails CI immediately (ce-code-review finding #8). * test(resolve-references): cover arity-overload filtering via resolveReferenceSites Pins the orchestration-layer wiring of providers.arityCompatibility: hook returns [save(arity 1), save(arity 2)], referenceSite.arity = 1, arityCompatibility verdicts 'compatible'/'incompatible' by parameterCount, exactly one reference emitted with toDef = the arity-1 overload. registries.test.ts already covered arity at the buildMethodRegistry level; this adds the missing entry-point check that resolveReferenceSites threads providers correctly through to lookupCore.Step5 (ce-code-review finding #10). * test(resolve-references): add hook-on vs hook-off parity test Runs resolveReferenceSites twice on the same fixture (Parent.save method hit + Child.name field hit, Child extends Parent MRO chain) — once with ownedMembersByOwner wired to a synthetic registry, once with the hook absent so collectOwnedMembers takes the defs.byId fallback. Asserts: - stats are identical (sitesProcessed / referencesEmitted / unresolved) - referenceIndex.bySourceScope entries have equal length - toDef sets are equal - each per-site reference (including evidence and depth) is .toEqual Locks the semantic-parity claim in code while both paths still exist. Will be removed alongside the fallback in finding #1 (ce-code-review #3). * test(typescript): probe Step 2 MRO walk against ambient (declare class) base Adds typescript-ambient-base-class fixture with an export declare class AmbientBase + Derived extends AmbientBase and a call site d.ambientMethod(). Integration assertions: - Both classes are detected - EXTENDS edge Derived → AmbientBase emitted - CALLS edge to ambient.ts:ambientMethod resolved via MRO walk Probes the ce-code-review #6 concern that ambient-only owners (whose bodies are never parsed) might be silently skipped by Step 2 after the owner-keyed lookup change. Result: the call resolves correctly — the method signature inside the declare class body still flows through reconcileOwnership into model.methods, so the hook returns the right ancestor hits. Residual risk is empirically closed. * feat(scope-resolution): route nested types via owner-keyed TypeRegistry Closes the Step 2 contract footgun where 'hook returns [] = authoritative miss' silently dropped any owned def whose NodeLabel was outside the method/field if-chain in reconcileOwnership. - TypeRegistry: add nestedByOwner Map + lookupAllByOwner(owner, simple) + registerByOwner(owner, simple, def). Mirrors MethodRegistry/ FieldRegistry shape; cleared with the rest on cascade clear. - reconcileOwnership: route class-like NodeLabels (Class/Interface/Enum/ Struct/Union/Trait/TypeAlias/Typedef/Record/Delegate/Annotation/ Template/Namespace) via types.registerByOwner. New nestedTypesRegistered stat. Idempotent skip via nodeId match. - validateOwnershipParity: extend the I9 invariant check to nested types. - lookupOwnedMembersByOwner: merge methods + fields + nested-type hits; short-circuit when any one source contributes the full result. Unblocks future receiver-MRO registries that need to resolve 'Outer.Inner' through the receiver's type-binding chain (ce-code-review finding #5a). * refactor(scope-resolution): make ownedMembersByOwner required; delete byId fallback Per ce-code-review finding #1, the optional-hook design encoded a silent O(|defs|) perf cliff into the type system: any RegistryContext built without the hook regressed Step 2 to scanning every def per probe with no warning. Production wires the hook unconditionally; the fallback was exercised only by tests. - RegistryContext.ownedMembersByOwner: required, returns readonly SymbolDefinition[] (no | undefined). Implementations MUST return [] on authoritative miss. - collectOwnedMembers in lookup-core.ts collapses to a one-line forward to the hook; the defs.byId.values() scan and simpleNameOf helper are deleted (simpleNameOf had no other consumers). - ResolveReferencesInput.ownedMembersByOwner: required to match. - Tests: drop three fallback-path tests (registries Const fallback, resolveReferenceSites no-hook fallback, resolveReferenceSites Const- undefined fallback) and the hook-vs-fallback parity test added by finding #3. makeCtx in registries.test.ts now defaults to a real owner-keyed scan over the test fixture defs so tests that don't care about the hook keep working. * perf(free-call-fallback): cache global callables by simple name once per pass pickUniqueGlobalCallable scanned scopes.defs.byId.values() on every free-call fallback site. After PR #1656 fixed Step 2, this scan became the dominant remaining O(|defs|) hot path on large repos (ce-code-review finding #4). - buildGlobalCallableIndex builds a Map<simpleName, SymbolDefinition[]> over scopes.defs once at the top of emitFreeCallFallback. Same filter the per-site scan applied: Function / Method / Constructor, keyed by the last .-segment of qualifiedName. - pickUniqueGlobalCallable consumes the prebuilt index via O(1) Map.get instead of iterating every def. Per-site complexity drops from O(|defs|) to O(|defs with this simple name|). - Cost: O(|defs|) once per pass instead of O(|defs| * |free-call sites|). Subsequent narrowing (arity, conversion-rank) and the model-side fallback (model.symbols.lookupCallableByName + model.methods.lookupMethodByName) are unchanged. * chore(autofix): apply prettier + eslint fixes via /autofix command * ci: trigger build --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Gergo Magyar <abhigyan1.patwari@gmail.com>
978 lines
36 KiB
TypeScript
978 lines
36 KiB
TypeScript
/**
|
|
* 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[]>;
|
|
ownedMembersByOwner?: RegistryContext['ownedMembersByOwner'];
|
|
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;
|
|
},
|
|
});
|
|
// Default hook: scan supplied defs by (ownerId, simpleName) — the same
|
|
// semantics the byId fallback used to provide. Tests that need a custom
|
|
// hook override via opts.ownedMembersByOwner.
|
|
const defaultOwnedMembersByOwner = (ownerDefId: string, memberName: string) => {
|
|
const out: SymbolDefinition[] = [];
|
|
for (const def of defs) {
|
|
if (def.ownerId !== ownerDefId) continue;
|
|
const dot = def.qualifiedName?.lastIndexOf('.') ?? -1;
|
|
const simple = dot === -1 ? def.qualifiedName : def.qualifiedName?.slice(dot + 1);
|
|
if (simple === memberName) out.push(def);
|
|
}
|
|
return out;
|
|
};
|
|
return {
|
|
scopes: buildScopeTree(scopes),
|
|
defs: defIndex,
|
|
qualifiedNames: qualifiedNameIndex,
|
|
moduleScopes,
|
|
ownedMembersByOwner: opts.ownedMembersByOwner ?? defaultOwnedMembersByOwner,
|
|
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('drops every candidate when ALL are incompatible AND none unknown (hard rejection)', () => {
|
|
// Post-commit af9af4a9 (PR #1497 / U1): the old soft-penalty fallback
|
|
// that kept incompatible candidates with `arityMatchIncompatible`
|
|
// weight was deliberately removed at this layer too. When every
|
|
// candidate is definitively arity-incompatible, the registry returns
|
|
// no resolution — matching the PHP variadic case `f(int $req, ...$rest)`
|
|
// called with zero args.
|
|
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(0);
|
|
});
|
|
|
|
it('keeps incompatible candidates when at least one verdict is unknown (soft penalty)', () => {
|
|
// The soft-rescue path is still active when at least one candidate's
|
|
// arity verdict is 'unknown' — that signals missing metadata rather
|
|
// than a definitive mismatch, so all candidates (including incompatible
|
|
// ones) are preserved with their evidence weights for downstream
|
|
// tie-breaking.
|
|
const save3 = mkDef({
|
|
nodeId: 'def:save-three',
|
|
type: 'Method',
|
|
qualifiedName: 'User.save',
|
|
parameterCount: 3,
|
|
});
|
|
const saveUnknown = mkDef({
|
|
nodeId: 'def:save-unknown',
|
|
type: 'Method',
|
|
qualifiedName: 'User.save',
|
|
});
|
|
const mod = mkScope({
|
|
id: 'scope:m',
|
|
parent: null,
|
|
bindings: { save: [mkBinding(save3, 'local'), mkBinding(saveUnknown, 'local')] },
|
|
});
|
|
const ctx = makeCtx([mod], [save3, saveUnknown], {
|
|
arity: (_callsite, def) => (def.nodeId === 'def:save-unknown' ? 'unknown' : 'incompatible'),
|
|
});
|
|
const results = buildMethodRegistry(ctx).lookup('save', 'scope:m', {
|
|
callsite: { arity: 1 },
|
|
});
|
|
expect(results).toHaveLength(2);
|
|
const incompat = results.find((r) => r.def.nodeId === 'def:save-three');
|
|
expect(incompat).toBeDefined();
|
|
expect(evidenceOfKind(incompat!, '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('inner scope shadows outer, yielding single result (hard-shadow baseline)', () => {
|
|
// Baseline: the hard-shadow rule in Step 1 means a near binding fully
|
|
// replaces the far one. No "confidence DESC" ordering to observe here
|
|
// because there is only one candidate — the far class never enters
|
|
// the result set. See the next test for true multi-candidate ranking.
|
|
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');
|
|
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0]!.def).toBe(nearClass);
|
|
});
|
|
|
|
it('orders multiple same-scope candidates by confidence DESC', () => {
|
|
// Two candidates co-exist at the same scope, one with origin=local
|
|
// (weight 0.55) and one with origin=wildcard (weight 0.30). Both pass
|
|
// the Class kind filter; confidence DESC should sort local first.
|
|
const localClass = mkDef({ nodeId: 'def:local', type: 'Class' });
|
|
const wildcardClass = mkDef({ nodeId: 'def:wildcard', type: 'Class' });
|
|
const mod = mkScope({
|
|
id: 'scope:m',
|
|
parent: null,
|
|
bindings: {
|
|
User: [mkBinding(wildcardClass, 'wildcard'), mkBinding(localClass, 'local')],
|
|
},
|
|
});
|
|
const ctx = makeCtx([mod], [localClass, wildcardClass]);
|
|
const results = buildClassRegistry(ctx).lookup('User', 'scope:m');
|
|
expect(results).toHaveLength(2);
|
|
expect(results[0]!.def).toBe(localClass); // local (0.55) > wildcard (0.30)
|
|
expect(results[1]!.def).toBe(wildcardClass);
|
|
expect(results[0]!.confidence).toBeGreaterThan(results[1]!.confidence);
|
|
});
|
|
|
|
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('uses ownedMembersByOwner before falling back to defs scans', () => {
|
|
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], {
|
|
ownedMembersByOwner: (ownerDefId, memberName) =>
|
|
ownerDefId === 'def:User' && memberName === 'save' ? [saveMethod] : [],
|
|
});
|
|
|
|
const results = buildMethodRegistry(ctx).lookup('save', 'scope:call', {
|
|
explicitReceiver: { name: 'user' },
|
|
});
|
|
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0]!.def).toBe(saveMethod);
|
|
expect(evidenceOfKind(results[0]!, 'type-binding')?.weight).toBe(
|
|
EvidenceWeights.typeBindingByMroDepth[0],
|
|
);
|
|
});
|
|
|
|
it('keeps hook-provided overloads available for arity filtering', () => {
|
|
const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' });
|
|
const saveOne = mkDef({
|
|
nodeId: 'def:User.save1',
|
|
type: 'Method',
|
|
qualifiedName: 'User.save',
|
|
ownerId: 'def:User',
|
|
parameterCount: 1,
|
|
});
|
|
const saveTwo = mkDef({
|
|
nodeId: 'def:User.save2',
|
|
type: 'Method',
|
|
qualifiedName: 'User.save',
|
|
ownerId: 'def:User',
|
|
parameterCount: 2,
|
|
});
|
|
const callScope = mkScope({
|
|
id: 'scope:call',
|
|
parent: null,
|
|
typeBindings: { user: typeRef('User', 'scope:call') },
|
|
});
|
|
const ctx = makeCtx([callScope], [userClass], {
|
|
ownedMembersByOwner: (ownerDefId, memberName) =>
|
|
ownerDefId === 'def:User' && memberName === 'save' ? [saveTwo, saveOne] : [],
|
|
arity: (callsite, def) =>
|
|
(def.parameterCount ?? 0) === callsite.arity ? 'compatible' : 'incompatible',
|
|
});
|
|
|
|
const results = buildMethodRegistry(ctx).lookup('save', 'scope:call', {
|
|
explicitReceiver: { name: 'user' },
|
|
callsite: { arity: 1 },
|
|
});
|
|
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0]!.def).toBe(saveOne);
|
|
});
|
|
|
|
it('resolves field members from ownedMembersByOwner through accepted-kind filtering', () => {
|
|
const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' });
|
|
const nameField = mkDef({
|
|
nodeId: 'def:User.name',
|
|
type: 'Property',
|
|
qualifiedName: 'User.name',
|
|
ownerId: 'def:User',
|
|
});
|
|
const readScope = mkScope({
|
|
id: 'scope:read',
|
|
parent: null,
|
|
typeBindings: { user: typeRef('User', 'scope:read') },
|
|
});
|
|
const ctx = makeCtx([readScope], [userClass], {
|
|
ownedMembersByOwner: (ownerDefId, memberName) =>
|
|
ownerDefId === 'def:User' && memberName === 'name' ? [nameField] : [],
|
|
});
|
|
|
|
const results = buildFieldRegistry(ctx).lookup('name', 'scope:read', {
|
|
explicitReceiver: { name: 'user' },
|
|
});
|
|
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0]!.def).toBe(nameField);
|
|
});
|
|
|
|
it('resolves Const members from ownedMembersByOwner through accepted-kind filtering', () => {
|
|
const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' });
|
|
const maxConst = mkDef({
|
|
nodeId: 'def:User.MAX',
|
|
type: 'Const',
|
|
qualifiedName: 'User.MAX',
|
|
ownerId: 'def:User',
|
|
});
|
|
const readScope = mkScope({
|
|
id: 'scope:read',
|
|
parent: null,
|
|
typeBindings: { user: typeRef('User', 'scope:read') },
|
|
});
|
|
const ctx = makeCtx([readScope], [userClass], {
|
|
ownedMembersByOwner: (ownerDefId, memberName) =>
|
|
ownerDefId === 'def:User' && memberName === 'MAX' ? [maxConst] : [],
|
|
});
|
|
|
|
const results = buildFieldRegistry(ctx).lookup('MAX', 'scope:read', {
|
|
explicitReceiver: { name: 'user' },
|
|
});
|
|
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0]!.def).toBe(maxConst);
|
|
});
|
|
|
|
it('resolves Static members from ownedMembersByOwner through accepted-kind filtering', () => {
|
|
const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' });
|
|
const counterStatic = mkDef({
|
|
nodeId: 'def:User.counter',
|
|
type: 'Static',
|
|
qualifiedName: 'User.counter',
|
|
ownerId: 'def:User',
|
|
});
|
|
const readScope = mkScope({
|
|
id: 'scope:read',
|
|
parent: null,
|
|
typeBindings: { user: typeRef('User', 'scope:read') },
|
|
});
|
|
const ctx = makeCtx([readScope], [userClass], {
|
|
ownedMembersByOwner: (ownerDefId, memberName) =>
|
|
ownerDefId === 'def:User' && memberName === 'counter' ? [counterStatic] : [],
|
|
});
|
|
|
|
const results = buildFieldRegistry(ctx).lookup('counter', 'scope:read', {
|
|
explicitReceiver: { name: 'user' },
|
|
});
|
|
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0]!.def).toBe(counterStatic);
|
|
});
|
|
|
|
it('returns every hook-provided field kind that shares (owner, name)', () => {
|
|
const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' });
|
|
const legacyProp = mkDef({
|
|
nodeId: 'prop:User.name',
|
|
type: 'Property',
|
|
qualifiedName: 'User.name',
|
|
ownerId: 'def:User',
|
|
});
|
|
const reconciledVar = mkDef({
|
|
nodeId: 'def:User.name',
|
|
type: 'Variable',
|
|
qualifiedName: 'User.name',
|
|
ownerId: 'def:User',
|
|
});
|
|
const readScope = mkScope({
|
|
id: 'scope:read',
|
|
parent: null,
|
|
typeBindings: { user: typeRef('User', 'scope:read') },
|
|
});
|
|
const ctx = makeCtx([readScope], [userClass], {
|
|
ownedMembersByOwner: (ownerDefId, memberName) =>
|
|
ownerDefId === 'def:User' && memberName === 'name' ? [legacyProp, reconciledVar] : [],
|
|
});
|
|
|
|
const results = buildFieldRegistry(ctx).lookup('name', 'scope:read', {
|
|
explicitReceiver: { name: 'user' },
|
|
});
|
|
|
|
expect(results).toHaveLength(2);
|
|
expect(results.map((r) => r.def.nodeId).sort()).toEqual(
|
|
[legacyProp.nodeId, reconciledVar.nodeId].sort(),
|
|
);
|
|
});
|
|
|
|
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('demotes Step-2-only candidates to tieBreakKey.origin=import (pins rank vs same-origin siblings)', () => {
|
|
// Two method defs named `impl`, both owned by the same interface and
|
|
// both reached ONLY via the Step 2 type-binding MRO walk (no lexical
|
|
// binding). Each candidate's `recordTypeBindingHit` path demotes its
|
|
// `tieBreakKey.origin` from the `ensureCandidate` default `'local'`
|
|
// to `'import'`. With both at equal confidence (owner-match + type-
|
|
// binding at depth 0), the tie-break cascade must fall through
|
|
// scope-depth / MRO-depth / origin (all equal) to DefId.localeCompare.
|
|
//
|
|
// If the demotion regressed (e.g., tieBreakKey.origin left as `'local'`),
|
|
// both candidates would still share the same origin and this test would
|
|
// pass by coincidence — so the test ALSO asserts `signals.origin` is
|
|
// absent from the evidence list (no false where-found weight emitted),
|
|
// which is the strongest observable invariant the demotion guarantees.
|
|
const iface = mkDef({
|
|
nodeId: 'def:Iface',
|
|
type: 'Interface',
|
|
qualifiedName: 'Iface',
|
|
});
|
|
const implA = mkDef({
|
|
nodeId: 'def:aaa.impl',
|
|
type: 'Method',
|
|
qualifiedName: 'Iface.impl',
|
|
ownerId: 'def:Iface',
|
|
});
|
|
const implB = mkDef({
|
|
nodeId: 'def:bbb.impl',
|
|
type: 'Method',
|
|
qualifiedName: 'Iface.impl',
|
|
ownerId: 'def:Iface',
|
|
});
|
|
const scope = mkScope({
|
|
id: 'scope:call',
|
|
parent: null,
|
|
typeBindings: { x: typeRef('Iface', 'scope:call') },
|
|
});
|
|
const ctx = makeCtx([scope], [iface, implA, implB]);
|
|
const results = buildMethodRegistry(ctx).lookup('impl', 'scope:call', {
|
|
explicitReceiver: { name: 'x' },
|
|
});
|
|
expect(results).toHaveLength(2);
|
|
// DefId.localeCompare: 'def:aaa.impl' < 'def:bbb.impl'.
|
|
expect(results[0]!.def).toBe(implA);
|
|
expect(results[1]!.def).toBe(implB);
|
|
// Demotion invariant: Step-2-only candidates have no `signals.origin`,
|
|
// so composeEvidence never emits a where-found signal for them.
|
|
for (const res of results) {
|
|
expect(evidenceOfKind(res, 'local')).toBeUndefined();
|
|
expect(evidenceOfKind(res, 'import')).toBeUndefined();
|
|
expect(evidenceOfKind(res, 'type-binding')).toBeDefined();
|
|
}
|
|
});
|
|
|
|
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([]);
|
|
});
|
|
});
|