/** * Unit tests for the scope-aware registries (RFC §4; Ring 2 SHARED #917). * * Tests are organized per RFC §4.2 step so a regression localizes to the * step it broke: * * §4.2 Step 1 — lexical scope-chain walk + shadowing * §4.2 Step 2 — type-binding / MRO walk (method/field registries) * §4.2 Step 3 — owner-scoped contributor * §4.2 Step 4 — kind filter + kind-match evidence * §4.2 Step 5 — arity filter * §4.2 Step 6 — global-qualified fallback * §4.2 Step 7 — rank + tie-break cascade * §4.5 — lookupQualified helper * §4.7 — invariants * * Corroborators (owner-match, unresolved-import cap, dynamic-unresolved) * get their own sections. */ import { describe, it, expect } from 'vitest'; import { buildClassRegistry, buildFieldRegistry, buildMethodRegistry, buildDefIndex, buildMethodDispatchIndex, buildModuleScopeIndex, buildQualifiedNameIndex, buildScopeTree, lookupCore, lookupQualified, EvidenceWeights, type BindingRef, type ImportEdge, type Range, type RegistryContext, type Resolution, type Scope, type ScopeId, type ScopeKind, type SymbolDefinition, type TypeRef, } from 'gitnexus-shared'; // ─── Test helpers ─────────────────────────────────────────────────────────── const r = (startLine: number, startCol: number, endLine: number, endCol: number): Range => ({ startLine, startCol, endLine, endCol, }); const mkDef = (overrides: Partial & { nodeId: string }): SymbolDefinition => ({ nodeId: overrides.nodeId, filePath: overrides.filePath ?? 'x.ts', type: overrides.type ?? 'Class', ...overrides, }); const mkBinding = ( def: SymbolDefinition, origin: BindingRef['origin'], via?: ImportEdge, ): BindingRef => ({ def, origin, ...(via !== undefined ? { via } : {}) }); interface ScopeSpec { id: ScopeId; parent: ScopeId | null; kind?: ScopeKind; range?: Range; filePath?: string; bindings?: Record; ownedDefs?: readonly SymbolDefinition[]; typeBindings?: Record; } const mkScope = (s: ScopeSpec): Scope => ({ id: s.id, parent: s.parent, kind: s.kind ?? 'Module', range: s.range ?? r(1, 0, 1000, 0), filePath: s.filePath ?? 'x.ts', bindings: new Map(Object.entries(s.bindings ?? {})), ownedDefs: s.ownedDefs ?? [], imports: [], typeBindings: new Map(Object.entries(s.typeBindings ?? {})), }); const typeRef = (rawName: string, declaredAtScope: ScopeId): TypeRef => ({ rawName, declaredAtScope, source: 'parameter-annotation', }); function makeCtx( scopes: Scope[], defs: SymbolDefinition[], opts: { mro?: Record; implsByInterface?: Record; 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([]); }); });