feat(cpp): standard-conversion-sequence ranking for overload resolution (#1606)

* feat(cpp): add standard-conversion-sequence ranking to overload resolution (#1578)

Introduce `ConversionRankFn` abstraction and `cppConversionRank` implementation
to disambiguate C++ overloaded calls by argument-to-parameter conversion cost.
Exact type match (rank 0) beats standard arithmetic conversion (rank 2), which
beats non-viable mismatch (Infinity). Thread the rank function through
`narrowOverloadCandidates`, `pickImplicitThisOverload`, `pickOverload`, and
`pickUniqueGlobalCallable` via the `ScopeResolver.conversionRankFn` contract.
Add `findAllCallableBindingsInScope` scope walker for collecting all overloads
at the first binding scope. Guard against false ambiguity suppression when
candidates span different files (local-shadows-import preservation).

* fix: address Claude review findings on conversion-rank PR

Finding 1 (HIGH): add tests that exercise the conversion ranker.
  - p('a') with p(int)/p(double): char→int promotion (rank 1) beats
    char→double conversion (rank 2), forcing step 4b in
    narrowOverloadCandidates. Exact-type filter misses both overloads.
  - h(42, 2.5) with h(int,int)/h(double,double): multi-arg tied total
    score forces the ranker, both candidates score 2 → suppressed.

Finding 2 (HIGH): unify multi-candidate suppression across all paths.
  - Non-ADL free-call: suppress when narrowed.length > 1 (same-file
    guard), mirroring ADL merged-candidate behavior.
  - ADL ordinary-only: same pattern.
  - pickOverload: return OVERLOAD_AMBIGUOUS when candidates.length > 1
    after normalized-ambiguity check.
  - Case 0.5 (this receiver): set ambiguous=true when narrowed > 1.

Finding 3+4 (MEDIUM): implement rank-1 integral promotions.
  - char→int and bool→int now return rank 1 (ISO C++ [conv.prom]).
  - Updated comment to remove misleading ISO table header; document
    only the post-normalization ranking that is actually implemented.
  - Updated ConversionRankFn JSDoc in overload-narrowing.ts.

218/218 C++ tests pass (registry-primary). Legacy: 186+32.

* fix: implement pairwise dominance comparison for overload ranking

Replace the summed per-slot conversion cost with ISO C++-aligned
pairwise dominance comparison ([over.ics.rank]). F1 is better than
F2 only when F1 is not worse for every argument and strictly better
for at least one. Non-dominated candidates are returned; if multiple
remain they are genuinely ambiguous.

This fixes false CALLS edges for asymmetric multi-arg overloads:
h('a', 2.5) against h(int,int) / h(double,double) — the old summed
cost picked h(double,double) (cost 2 < 3), but ISO C++ considers
the call ambiguous because h(int,int) is better at arg 0 via char
promotion. The pairwise check correctly finds neither dominates.

Add h('a', 2.5) test case asserting zero CALLS edges alongside
the existing h(42, 2.5) symmetric-tie test.

218/218 C++ tests pass (registry-primary). Legacy: 186+32.

* docs: update step 4b JSDoc to reflect pairwise dominance

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
azizur100389 2026-05-16 11:15:21 +01:00 • committed by GitHub
parent 6568ee7c73
commit 28b14cf152
11 changed files with 446 additions and 23 deletions

View file

@ -0,0 +1,47 @@
/**
* C++ conversion-rank scoring for overload resolution (#1578).
*
* Operates on **normalized** type strings (output of
* `normalizeCppParamType` in `arity-metadata.ts`). After normalization:
* - int/long/short/unsigned → 'int'
* - float/double → 'double'
* - char → 'char', bool → 'bool'
*
* Because the normalizer collapses promotion pairs (int↔long,
* float↔double) to the same string, those promotions are invisible at
* this layer — they appear as exact matches (rank 0).
*
* Post-normalization ranking:
* - rank 0 — exact (same normalized type)
* - rank 1 — integral promotion (char→int, bool→int)
* - rank 2 — standard arithmetic conversion (int↔double, char→double,
* bool→double)
* - Infinity — mismatch (string↔int, user types, pointers, etc.)
*
* This function is intentionally C++-specific (issue #1578 pitfall:
* keep conversion-rank tables out of shared overload-narrowing). Other
* languages may define their own `ConversionRankFn` in the future.
*/
/** Set of normalized arithmetic types that support implicit conversion. */
const ARITHMETIC = new Set(['int', 'double', 'char', 'bool']);
/** Integral promotion targets: char→int and bool→int are rank 1. */
const INTEGRAL_PROMOTION = new Map([
['char', 'int'],
['bool', 'int'],
]);
/**
* Return the conversion rank from `argType` to `paramType`.
*
* @returns 0 for exact match, 1 for integral promotion (char/bool→int),
* 2 for standard arithmetic conversion, Infinity for mismatch.
*/
export function cppConversionRank(argType: string, paramType: string): number {
if (argType === paramType) return 0;
// Integral promotions: char→int, bool→int (ISO C++ [conv.prom])
if (INTEGRAL_PROMOTION.get(argType) === paramType) return 1;
if (ARITHMETIC.has(argType) && ARITHMETIC.has(paramType)) return 2;
return Infinity;
}

View file

@ -9,6 +9,7 @@ import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { cppProvider } from '../c-cpp.js';
import { cppArityCompatibility } from './arity.js';
import { cppConversionRank } from './conversion-rank.js';
import { cppMergeBindings } from './merge-bindings.js';
import { resolveCppImportTarget } from './import-target.js';
import { scanCppHeaderFiles } from './header-scan.js';
@ -169,6 +170,10 @@ export const cppScopeResolver: ScopeResolver = {
propagatesReturnTypesAcrossImports: true,
// C++ #include brings in all symbols — enable global free call fallback
allowGlobalFreeCallFallback: true,
// C++ standard-conversion-sequence ranking for overload resolution (#1578).
// Disambiguates `f(int)` vs `f(double)` called with `f(2.5)` by scoring
// each candidate's conversion cost; exact match wins over standard conversion.
conversionRankFn: cppConversionRank,
// Range-for element type inference: for (auto& user : users) → bind user to User
populateRangeBindings: populateCppRangeBindings,
// C++ method return-type bindings need to be visible from module scope

View file

@ -264,6 +264,7 @@ import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
import { LanguageProvider } from '../../language-provider.js';
import { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import type { SemanticModel } from '../../model/semantic-model.js';
import type { ConversionRankFn } from '../passes/overload-narrowing.js';
/** A LinearizeStrategy receives the full ancestor map so C3-style
* algorithms (which need to merge each parent's MRO) can implement
@ -533,6 +534,20 @@ export interface ScopeResolver {
*/
readonly allowGlobalFreeCallFallback?: boolean;
/**
* Optional per-slot conversion-rank function for overload resolution.
* When provided, `narrowOverloadCandidates` uses ranked scoring as a
* fallback when the exact-type filter produces no match. The function
* returns a numeric cost (0 = exact, 1 = promotion, 2 = standard
* conversion, Infinity = incompatible) for converting an argument
* type to a parameter type.
*
* The conversion-rank table is language-specific (issue #1578 pitfall:
* keep it out of shared overload-narrowing). C++ provides
* `cppConversionRank`; other languages define their own if needed.
*/
readonly conversionRankFn?: ConversionRankFn;
/**
* Optional predicate to identify definitions with file-local linkage
* (e.g. C `static` functions). When provided, `pickUniqueGlobalCallable`

View file

@ -25,6 +25,7 @@ import type { WorkspaceResolutionIndex } from '../workspace-index.js';
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js';
import {
findAllCallableBindingsInScope,
findCallableBindingInScope,
findCallableBindingsAndAdlBlocker,
findClassBindingInScope,
@ -32,6 +33,7 @@ import {
import {
isOverloadAmbiguousAfterNormalization,
narrowOverloadCandidates,
type ConversionRankFn,
} from './overload-narrowing.js';
export function emitFreeCallFallback(
@ -63,6 +65,7 @@ export function emitFreeCallFallback(
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
) => readonly SymbolDefinition[] | undefined;
readonly conversionRankFn?: ConversionRankFn;
} = {},
): number {
let emitted = 0;
@ -90,16 +93,59 @@ export function emitFreeCallFallback(
// the same name in a single class, choose the best match by
// arity + argument types.
if (fnDef === undefined) {
fnDef = pickImplicitThisOverload(site, scopes, workspaceIndex, model);
fnDef = pickImplicitThisOverload(
site,
scopes,
workspaceIndex,
model,
options.conversionRankFn,
);
}
// Scope-chain callable lookup. First-match preserves scope-chain
// precedence (local shadows import). When a conversion-rank function
// is available AND the binding scope contains multiple overloads,
// refine with `narrowOverloadCandidates` to pick the best overload
// by argument types (#1578). The first-match result is kept as a
// fallback when narrowing is indeterminate.
if (fnDef === undefined) {
if (options.resolveAdlCandidates === undefined) {
// Non-ADL path: first-match preserves scope-chain precedence
// (local shadows import). When a conversion-rank function is
// available AND the binding scope contains multiple overloads,
// refine with narrowOverloadCandidates (#1578).
fnDef = findCallableBindingInScope(site.inScope, site.name, scopes);
if (fnDef !== undefined && options.conversionRankFn !== undefined) {
const allCallables = findAllCallableBindingsInScope(site.inScope, site.name, scopes);
if (allCallables.length > 1) {
const narrowed = narrowOverloadCandidates(
allCallables,
site.arity,
site.argumentTypes,
options.conversionRankFn,
);
if (narrowed.length === 1) {
fnDef = narrowed[0];
} else if (narrowed.length > 1) {
// Multiple survivors after conversion-rank scoring.
// Suppress when all candidates share the same file (true
// overloads) — mirrors ADL merged-candidate path behavior.
// Cross-file candidates are shadowing; keep first-match.
const sameFile = narrowed.every((d) => d.filePath === narrowed[0]!.filePath);
if (sameFile) {
handledSites.add(
`${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`,
);
continue;
}
}
// narrowed.length === 0: keep the first-match fnDef —
// preserves local-shadows-import.
}
}
} else {
// ISO C++ `[basic.lookup.unqual]` §7: ADL is suppressed when
// ordinary lookup finds a non-function name (variable, class, enum)
// or a block-scope function declaration (not via using-declaration)
// at the nearest scope where the name exists.
// ADL path: ISO C++ `[basic.lookup.unqual]` §7 — ADL is suppressed
// when ordinary lookup finds a non-function name or a block-scope
// function declaration.
const {
callables: ordinary,
nonCallableFound,
@ -120,43 +166,67 @@ export function emitFreeCallFallback(
parsedFiles,
);
// Preserve existing ordinary-lookup behavior when ADL contributed
// no candidates.
// When ADL contributed no candidates, narrow ordinary candidates
// with conversion-rank scoring when multiple overloads exist.
// Single candidate or empty falls through to first-match.
if (adl === undefined || adl.length === 0) {
fnDef = ordinary[0];
if (ordinary.length <= 1 || options.conversionRankFn === undefined) {
fnDef = ordinary[0];
} else {
const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`;
const narrowed = narrowOverloadCandidates(
ordinary,
site.arity,
site.argumentTypes,
options.conversionRankFn,
);
if (narrowed.length === 1) {
fnDef = narrowed[0];
} else if (narrowed.length > 1) {
// Multiple survivors — suppress when same-file (true
// overloads), mirrors ADL merged-candidate behavior.
const sameFile = narrowed.every((d) => d.filePath === narrowed[0]!.filePath);
if (sameFile) {
handledSites.add(siteKey);
continue;
}
fnDef = ordinary[0]; // cross-file shadowing → first-match
} else {
fnDef = ordinary[0]; // narrowed empty → first-match
}
}
} else {
const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`;
const merged: SymbolDefinition[] = [];
const seen = new Set<string>();
const seenMerge = new Set<string>();
const push = (defs: readonly SymbolDefinition[]): void => {
for (const d of defs) {
if (seen.has(d.nodeId)) continue;
seen.add(d.nodeId);
if (seenMerge.has(d.nodeId)) continue;
seenMerge.add(d.nodeId);
merged.push(d);
}
};
push(ordinary);
push(adl);
const narrowed = narrowOverloadCandidates(merged, site.arity, site.argumentTypes);
const narrowed = narrowOverloadCandidates(
merged,
site.arity,
site.argumentTypes,
options.conversionRankFn,
);
if (narrowed.length === 1) {
fnDef = narrowed[0];
} else if (narrowed.length === 0) {
// ADL contributed candidates, but none survived arity/type
// narrowing. Treat as handled to avoid global-name fallback
// binding to the same mismatched symbol by simple-name
// uniqueness.
handledSites.add(siteKey);
continue;
} else if (narrowed.length > 1) {
// Suppress ambiguous overload calls (emit zero edges) when
// merged ordinary+ADL candidate sets cannot be disambiguated.
if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) {
handledSites.add(siteKey);
continue;
}
// Multiple survivors remain but no conversion-ranking step
// exists yet; suppress instead of picking arbitrarily.
// Multiple survivors remain after conversion-rank scoring;
// suppress instead of picking arbitrarily.
handledSites.add(siteKey);
continue;
}
@ -184,6 +254,8 @@ export function emitFreeCallFallback(
scopes,
})
: undefined,
site.argumentTypes,
options.conversionRankFn,
);
}
if (fnDef === undefined) continue;
@ -222,6 +294,8 @@ function pickUniqueGlobalCallable(
isFileLocalDef?: (def: SymbolDefinition) => boolean,
callArity?: number,
isCallerVisible?: (candidate: SymbolDefinition) => boolean,
callArgTypes?: readonly string[],
conversionRankFn?: ConversionRankFn,
): SymbolDefinition | undefined {
const scopeDefs: SymbolDefinition[] = [];
const scopeSeen = new Set<string>();
@ -256,6 +330,14 @@ function pickUniqueGlobalCallable(
const arityMatch = narrowByArity(scopeDefs, callArity);
if (arityMatch !== undefined) return arityMatch;
}
// When arity narrowing left >1 candidate, try overload narrowing with
// argument types + conversion ranking (#1578). This picks the unique
// best-rank candidate when exact-type or conversion-rank scoring can
// disambiguate (e.g., `f(int)` vs `f(double)` called with `f(2.5)`).
if (scopeDefs.length > 1) {
const narrowed = narrowOverloadCandidates(scopeDefs, callArity, callArgTypes, conversionRankFn);
if (narrowed.length === 1) return narrowed[0];
}
const defs: SymbolDefinition[] = [];
const seen = new Set<string>();
@ -289,6 +371,11 @@ function pickUniqueGlobalCallable(
const arityMatch = narrowByArity(defs, callArity);
if (arityMatch !== undefined) return arityMatch;
}
// Same argument-type + conversion-rank narrowing for the model pool.
if (defs.length > 1) {
const narrowed = narrowOverloadCandidates(defs, callArity, callArgTypes, conversionRankFn);
if (narrowed.length === 1) return narrowed[0];
}
return undefined;
}
@ -362,6 +449,7 @@ export function pickImplicitThisOverload(
scopes: ScopeResolutionIndexes,
workspaceIndex: WorkspaceResolutionIndex,
model: SemanticModel,
conversionRankFn?: ConversionRankFn,
): SymbolDefinition | undefined {
// Find the enclosing Class scope by walking parents.
let curId: ScopeId | null = site.inScope;
@ -389,7 +477,12 @@ export function pickImplicitThisOverload(
// ambiguous narrowing (multiple compatible candidates with no
// disambiguating signal) leaves the call unresolved rather than
// routing to an arbitrary first overload by registration order.
const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes);
const candidates = narrowOverloadCandidates(
overloads,
site.arity,
site.argumentTypes,
conversionRankFn,
);
if (candidates.length !== 1) return undefined;
return candidates[0];
}

View file

@ -24,15 +24,35 @@
* equality. An empty string in `argTypes[i]` means "unknown" and
* counts as a match. Mismatches disqualify. A non-empty typed
* result wins; otherwise return the arity-filtered candidates.
* 4b. When the exact-type filter from step 4 returns empty AND a
* `conversionRankFn` is provided, rank candidates via pairwise
* dominance comparison (ISO C++ [over.ics.rank]): F1 beats F2
* only when F1 is not worse for every arg and better for at
* least one. Non-dominated candidates are returned; multiple
* survivors are genuinely ambiguous.
* 5. Empty input returns empty output.
*/
import type { SymbolDefinition } from 'gitnexus-shared';
/**
* Per-slot conversion-rank function. Returns a numeric cost for
* converting `argType` to `paramType`:
* - 0 = exact match (no conversion)
* - 1 = promotion (e.g. char→int, bool→int in C++)
* - 2 = standard conversion (e.g. int→double)
* - Infinity = incompatible types
*
* Each language provides its own implementation. The function operates
* on normalized type strings (output of the language's type normalizer).
*/
export type ConversionRankFn = (argType: string, paramType: string) => number;
export function narrowOverloadCandidates(
overloads: readonly SymbolDefinition[],
argCount: number | undefined,
argTypes: readonly string[] | undefined,
conversionRankFn?: ConversionRankFn,
): readonly SymbolDefinition[] {
if (overloads.length === 0) return [];
@ -84,11 +104,98 @@ export function narrowOverloadCandidates(
return true;
});
if (typed.length > 0) return typed;
// ── Conversion-rank scoring (step 4b) ──────────────────────────
// The exact-type filter above rejected every candidate. When a
// per-language conversion-rank function is available, rank via
// pairwise dominance: F1 beats F2 only when F1 is not worse for
// every arg and better for at least one. Non-dominated candidates
// are returned; multiple survivors are genuinely ambiguous.
if (conversionRankFn !== undefined) {
const ranked = rankByConversion(candidates, argTypes, conversionRankFn);
if (ranked.length > 0) return ranked;
}
}
return candidates;
}
/**
* Pairwise dominance comparison (ISO C++ [over.ics.rank]).
*
* F1 is a better match than F2 when F1's conversion rank is **not
* worse** for every argument AND **strictly better** for at least one.
* Candidates dominated by any other viable candidate are removed.
* If more than one non-dominated candidate remains, they are genuinely
* ambiguous — callers suppress the edge rather than picking arbitrarily.
*
* Candidates with at least one `Infinity`-ranked slot (incompatible
* type) are excluded before pairwise comparison begins.
*/
function rankByConversion(
candidates: readonly SymbolDefinition[],
argTypes: readonly string[],
rankFn: ConversionRankFn,
): readonly SymbolDefinition[] {
// Step 1: compute per-slot ranks and exclude non-viable candidates.
const viable: Array<{ def: SymbolDefinition; ranks: number[] }> = [];
for (const d of candidates) {
const params = d.parameterTypes;
if (params === undefined) continue;
const ranks: number[] = [];
let ok = true;
for (let i = 0; i < argTypes.length && i < params.length; i++) {
if (argTypes[i] === '') {
ranks.push(0); // unknown arg → any-match (rank 0)
continue;
}
const r = rankFn(argTypes[i], params[i]);
if (!isFinite(r)) {
ok = false;
break;
}
ranks.push(r);
}
if (!ok) continue;
viable.push({ def: d, ranks });
}
if (viable.length <= 1) return viable.map((v) => v.def);
// Step 2: pairwise dominance — remove candidates dominated by any other.
const dominated = new Set<number>();
for (let i = 0; i < viable.length; i++) {
if (dominated.has(i)) continue;
for (let j = i + 1; j < viable.length; j++) {
if (dominated.has(j)) continue;
const cmp = pairwiseCompare(viable[i].ranks, viable[j].ranks);
if (cmp < 0)
dominated.add(j); // i dominates j
else if (cmp > 0) dominated.add(i); // j dominates i
}
}
return viable.filter((_, idx) => !dominated.has(idx)).map((v) => v.def);
}
/**
* Compare two per-slot rank vectors.
* Returns -1 if `a` dominates `b` (not worse everywhere, better somewhere),
* +1 if `b` dominates `a`,
* 0 if neither dominates (incomparable or equal).
*/
function pairwiseCompare(a: readonly number[], b: readonly number[]): -1 | 0 | 1 {
let aBetter = false;
let bBetter = false;
const len = Math.min(a.length, b.length);
for (let i = 0; i < len; i++) {
if (a[i] < b[i]) aBetter = true;
else if (b[i] < a[i]) bBetter = true;
if (aBetter && bBetter) return 0; // incomparable — early exit
}
if (aBetter && !bBetter) return -1;
if (bBetter && !aBetter) return 1;
return 0;
}
/**
* Detect when >1 candidate share identical `parameterTypes` after the
* per-language normalizer has collapsed distinct underlying types. This

View file

@ -73,6 +73,7 @@ type ReceiverBoundProviderSubset = Pick<
| 'hoistTypeBindingsToModule'
| 'resolveQualifiedReceiverMember'
| 'resolveThisViaEnclosingClass'
| 'conversionRankFn'
>;
function normalizeTemplateArgToken(value: string): string {
@ -343,6 +344,7 @@ export function emitReceiverBoundCalls(
methodOverloads,
site.arity,
site.argumentTypes,
provider.conversionRankFn,
);
if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) {
ambiguous = true;
@ -356,6 +358,12 @@ export function emitReceiverBoundCalls(
hiddenByName = true;
break;
}
// Multiple tied survivors with distinct param types (e.g.
// h(int,double) vs h(double,int) both scoring 2) → ambiguous.
if (narrowed.length > 1) {
ambiguous = true;
break;
}
memberDef = narrowed[0] ?? methodOverloads[0];
break;
}
@ -640,7 +648,13 @@ export function emitReceiverBoundCalls(
let memberDef: SymbolDefinition | undefined;
let ambiguous = false;
for (const ownerId of chain) {
const picked = pickOverload(ownerId, memberName, site, model);
const picked = pickOverload(
ownerId,
memberName,
site,
model,
provider.conversionRankFn,
);
if (picked === OVERLOAD_AMBIGUOUS) {
ambiguous = true;
break;
@ -708,6 +722,7 @@ function pickOverload(
memberName: string,
site: ParsedFile['referenceSites'][number],
model: SemanticModel,
conversionRankFn?: (argType: string, paramType: string) => number,
): SymbolDefinition | typeof OVERLOAD_AMBIGUOUS | undefined {
const overloads = model.methods.lookupAllByOwner(ownerId, memberName);
if (overloads.length === 0) {
@ -718,7 +733,12 @@ function pickOverload(
}
if (overloads.length === 1) return overloads[0];
const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes);
const candidates = narrowOverloadCandidates(
overloads,
site.arity,
site.argumentTypes,
conversionRankFn,
);
// 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.
@ -726,6 +746,11 @@ function pickOverload(
// 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, site.arity)) return OVERLOAD_AMBIGUOUS;
// When conversion-rank scoring leaves >1 tied candidate with distinct
// parameter types (e.g. h(int,double) vs h(double,int) both scoring 2),
// suppress rather than picking arbitrarily — C++ would call this
// ambiguous. Mirrors ADL merged-candidate suppression behavior.
if (candidates.length > 1) return OVERLOAD_AMBIGUOUS;
return candidates[0] ?? overloads[0];
}

View file

@ -382,6 +382,7 @@ export function runScopeResolution(
isFileLocalDef: provider.isFileLocalDef,
isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller,
resolveAdlCandidates: provider.resolveAdlCandidates,
conversionRankFn: provider.conversionRankFn,
},
);
const { emitted, skipped } = emitReferencesViaLookup(

View file

@ -0,0 +1,10 @@
#include "lib.h"
void Service::f(int x) {}
void Service::f(double x) {}
void Service::g(int x) {}
void Service::g(long x) {}
void Service::h(int a, int b) {}
void Service::h(double a, double b) {}
void Service::p(int x) {}
void Service::p(double x) {}

View file

@ -0,0 +1,33 @@
#pragma once
class Service {
public:
// Variant 1 & 3: f(int) vs f(double)
void f(int x);
void f(double x);
// Variant 2: g(int) vs g(long) — both normalize to 'int'
void g(int x);
void g(long x);
// Variant 4: multi-arg tied total score
void h(int a, int b);
void h(double a, double b);
// Variant 5: char-literal promotion (exercises conversion ranker)
void p(int x);
void p(double x);
// Inline: call sites live inside the class scope so the scope-chain
// walk finds the Class scope, enabling pickImplicitThisOverload to
// resolve overloads against the declaration-side Method nodes (which
// carry distinct parameterTypes and graph-node IDs).
void run() {
f(2.5); // Variant 1: double literal -> f(double) wins (exact > standard)
f(42); // Variant 3: int literal -> f(int) wins (exact > standard)
g(42); // Variant 2: int/long both normalize to 'int' -> ambiguous
h(42, 2.5); // Variant 4: incomparable — neither dominates the other -> ambiguous
h('a', 2.5);// Variant 6: asymmetric — h(int,int) better at arg0 (promotion), h(double,double) better at arg1 (exact) -> ambiguous
p('a'); // Variant 5: char literal -> p(int) wins via promotion (rank 1 < rank 2)
}
};

View file

@ -1762,6 +1762,80 @@ describe('C++ ambiguous integer-width overloads', () => {
});
});
// ---------------------------------------------------------------------------
// C++ overload resolution: standard-conversion-sequence ranking (#1578)
// Disambiguates overloads when exact normalized-type matching cannot,
// by scoring each candidate's conversion cost. Exact match (rank 0) wins
// over standard conversion (rank 2); same-rank ties still suppress.
// ---------------------------------------------------------------------------
describe('C++ overload resolution — conversion-rank disambiguation (#1578)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'cpp-overload-conversion-rank'),
() => {},
);
}, 60000);
it('f(2.5) resolves to f(double) — exact match beats standard conversion', () => {
const calls = getRelationships(result, 'CALLS');
const fCalls = calls.filter((c) => c.source === 'run' && c.target === 'f');
// Conversion-rank scoring picks f(double) as the unique best:
// f(double) is exact match (rank 0), f(int) is standard conversion (rank 2).
const fDoubleEdges = fCalls.filter((c) => {
const tgt = result.graph.getNode(c.rel.targetId);
return tgt?.properties.parameterTypes?.[0] === 'double';
});
expect(fDoubleEdges.length).toBe(1);
});
it('f(42) resolves to f(int) — exact match beats standard conversion', () => {
const calls = getRelationships(result, 'CALLS');
const fCalls = calls.filter((c) => c.source === 'run' && c.target === 'f');
// f(int) is exact match (rank 0), f(double) is standard conversion (rank 2).
const fIntEdges = fCalls.filter((c) => {
const tgt = result.graph.getNode(c.rel.targetId);
return tgt?.properties.parameterTypes?.[0] === 'int';
});
expect(fIntEdges.length).toBe(1);
});
it('g(42) emits zero CALLS edges — int/long normalize to same type, ambiguous', () => {
const calls = getRelationships(result, 'CALLS');
const gCalls = calls.filter((c) => c.source === 'run' && c.target === 'g');
// g(int) and g(long) both normalize to parameterTypes=['int'],
// so isOverloadAmbiguousAfterNormalization triggers suppression.
expect(gCalls.length).toBe(0);
});
it("p('a') resolves to p(int) — char promotion (rank 1) beats char→double conversion (rank 2)", () => {
const calls = getRelationships(result, 'CALLS');
const pCalls = calls.filter((c) => c.source === 'run' && c.target === 'p');
// p('a'): argType='char'. Exact-type filter misses both p(int) and
// p(double), forcing the conversion ranker (step 4b). char→int is an
// integral promotion (rank 1), char→double is a standard conversion
// (rank 2). p(int) wins with the lower total cost.
expect(pCalls.length).toBe(1);
const tgt = result.graph.getNode(pCalls[0].rel.targetId);
expect(tgt?.properties.parameterTypes?.[0]).toBe('int');
});
it('h(42, 2.5) emits zero CALLS edges — incomparable multi-arg overloads, ambiguous', () => {
const calls = getRelationships(result, 'CALLS');
const hCalls = calls.filter((c) => c.source === 'run' && c.target === 'h');
// h(42, 2.5) + h('a', 2.5): both call sites produce incomparable
// pairwise rankings. For h(42, 2.5) with argTypes=['int','double']:
// h(int,int): [rank('int','int')=0, rank('double','int')=2]
// h(double,double): [rank('int','double')=2, rank('double','double')=0]
// h(int,int) better at arg0, h(double,double) better at arg1 → neither
// dominates → ambiguous. Same pattern for h('a',2.5).
// Contract: zero edges for ALL h() call sites combined (dedup).
expect(hCalls.length).toBe(0);
});
});
// ---------------------------------------------------------------------------
// U3: anonymous-namespace symbols MUST NOT leak across translation units
// (full-pipeline integration test; unit-level coverage exists separately)

View file

@ -175,6 +175,19 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
'Derived<T>::g_unqualified() -> f() does NOT bind to Base<T>::f',
'Derived<T>::g_this() -> this->f() resolves to Base<T>::f (1 edge)',
'Derived<T>::g() -> this->f() emits zero CALLS edges when only hidden derived overload is arity-incompatible',
// Conversion-rank scoring (#1578) disambiguates `f(int)` vs `f(double)`
// by ranking exact match over standard conversion. The legacy DAG has no
// conversion-rank scoring; it either picks arbitrarily or leaves the call
// unresolved. Scope-resolver-only correctness win.
'f(2.5) resolves to f(double) — exact match beats standard conversion',
'f(42) resolves to f(int) — exact match beats standard conversion',
'g(42) emits zero CALLS edges — int/long normalize to same type, ambiguous',
// char-literal promotion exercises the conversion ranker (step 4b).
// Legacy DAG has no conversion-rank scoring. Scope-resolver-only.
"p('a') resolves to p(int) — char promotion (rank 1) beats char→double conversion (rank 2)",
// Multi-arg incomparable overloads: pairwise dominance check finds
// neither h(int,int) nor h(double,double) dominates. Scope-resolver-only.
'h(42, 2.5) emits zero CALLS edges — incomparable multi-arg overloads, ambiguous',
// The legacy DAG path has no inline-namespace same-name ambiguity
// detection. When two inline children declare the same name, the
// legacy path picks an arbitrary match. The scope-resolver returns