/** * Unit tests for `narrowOverloadCandidates` — the shared overload- * narrowing utility used by `receiver-bound-calls.ts::pickOverload` * (explicit receiver member call) and * `free-call-fallback.ts::pickImplicitThisOverload` (implicit-`this` * free call). * * The utility is pure (data in / data out), so tests build synthetic * `SymbolDefinition` stubs — no fixtures, no pipeline. */ import { describe, it, expect } from 'vitest'; import type { SymbolDefinition } from 'gitnexus-shared'; import { narrowOverloadCandidates } from '../../../src/core/ingestion/scope-resolution/passes/overload-narrowing.js'; const mkDef = (overrides: Partial & { nodeId: string }): SymbolDefinition => ({ nodeId: overrides.nodeId, filePath: overrides.filePath ?? 'x.cs', type: overrides.type ?? 'Method', ...overrides, }); describe('narrowOverloadCandidates — empty input', () => { it('returns empty output for empty overload list', () => { expect(narrowOverloadCandidates([], 1, ['int'])).toEqual([]); expect(narrowOverloadCandidates([], undefined, undefined)).toEqual([]); }); }); describe('narrowOverloadCandidates — arity filtering', () => { const add1 = mkDef({ nodeId: 'add:1', parameterCount: 1, requiredParameterCount: 1 }); const add2 = mkDef({ nodeId: 'add:2', parameterCount: 2, requiredParameterCount: 2 }); const add3 = mkDef({ nodeId: 'add:3', parameterCount: 3, requiredParameterCount: 3 }); it('passes all overloads through when argCount is undefined', () => { const result = narrowOverloadCandidates([add1, add2, add3], undefined, undefined); expect(result.map((d) => d.nodeId)).toEqual(['add:1', 'add:2', 'add:3']); }); it('filters out overloads whose max is below argCount (non-variadic)', () => { const result = narrowOverloadCandidates([add1, add2, add3], 2, undefined); expect(result.map((d) => d.nodeId)).toEqual(['add:2']); }); it('filters out overloads whose required-count exceeds argCount', () => { const result = narrowOverloadCandidates([add1, add2, add3], 1, undefined); expect(result.map((d) => d.nodeId)).toEqual(['add:1']); }); it('accepts argCount above max when `params` variadic marker is present', () => { const writeLine = mkDef({ nodeId: 'wl:1', parameterCount: 2, requiredParameterCount: 1, parameterTypes: ['string', 'params object[]'], }); const result = narrowOverloadCandidates([writeLine], 5, undefined); expect(result.map((d) => d.nodeId)).toEqual(['wl:1']); }); it('accepts argCount above max when bare `params` marker is present', () => { const variadic = mkDef({ nodeId: 'v:1', parameterCount: 1, requiredParameterCount: 0, parameterTypes: ['params'], }); const result = narrowOverloadCandidates([variadic], 4, undefined); expect(result.map((d) => d.nodeId)).toEqual(['v:1']); }); it('returns empty when arity filter empties the set AND every candidate had definite bounds', () => { // argCount=5 doesn't match any overload (none variadic, all have max < 5). // Post-commit af9af4a9 (PR #1497 / U1): the empty result is now authoritative // because every rejected candidate had defined `parameterCount` / // `requiredParameterCount`. The old "always fall back to full list" rescue // was deliberately removed so resolvers actually drop calls that are // definitively arity-incompatible (e.g., PHP `f(int $req, ...$rest)` // called with zero args). const result = narrowOverloadCandidates([add1, add2, add3], 5, undefined); expect(result.map((d) => d.nodeId)).toEqual([]); }); // Note: the `anyUnknownBounds ? overloads : []` branch in // narrowOverloadCandidates is structurally unreachable in this caller's // shape — a candidate with both `parameterCount` and `requiredParameterCount` // undefined always passes the arity filter (neither `argCount > max` nor // `argCount < min` can fire), so `arityMatches.length` is always > 0 // whenever `anyUnknownBounds` is true. The branch is preserved in the // source as a defensive guard for future refactors that might add // additional rejection criteria in the filter. }); describe('narrowOverloadCandidates — type narrowing', () => { const byInt = mkDef({ nodeId: 'm:int', parameterCount: 1, requiredParameterCount: 1, parameterTypes: ['int'], }); const byString = mkDef({ nodeId: 'm:string', parameterCount: 1, requiredParameterCount: 1, parameterTypes: ['string'], }); it('picks the overload whose parameterTypes[i] equals argTypes[i]', () => { const result = narrowOverloadCandidates([byInt, byString], 1, ['string']); expect(result.map((d) => d.nodeId)).toEqual(['m:string']); }); it('treats empty-string argTypes slot as "unknown" and matches every candidate', () => { const result = narrowOverloadCandidates([byInt, byString], 1, ['']); // Both candidates survive because "" is an unknown slot. expect(result.map((d) => d.nodeId).sort()).toEqual(['m:int', 'm:string']); }); it('falls through to arity-filtered candidates when type filter matches nothing', () => { const result = narrowOverloadCandidates([byInt, byString], 1, ['bool']); // Type mismatch against both — falls back to arity candidates. expect(result.map((d) => d.nodeId).sort()).toEqual(['m:int', 'm:string']); }); it('skips the type filter entirely when argTypes is undefined', () => { const result = narrowOverloadCandidates([byInt, byString], 1, undefined); expect(result.map((d) => d.nodeId).sort()).toEqual(['m:int', 'm:string']); }); it('skips the type filter entirely when argTypes is empty', () => { const result = narrowOverloadCandidates([byInt, byString], 1, []); expect(result.map((d) => d.nodeId).sort()).toEqual(['m:int', 'm:string']); }); it('disqualifies an overload with missing parameterTypes under type filter', () => { const noTypes = mkDef({ nodeId: 'm:notypes', parameterCount: 1, requiredParameterCount: 1, }); const result = narrowOverloadCandidates([byInt, noTypes], 1, ['int']); expect(result.map((d) => d.nodeId)).toEqual(['m:int']); }); }); describe('narrowOverloadCandidates — constraint filter monotonicity (issue #1579)', () => { // Language-agnostic contract: when `constraintCompatibility` returns // 'unknown' for every candidate, the filter must keep every candidate. // Adding a predicate to the registry can only narrow correctly, never // produce a wrong edge — this guarantees the worst-case behavior is // today's "degrade not lie" suppression, not a regression. const a = mkDef({ nodeId: 'a', parameterCount: 1, requiredParameterCount: 1, parameterTypes: ['T'], templateConstraints: { dummy: true }, }); const b = mkDef({ nodeId: 'b', parameterCount: 1, requiredParameterCount: 1, parameterTypes: ['T'], templateConstraints: { dummy: true }, }); it('keeps every candidate when constraintCompatibility returns unknown for all', () => { const result = narrowOverloadCandidates([a, b], 1, ['int'], { constraintCompatibility: () => 'unknown', }); expect(result.map((d) => d.nodeId).sort()).toEqual(['a', 'b']); }); it('drops only candidates the hook explicitly marks incompatible', () => { const result = narrowOverloadCandidates([a, b], 1, ['int'], { constraintCompatibility: (_callsite, def) => def.nodeId === 'a' ? 'incompatible' : 'compatible', }); expect(result.map((d) => d.nodeId)).toEqual(['b']); }); it('skips the constraint filter when hookCtx is omitted (pre-#1579 behavior preserved)', () => { const result = narrowOverloadCandidates([a, b], 1, ['int']); expect(result.map((d) => d.nodeId).sort()).toEqual(['a', 'b']); }); it('skips the constraint filter for candidates without templateConstraints', () => { const plain = mkDef({ nodeId: 'plain', parameterCount: 1, requiredParameterCount: 1, parameterTypes: ['T'], }); // Even though the hook would return 'incompatible' for everything, the // candidate has no templateConstraints so the filter doesn't consult it. const result = narrowOverloadCandidates([plain], 1, ['int'], { constraintCompatibility: () => 'incompatible', }); expect(result.map((d) => d.nodeId)).toEqual(['plain']); }); });