From 9071cf23d4f57e94530cec694ae98986438e2d11 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Sat, 16 May 2026 13:13:52 +0100 Subject: [PATCH] Add C++ overload conversion ranking --- .../languages/cpp/conversion-ranking.ts | 37 ++++++++++ .../ingestion/languages/cpp/scope-resolver.ts | 2 + .../contract/scope-resolver.ts | 16 +++++ .../passes/free-call-fallback.ts | 69 +++++++++++++++++-- .../passes/overload-narrowing.ts | 47 ++++++++++--- .../passes/receiver-bound-calls.ts | 15 +++- .../scope-resolution/pipeline/run.ts | 1 + .../caller.cpp | 11 +++ .../service.cpp | 4 ++ .../cpp-overload-conversion-ranking/service.h | 7 ++ .../test/integration/resolvers/cpp.test.ts | 29 ++++++++ .../overload-narrowing.test.ts | 58 ++++++++++++++++ 12 files changed, 281 insertions(+), 15 deletions(-) create mode 100644 gitnexus/src/core/ingestion/languages/cpp/conversion-ranking.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-overload-conversion-ranking/caller.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-overload-conversion-ranking/service.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-overload-conversion-ranking/service.h diff --git a/gitnexus/src/core/ingestion/languages/cpp/conversion-ranking.ts b/gitnexus/src/core/ingestion/languages/cpp/conversion-ranking.ts new file mode 100644 index 0000000000..b8f58fa8bc --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/conversion-ranking.ts @@ -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; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts index 52c575cf4b..95740c9917 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -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 @@ -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), diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 315471de15..d1bf98a4a7 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -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; @@ -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 ─────────────────────────────────────────────── /** diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index d9e4cdaa3a..7f53325d35 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -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; @@ -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) { @@ -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[] = []; @@ -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) { @@ -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(); + 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, @@ -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; @@ -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]; } 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 bff16d27ea..b3ffa830fa 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts @@ -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 []; @@ -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; 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 92b4c1ac1f..6573ceeaab 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 @@ -73,6 +73,7 @@ type ReceiverBoundProviderSubset = Pick< | 'hoistTypeBindingsToModule' | 'resolveQualifiedReceiverMember' | 'resolveThisViaEnclosingClass' + | 'overloadConversionRank' >; function normalizeTemplateArgToken(value: string): string { @@ -343,6 +344,7 @@ export function emitReceiverBoundCalls( methodOverloads, site.arity, site.argumentTypes, + { conversionRank: provider.overloadConversionRank }, ); if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) { ambiguous = true; @@ -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; @@ -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) { @@ -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. diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 0809d59caf..27588db9c5 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -382,6 +382,7 @@ export function runScopeResolution( isFileLocalDef: provider.isFileLocalDef, isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller, resolveAdlCandidates: provider.resolveAdlCandidates, + overloadConversionRank: provider.overloadConversionRank, }, ); const { emitted, skipped } = emitReferencesViaLookup( diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-conversion-ranking/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-overload-conversion-ranking/caller.cpp new file mode 100644 index 0000000000..9d291c9174 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-conversion-ranking/caller.cpp @@ -0,0 +1,11 @@ +#include "service.h" + +void callDoubleLiteral() { + Service s; + s.pick(2.5); +} + +void callIntLiteral() { + Service s; + s.pick(42); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-conversion-ranking/service.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-overload-conversion-ranking/service.cpp new file mode 100644 index 0000000000..6d2eb3e5e2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-conversion-ranking/service.cpp @@ -0,0 +1,4 @@ +#include "service.h" + +void Service::pick(int x) {} +void Service::pick(double x) {} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-conversion-ranking/service.h b/gitnexus/test/fixtures/lang-resolution/cpp-overload-conversion-ranking/service.h new file mode 100644 index 0000000000..7a8d61e76a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-conversion-ranking/service.h @@ -0,0 +1,7 @@ +#pragma once + +class Service { +public: + void pick(int x); + void pick(double x); +}; diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index de0ad96325..b74806a136 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -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', () => { diff --git a/gitnexus/test/unit/scope-resolution/overload-narrowing.test.ts b/gitnexus/test/unit/scope-resolution/overload-narrowing.test.ts index 9a14fdddd0..8623df94a4 100644 --- a/gitnexus/test/unit/scope-resolution/overload-narrowing.test.ts +++ b/gitnexus/test/unit/scope-resolution/overload-narrowing.test.ts @@ -142,3 +142,61 @@ describe('narrowOverloadCandidates — type narrowing', () => { expect(result.map((d) => d.nodeId)).toEqual(['m:int']); }); }); + +describe('narrowOverloadCandidates — conversion ranking', () => { + const byInt = mkDef({ + nodeId: 'm:int', + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['int'], + }); + const byDouble = mkDef({ + nodeId: 'm:double', + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['double'], + }); + + const rank = (arg: string, param: string): number | undefined => { + if (arg === param) return 0; + if (arg === 'int' && param === 'double') return 2; + if (arg === 'double' && param === 'int') return 2; + return undefined; + }; + + it('chooses the unique lowest conversion rank', () => { + const result = narrowOverloadCandidates([byInt, byDouble], 1, ['double'], { + conversionRank: rank, + }); + expect(result.map((d) => d.nodeId)).toEqual(['m:double']); + }); + + it('keeps an exact match ahead of a standard conversion', () => { + const result = narrowOverloadCandidates([byInt, byDouble], 1, ['int'], { + conversionRank: rank, + }); + expect(result.map((d) => d.nodeId)).toEqual(['m:int']); + }); + + it('returns tied best candidates so callers can suppress ambiguity', () => { + const left = mkDef({ + nodeId: 'm:left', + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['long'], + }); + const right = mkDef({ + nodeId: 'm:right', + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['double'], + }); + const tiedRank = (arg: string, param: string): number | undefined => + arg === 'int' && (param === 'long' || param === 'double') ? 2 : undefined; + + const result = narrowOverloadCandidates([left, right], 1, ['int'], { + conversionRank: tiedRank, + }); + expect(result.map((d) => d.nodeId).sort()).toEqual(['m:left', 'm:right']); + }); +});