Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions gitnexus/src/core/ingestion/languages/cpp/conversion-ranking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* V1 C++ primitive standard-conversion-sequence ranking for overload
* narrowing. The resolver's type signals are intentionally coarse
* normalized names, so this models only the primitive ranks that those
* signals can represent.
*/

const PROMOTIONS = new Set([
'char->int',
'bool->int',
'short->int',
'int->long',
'float->double',
]);

const STANDARD_CONVERSIONS = new Set([
'char->long',
'char->double',
'bool->long',
'bool->double',
'short->long',
'short->double',
'int->double',
'double->int',
'double->long',
'long->int',
'long->double',
]);

export function cppPrimitiveConversionRank(argType: string, paramType: string): number | undefined {
if (argType === '' || paramType === '') return 0;
if (argType === paramType) return 0;
const key = `${argType}->${paramType}`;
if (PROMOTIONS.has(key)) return 1;
if (STANDARD_CONVERSIONS.has(key)) return 2;
return undefined;
}
2 changes: 2 additions & 0 deletions gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
resolveCppQualifiedNamespaceMember,
} from './inline-namespaces.js';
import { populateCppRangeBindings } from './range-bindings.js';
import { cppPrimitiveConversionRank } from './conversion-ranking.js';

/**
* C++ `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
Expand Down Expand Up @@ -83,6 +84,7 @@ export const cppScopeResolver: ScopeResolver = {
// Adapter: cppArityCompatibility predates ScopeResolver and uses
// (def, callsite). ScopeResolver contract is (callsite, def).
arityCompatibility: (callsite, def) => cppArityCompatibility(def, callsite),
overloadConversionRank: cppPrimitiveConversionRank,

buildMro: (graph, parsedFiles, nodeLookup) =>
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,11 @@ export type LinearizeStrategy = (
/** Result of `ScopeResolver.arityCompatibility` — mirrors `RegistryProviders.arityCompatibility`. */
export type ArityVerdict = 'compatible' | 'unknown' | 'incompatible';

export type OverloadConversionRanker = (
argType: string,
paramType: string,
) => number | undefined;

export interface ScopeResolver {
/** Identity for telemetry + per-language flag check. */
readonly language: SupportedLanguages;
Expand Down Expand Up @@ -373,6 +378,17 @@ export interface ScopeResolver {
*/
arityCompatibility(callsite: Callsite, def: SymbolDefinition): ArityVerdict;

/**
* Optional per-language overload conversion ranker. Lower ranks are
* better; undefined means the argument cannot convert to the parameter.
* Generic overload narrowing uses exact equality when this hook is absent.
*
* C++ uses this for primitive standard-conversion-sequence ranking
* (exact < promotion < standard conversion). Languages without a
* well-defined conversion ranking should leave it undefined.
*/
readonly overloadConversionRank?: OverloadConversionRanker;

// ─── Per-language strategies ───────────────────────────────────────────────

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ export function emitFreeCallFallback(
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
) => readonly SymbolDefinition[] | undefined;
readonly overloadConversionRank?: (
argType: string,
paramType: string,
) => number | undefined;
} = {},
): number {
let emitted = 0;
Expand Down Expand Up @@ -90,7 +94,13 @@ 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.overloadConversionRank,
);
}
if (fnDef === undefined) {
if (options.resolveAdlCandidates === undefined) {
Expand Down Expand Up @@ -123,7 +133,16 @@ export function emitFreeCallFallback(
// Preserve existing ordinary-lookup behavior when ADL contributed
// no candidates.
if (adl === undefined || adl.length === 0) {
fnDef = ordinary[0];
const ordinaryPool = mergeCallableCandidates(
ordinary,
model.symbols.lookupExactAll(parsed.filePath, site.name),
);
fnDef = pickUniqueNarrowedFreeCallCandidate(
ordinaryPool,
site.arity,
site.argumentTypes,
options.overloadConversionRank,
);
} else {
const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`;
const merged: SymbolDefinition[] = [];
Expand All @@ -138,7 +157,9 @@ export function emitFreeCallFallback(
push(ordinary);
push(adl);

const narrowed = narrowOverloadCandidates(merged, site.arity, site.argumentTypes);
const narrowed = narrowOverloadCandidates(merged, site.arity, site.argumentTypes, {
conversionRank: options.overloadConversionRank,
});
if (narrowed.length === 1) {
fnDef = narrowed[0];
} else if (narrowed.length === 0) {
Expand Down Expand Up @@ -214,6 +235,43 @@ export function emitFreeCallFallback(
return emitted;
}

function mergeCallableCandidates(
first: readonly SymbolDefinition[],
second: readonly SymbolDefinition[],
): readonly SymbolDefinition[] {
if (second.length === 0) return first;
const merged: SymbolDefinition[] = [];
const seen = new Set<string>();
const push = (defs: readonly SymbolDefinition[]): void => {
for (const def of defs) {
if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') continue;
if (seen.has(def.nodeId)) continue;
seen.add(def.nodeId);
merged.push(def);
}
};
push(first);
push(second);
return merged;
}

function pickUniqueNarrowedFreeCallCandidate(
candidates: readonly SymbolDefinition[],
arity: number | undefined,
argumentTypes: readonly string[] | undefined,
conversionRank?: (argType: string, paramType: string) => number | undefined,
): SymbolDefinition | undefined {
if (candidates.length === 0) return undefined;
if (candidates.length === 1) return candidates[0];
const narrowed = narrowOverloadCandidates(candidates, arity, argumentTypes, {
conversionRank,
});
if (narrowed.length === 1) return narrowed[0];
const hasTypeSignal = argumentTypes !== undefined && argumentTypes.some((t) => t !== '');
if (!hasTypeSignal) return narrowed[0] ?? candidates[0];
return undefined;
}

function pickUniqueGlobalCallable(
name: string,
model: SemanticModel,
Expand Down Expand Up @@ -362,6 +420,7 @@ export function pickImplicitThisOverload(
scopes: ScopeResolutionIndexes,
workspaceIndex: WorkspaceResolutionIndex,
model: SemanticModel,
conversionRank?: (argType: string, paramType: string) => number | undefined,
): SymbolDefinition | undefined {
// Find the enclosing Class scope by walking parents.
let curId: ScopeId | null = site.inScope;
Expand Down Expand Up @@ -389,7 +448,9 @@ 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, {
conversionRank,
});
if (candidates.length !== 1) return undefined;
return candidates[0];
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,31 @@
* filter and return empty — the call is genuinely arity-incompatible
* (e.g., PHP `f(int $req, ...$rest)` called with zero args).
* 4. If `argTypes` is present, filter further by per-slot type
* 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.
* equality, or by an optional per-language conversion ranker.
* An empty string in `argTypes[i]` means "unknown" and counts as
* a match. Mismatches disqualify. A unique lowest-ranked result
* wins; tied best ranks remain as multiple survivors so callers
* can suppress ambiguous overloads. If no typed candidate matches,
* return the arity-filtered candidates.
* 5. Empty input returns empty output.
*/

import type { SymbolDefinition } from 'gitnexus-shared';

export type OverloadConversionRanker = (
argType: string,
paramType: string,
) => number | undefined;

export interface OverloadNarrowingOptions {
readonly conversionRank?: OverloadConversionRanker;
}

export function narrowOverloadCandidates(
overloads: readonly SymbolDefinition[],
argCount: number | undefined,
argTypes: readonly string[] | undefined,
options: OverloadNarrowingOptions = {},
): readonly SymbolDefinition[] {
if (overloads.length === 0) return [];

Expand Down Expand Up @@ -74,16 +87,32 @@ export function narrowOverloadCandidates(
arityMatches.length > 0 ? arityMatches : anyUnknownBounds ? overloads : [];

if (argTypes !== undefined && argTypes.length > 0) {
const typed = candidates.filter((d) => {
const scored: Array<{ readonly def: SymbolDefinition; readonly score: number }> = [];
for (const d of candidates) {
const params = d.parameterTypes;
if (params === undefined) return false;
if (params === undefined) continue;
let score = 0;
let match = true;
for (let i = 0; i < argTypes.length && i < params.length; i++) {
if (argTypes[i] === '') continue;
if (argTypes[i] !== params[i]) return false;
const rank =
options.conversionRank !== undefined
? options.conversionRank(argTypes[i], params[i])
: argTypes[i] === params[i]
? 0
: undefined;
if (rank === undefined || !Number.isFinite(rank)) {
match = false;
break;
}
score += rank;
}
return true;
});
if (typed.length > 0) return typed;
if (match) scored.push({ def: d, score });
}
if (scored.length > 0) {
const best = Math.min(...scored.map((s) => s.score));
return scored.filter((s) => s.score === best).map((s) => s.def);
}
}

return candidates;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ type ReceiverBoundProviderSubset = Pick<
| 'hoistTypeBindingsToModule'
| 'resolveQualifiedReceiverMember'
| 'resolveThisViaEnclosingClass'
| 'overloadConversionRank'
>;

function normalizeTemplateArgToken(value: string): string {
Expand Down Expand Up @@ -343,6 +344,7 @@ export function emitReceiverBoundCalls(
methodOverloads,
site.arity,
site.argumentTypes,
{ conversionRank: provider.overloadConversionRank },
);
if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) {
ambiguous = true;
Expand Down Expand Up @@ -640,7 +642,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.overloadConversionRank,
);
if (picked === OVERLOAD_AMBIGUOUS) {
ambiguous = true;
break;
Expand Down Expand Up @@ -708,6 +716,7 @@ function pickOverload(
memberName: string,
site: ParsedFile['referenceSites'][number],
model: SemanticModel,
conversionRank?: (argType: string, paramType: string) => number | undefined,
): SymbolDefinition | typeof OVERLOAD_AMBIGUOUS | undefined {
const overloads = model.methods.lookupAllByOwner(ownerId, memberName);
if (overloads.length === 0) {
Expand All @@ -718,7 +727,9 @@ 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, {
conversionRank,
});
// 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@ export function runScopeResolution(
isFileLocalDef: provider.isFileLocalDef,
isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller,
resolveAdlCandidates: provider.resolveAdlCandidates,
overloadConversionRank: provider.overloadConversionRank,
},
);
const { emitted, skipped } = emitReferencesViaLookup(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#include "service.h"

void callDoubleLiteral() {
Service s;
s.pick(2.5);
}

void callIntLiteral() {
Service s;
s.pick(42);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#include "service.h"

void Service::pick(int x) {}
void Service::pick(double x) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#pragma once

class Service {
public:
void pick(int x);
void pick(double x);
};
29 changes: 29 additions & 0 deletions gitnexus/test/integration/resolvers/cpp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1072,6 +1072,35 @@ describe('C++ overload disambiguation by parameter types', () => {
});
});

// ── Phase P: C++ standard-conversion-sequence ranking ──────────────────────

describe('C++ overload disambiguation by primitive conversion ranking', () => {
let result: PipelineResult;

beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'cpp-overload-conversion-ranking'),
() => {},
);
}, 60000);

it('callDoubleLiteral() emits exactly one CALLS edge to pick(double)', () => {
const calls = getRelationships(result, 'CALLS');
const pickCalls = calls.filter((c) => c.source === 'callDoubleLiteral' && c.target === 'pick');
expect(pickCalls.length).toBe(1);
const targetNode = result.graph.getNode(pickCalls[0].rel.targetId);
expect(targetNode?.properties.parameterTypes).toEqual(['double']);
});

it('callIntLiteral() emits exactly one CALLS edge to pick(int)', () => {
const calls = getRelationships(result, 'CALLS');
const pickCalls = calls.filter((c) => c.source === 'callIntLiteral' && c.target === 'pick');
expect(pickCalls.length).toBe(1);
const targetNode = result.graph.getNode(pickCalls[0].rel.targetId);
expect(targetNode?.properties.parameterTypes).toEqual(['int']);
});
});

// ── Phase P: Same-arity overloads — cross-file + chain resolution ─────────

describe('C++ same-arity overload cross-file and chain resolution', () => {
Expand Down
Loading