diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts index f36287052..352e4b64d 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts @@ -88,3 +88,48 @@ export function narrowOverloadCandidates( return candidates; } + +/** + * Detect when >1 candidate share identical `parameterTypes` after the + * per-language normalizer has collapsed distinct underlying types. This + * signals "the resolver cannot pick the right overload — the + * normalization that helps single-candidate flows now hides a real + * ambiguity" and lets callers suppress the edge rather than pick + * arbitrarily. + * + * Concrete trigger (PR #1520 review follow-up plan U2, Claude review + * Finding 5): the C++ `arity-metadata.ts` normalizer collapses `int`, + * `long`, `short`, `unsigned`, and `size_t` to `'int'`. Without this + * check, `process(int)` and `process(long)` both end up with + * `parameterTypes === ['int']`, and `pickOverload` arbitrarily picks + * the first — emitting a false CALLS edge to the wrong overload. + * + * Returns false when: + * - 0 or 1 candidates (no ambiguity to detect) + * - any candidate has undefined `parameterTypes` (can't compare) + * - candidates differ in arity or in any parameter-type slot + * + * Other languages: this check is a precondition gate, not a behavior + * change for normal narrowing. Languages whose normalizers do not + * collapse distinct types (verified by grep over `*-arity-metadata.ts` + * — no `int → int` collapse outside C++) will never produce >1 + * candidate with identical `parameterTypes` from genuinely distinct + * declarations, so this returns false for them. The branch is + * effectively C++-only in practice. + */ +export function isOverloadAmbiguousAfterNormalization( + candidates: readonly SymbolDefinition[], +): boolean { + if (candidates.length < 2) return false; + const first = candidates[0].parameterTypes; + if (first === undefined) 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[j] !== first[j]) return false; + } + } + return true; +} diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 0cb544db9..e4178373c 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -51,7 +51,7 @@ import { import { tryEmitEdge } from '../graph-bridge/edges.js'; import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js'; import { resolveDefGraphId } from '../graph-bridge/ids.js'; -import { narrowOverloadCandidates } from './overload-narrowing.js'; +import { narrowOverloadCandidates, isOverloadAmbiguousAfterNormalization } from './overload-narrowing.js'; /** Subset of `ScopeResolver` consumed by this pass. Accepting the * subset rather than the full provider keeps tests and partial @@ -454,9 +454,24 @@ export function emitReceiverBoundCalls( if (ownerDef !== undefined) { const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)]; let memberDef: SymbolDefinition | undefined; + let ambiguous = false; for (const ownerId of chain) { - memberDef = pickOverload(ownerId, memberName, site, model); - if (memberDef !== undefined) break; + const picked = pickOverload(ownerId, memberName, site, model); + if (picked === OVERLOAD_AMBIGUOUS) { + ambiguous = true; + break; + } + if (picked !== undefined) { + memberDef = picked; + break; + } + } + if (ambiguous) { + // Suppress and mark handled so `emitReferencesViaLookup` + // doesn't re-emit the pre-resolved reference. See + // OVERLOAD_AMBIGUOUS docstring for the upstream cause. + handledSites.add(siteKey); + continue; } if (memberDef !== undefined) { // For read/write ACCESSES, mirror the legacy DAG's reason @@ -509,7 +524,7 @@ function pickOverload( memberName: string, site: ParsedFile['referenceSites'][number], model: SemanticModel, -): SymbolDefinition | undefined { +): SymbolDefinition | typeof OVERLOAD_AMBIGUOUS | undefined { const overloads = model.methods.lookupAllByOwner(ownerId, memberName); if (overloads.length === 0) { // Non-callable member (field / property / variable) — ACCESSES @@ -520,5 +535,22 @@ function pickOverload( if (overloads.length === 1) return overloads[0]; const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes); + // When narrowing leaves >1 candidate that share identical normalized + // parameter-types (e.g., C++ `f(int)` vs `f(long)` both collapsed to + // `['int']` by `normalizeCppParamType`), suppress the edge entirely. + // 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; return candidates[0] ?? overloads[0]; } + +/** + * Sentinel returned by `pickOverload` when narrowing leaves >1 candidate + * sharing identical normalized parameter-types. Callers should suppress + * the CALLS edge AND mark the site as handled so `emitReferencesViaLookup` + * does not re-emit from the pre-resolved reference index. See + * `pickOverload` JSDoc for the upstream cause (per-language normalizer + * collapses distinct types in arity-metadata). + */ +export const OVERLOAD_AMBIGUOUS = Symbol('overload-ambiguous'); diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/caller.cpp new file mode 100644 index 000000000..89e62ead1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/caller.cpp @@ -0,0 +1,6 @@ +#include "service.h" + +void run() { + Service s; + s.process(42); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/service.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/service.cpp new file mode 100644 index 000000000..9bde80f6f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/service.cpp @@ -0,0 +1,4 @@ +#include "service.h" + +void Service::process(int x) {} +void Service::process(long x) {} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/service.h b/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/service.h new file mode 100644 index 000000000..1e4c5de07 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/service.h @@ -0,0 +1,7 @@ +#pragma once + +class Service { +public: + void process(int x); + void process(long x); +}; diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 83b7f3919..d70d37dd5 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -1664,3 +1664,28 @@ describe('C++ anonymous namespace symbols visible in same TU', () => { expect(wCalls.length).toBe(1); }); }); + +// --------------------------------------------------------------------------- +// U2: integer-width overload ambiguity suppresses CALLS edge entirely +// (PR #1520 review follow-up plan U2; Claude review Finding 5) +// --------------------------------------------------------------------------- + +describe('C++ ambiguous integer-width overloads', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-overload-int-long'), + () => {}, + ); + }, 60000); + + it('emits zero CALLS edges when process(int)/process(long) collide after normalization', () => { + const calls = getRelationships(result, 'CALLS'); + const processCalls = calls.filter((c) => c.source === 'run' && c.target === 'process'); + // Exact .toBe(0): any non-zero count is a regression. count=1 = arbitrary + // pick (the bug U2 fixes); count=2+ would require an ambiguous-edge model + // GitNexus does not have. The resolver must suppress entirely. + expect(processCalls.length).toBe(0); + }); +}); diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index f753ba317..e80de2762 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -101,6 +101,13 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly