fix(cpp): suppress receiver-bound CALLS when default-arg overloads collide (U4)

ISO C++ rejects 's.f(1)' as ambiguous when both 'void f(int)' and
'void f(int, int = 0)' are declared on S. The previous resolver
returned the first viable candidate via pickOverload's fallback.

Extended isOverloadAmbiguousAfterNormalization to take an optional
argCount: when provided, the predicate compares only the first
argCount slots of each candidate's parameterTypes. Candidates whose
declared-prefix matches up to argCount are treated as ambiguous
because default arguments make all of them equally viable for the
call.

Without argCount, behavior is unchanged (the original int/long
normalization-collapse contract, full-length equality required).
pickOverload now passes site.arity so default-arg ambiguity fires.

Test: cpp-overload-default-arg-ambiguous fixture. s.f(1) where S has
f(int) and f(int, int = 0) asserts exactly .toBe(0) CALLS edges.
Passes under both REGISTRY_PRIMARY_CPP=1 and =0.

All 2114 resolver integration tests pass; all 148 cpp tests pass
under both modes.
This commit is contained in:
Gergo Magyar 2026-05-13 16:09:24 +01:00
parent 6a1e2a9ed0
commit f543339a4f
6 changed files with 63 additions and 3 deletions

View file

@ -119,17 +119,32 @@ export function narrowOverloadCandidates(
*/
export function isOverloadAmbiguousAfterNormalization(
candidates: readonly SymbolDefinition[],
argCount?: number,
): boolean {
if (candidates.length < 2) return false;
const first = candidates[0].parameterTypes;
if (first === undefined) return false;
// When argCount is provided, compare only the first `argCount` slots —
// this catches default-argument ambiguity: `void f(int); void f(int, int = 0);`
// called with `f(1)` (argCount=1) leaves both candidates viable because
// default args make them arity-compatible, and their first slot is
// identical even though full parameterTypes lengths differ.
// Without argCount, fall back to full-sequence comparison (the original
// int/long normalization-collapse case).
const compareUpTo = argCount !== undefined ? argCount : first.length;
if (compareUpTo === 0) return false;
if (first.length < compareUpTo) return false;
for (let i = 1; i < candidates.length; i++) {
const p = candidates[i].parameterTypes;
if (p === undefined) return false;
if (p.length !== first.length) return false;
for (let j = 0; j < p.length; j++) {
if (p.length < compareUpTo) return false;
for (let j = 0; j < compareUpTo; j++) {
if (p[j] !== first[j]) return false;
}
// When argCount is NOT provided, also require length equality so
// distinct-arity candidates that happen to share a prefix don't
// collapse to ambiguous (preserves the original int/long contract).
if (argCount === undefined && p.length !== first.length) return false;
}
return true;
}

View file

@ -549,7 +549,7 @@ function pickOverload(
// The graph schema has no ambiguous-target edge model, so emitting one
// would arbitrarily pick a candidate and lie about the call's target.
// PR #1520 review follow-up plan U2 / Claude review Finding 5.
if (isOverloadAmbiguousAfterNormalization(candidates)) return OVERLOAD_AMBIGUOUS;
if (isOverloadAmbiguousAfterNormalization(candidates, site.arity)) return OVERLOAD_AMBIGUOUS;
return candidates[0] ?? overloads[0];
}

View file

@ -0,0 +1,6 @@
#include "service.h"
void run() {
S s;
s.f(1);
}

View file

@ -0,0 +1,4 @@
#include "service.h"
void S::f(int) {}
void S::f(int, int) {}

View file

@ -0,0 +1,7 @@
#pragma once
class S {
public:
void f(int);
void f(int, int = 0);
};

View file

@ -1854,3 +1854,31 @@ describe('C++ namespace-qualified call is not a super receiver', () => {
expect(getInstanceCalls[0].targetFilePath).toContain('singleton.h');
});
});
// ---------------------------------------------------------------------------
// U4 (follow-up plan 2026-05-13-001): default-argument overload ambiguity.
// `void f(int); void f(int, int = 0); f(1);` is ambiguous per ISO C++. The
// OVERLOAD_AMBIGUOUS sentinel from plan 2026-05-12-002 U2 should detect
// this case via isOverloadAmbiguousAfterNormalization.
// ---------------------------------------------------------------------------
describe('C++ default-argument overload ambiguity', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'cpp-overload-default-arg-ambiguous'),
() => {},
);
}, 60000);
it('s.f(1) emits zero CALLS edges when f(int) and f(int, int=0) both match', () => {
const calls = getRelationships(result, 'CALLS');
const fCalls = calls.filter((c) => c.source === 'run' && c.target === 'f');
// Exact .toBe(0): count=1 means arbitrary pick (the bug); count=2+ would
// require an ambiguous-target edge model GitNexus does not have. The
// resolver must suppress entirely. Standard C++ rejects the call as
// ambiguous (GCC/Clang both diagnose).
expect(fCalls.length).toBe(0);
});
});