diff --git a/gitnexus/src/core/ingestion/languages/cpp/conversion-rank.ts b/gitnexus/src/core/ingestion/languages/cpp/conversion-rank.ts index bea3600a7..330deea09 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/conversion-rank.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/conversion-rank.ts @@ -12,7 +12,8 @@ * - rank 2: standard conversion (arithmetic, nullptr -> T*, T* -> bool, * T* -> void*) * - rank 3: nullptr -> bool (kept worse than nullptr -> T*) - * - rank 4: ellipsis conversion (worst viable) + * - rank 4: user-defined conversion (one-step, conservative) + * - rank 5: ellipsis conversion (worst viable) * - Infinity: mismatch (string -> int, user types, unsupported shapes) * * This function is intentionally C++-specific. Other languages may define @@ -20,6 +21,7 @@ */ import type { ParameterTypeClass } from 'gitnexus-shared'; +import { hasCppUserDefinedConversion } from './user-defined-conversions.js'; /** Set of normalized arithmetic types that support implicit conversion. */ const ARITHMETIC = new Set(['int', 'double', 'char', 'bool']); @@ -34,7 +36,8 @@ const INTEGRAL_PROMOTION = new Map([ * Return the conversion rank from `argType` to `paramType`. * * @returns 0 for exact match, 1 for integral promotion, 2 for standard - * conversion, 3 for nullptr -> bool, 4 for ellipsis, Infinity + * conversion, 3 for nullptr -> bool, 4 for user-defined conversion, + * 5 for ellipsis, Infinity * for mismatch. */ export function cppConversionRank( @@ -46,13 +49,14 @@ export function cppConversionRank( if (argType === paramType) { return exactShapeCompatible(argTypeClass, paramTypeClass) ? 0 : Infinity; } - if (paramType === '...') return 4; + if (paramType === '...') return 5; if (INTEGRAL_PROMOTION.get(argType) === paramType) return 1; if (ARITHMETIC.has(argType) && ARITHMETIC.has(paramType)) return 2; if (argType === 'null' && isPointer(paramTypeClass)) return 2; if (argType === 'null' && paramType === 'bool') return 3; if (isPointer(argTypeClass) && paramType === 'bool') return 2; if (isPointer(argTypeClass) && isPointer(paramTypeClass) && paramType === 'void') return 2; + if (hasCppUserDefinedConversion(argType, paramType)) return 4; return Infinity; } diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts index 9a5ac85a5..de0ff6d21 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -34,6 +34,10 @@ import { } from './inline-namespaces.js'; import { populateCppRangeBindings } from './range-bindings.js'; import { cppConstraintCompatibility } from './constraint-filter.js'; +import { + clearCppUserDefinedConversions, + populateCppUserDefinedConversions, +} from './user-defined-conversions.js'; /** * C++ `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by @@ -61,6 +65,7 @@ export const cppScopeResolver: ScopeResolver = { clearCppDependentBases(); clearCppAdlState(); clearCppInlineNamespaces(); + clearCppUserDefinedConversions(); return scanCppHeaderFiles(repoPath); }, @@ -110,6 +115,10 @@ export const cppScopeResolver: ScopeResolver = { // by ADL (U2 of plan 2026-05-13-001) to identify each argument type's // associated namespace for Koenig lookup. populateCppAssociatedNamespaces(parsed); + // Build conservative one-step user-defined conversion facts for + // overload ranking (#1631): converting constructors and conversion + // operators only, with no chaining or explicit-constructor handling. + populateCppUserDefinedConversions(parsed); }, // Resolve recorded template-class → dependent-base simple names to diff --git a/gitnexus/src/core/ingestion/languages/cpp/user-defined-conversions.ts b/gitnexus/src/core/ingestion/languages/cpp/user-defined-conversions.ts new file mode 100644 index 000000000..20d6eff29 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/user-defined-conversions.ts @@ -0,0 +1,95 @@ +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import type { ScopeId } from 'gitnexus-shared'; +import { normalizeCppParamType } from './arity-metadata.js'; + +const userDefinedConversions = new Set(); + +export function clearCppUserDefinedConversions(): void { + userDefinedConversions.clear(); +} + +export function hasCppUserDefinedConversion(argType: string, paramType: string): boolean { + return userDefinedConversions.has(conversionKey(argType, paramType)); +} + +export function populateCppUserDefinedConversions(parsed: ParsedFile): void { + const scopesById = new Map(); + for (const scope of parsed.scopes) scopesById.set(scope.id, scope); + + for (const classScope of parsed.scopes) { + if (classScope.kind !== 'Class') continue; + const classDef = classScope.ownedDefs.find(isClassLike); + if (classDef === undefined) continue; + const className = normalizedSimpleName(classDef); + if (className === '') continue; + + const methodDefs = collectClassMethodDefs(classScope.id, parsed, scopesById); + for (const def of methodDefs) { + const simpleName = simpleNameOf(def); + if (simpleName === className && def.parameterTypes?.length === 1) { + registerCppUserDefinedConversion(def.parameterTypes[0], className); + continue; + } + + const operatorTarget = conversionOperatorTarget(simpleName); + if (operatorTarget !== undefined && def.parameterTypes?.length === 0) { + registerCppUserDefinedConversion(className, operatorTarget); + } + } + } +} + +export function registerCppUserDefinedConversion(argType: string, paramType: string): void { + if (argType === '' || paramType === '') return; + if (argType === paramType) return; + userDefinedConversions.add(conversionKey(argType, paramType)); +} + +function collectClassMethodDefs( + classScopeId: ScopeId, + parsed: ParsedFile, + scopesById: ReadonlyMap, +): SymbolDefinition[] { + const methods: SymbolDefinition[] = []; + const classScope = scopesById.get(classScopeId); + if (classScope === undefined) return methods; + + for (const def of classScope.ownedDefs) { + if (isCallableMember(def)) methods.push(def); + } + for (const scope of parsed.scopes) { + if (scope.parent !== classScopeId) continue; + if (scope.kind === 'Class') continue; + for (const def of scope.ownedDefs) { + if (isCallableMember(def)) methods.push(def); + } + } + return methods; +} + +function conversionOperatorTarget(simpleName: string): string | undefined { + const match = /^operator\s+(.+)$/.exec(simpleName); + if (match === null) return undefined; + const target = normalizeCppParamType(match[1]); + return target.length > 0 ? target : undefined; +} + +function conversionKey(argType: string, paramType: string): string { + return `${argType}\0${paramType}`; +} + +function normalizedSimpleName(def: SymbolDefinition): string { + return normalizeCppParamType(simpleNameOf(def)); +} + +function simpleNameOf(def: SymbolDefinition): string { + return def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; +} + +function isClassLike(def: SymbolDefinition): boolean { + return def.type === 'Class' || def.type === 'Struct' || def.type === 'Interface'; +} + +function isCallableMember(def: SymbolDefinition): boolean { + return def.type === 'Method' || def.type === 'Constructor'; +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-user-defined-conversion/lib.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-overload-user-defined-conversion/lib.cpp new file mode 100644 index 000000000..79ad0b84d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-user-defined-conversion/lib.cpp @@ -0,0 +1,11 @@ +#include "lib.h" + +Wrap::Wrap(int value) {} +WrapA::WrapA(int value) {} +WrapB::WrapB(int value) {} + +void Service::f(Wrap value) {} +void Service::f(double value) {} +void Service::g(Wrap value) {} +void Service::h(WrapA value) {} +void Service::h(WrapB value) {} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-user-defined-conversion/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-overload-user-defined-conversion/lib.h new file mode 100644 index 000000000..054891c6b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-user-defined-conversion/lib.h @@ -0,0 +1,31 @@ +#pragma once + +class Wrap { +public: + Wrap(int value); +}; + +class WrapA { +public: + WrapA(int value); +}; + +class WrapB { +public: + WrapB(int value); +}; + +class Service { +public: + void f(Wrap value); + void f(double value); + void g(Wrap value); + void h(WrapA value); + void h(WrapB value); + + void run() { + f(42); + g(42); + h(42); + } +}; diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index b00aa9632..d80734ec8 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -1983,6 +1983,42 @@ describe('C++ overload resolution — pointer/nullptr/ellipsis ranks (#1637)', ( }); }); +describe('C++ overload resolution — user-defined conversion rank (#1631)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-overload-user-defined-conversion'), + () => {}, + ); + }, 60000); + + it('f(42) resolves to f(double) because standard conversion beats constructor UDC', () => { + const calls = getRelationships(result, 'CALLS'); + const fCalls = calls.filter((c) => c.source === 'run' && c.target === 'f'); + + expect(fCalls.length).toBe(1); + const target = result.graph.getNode(fCalls[0].rel.targetId); + expect(target?.properties.parameterTypes).toEqual(['double']); + }); + + it('g(42) keeps a single constructor UDC viable when no standard conversion overload exists', () => { + const calls = getRelationships(result, 'CALLS'); + const gCalls = calls.filter((c) => c.source === 'run' && c.target === 'g'); + + expect(gCalls.length).toBe(1); + const target = result.graph.getNode(gCalls[0].rel.targetId); + expect(target?.properties.parameterTypes).toEqual(['Wrap']); + }); + + it('h(42) emits zero CALLS edges when two single-step constructor UDCs tie', () => { + const calls = getRelationships(result, 'CALLS'); + const hCalls = calls.filter((c) => c.source === 'run' && c.target === 'h'); + + expect(hCalls.length).toBe(0); + }); +}); + // --------------------------------------------------------------------------- // U3: anonymous-namespace symbols MUST NOT leak across translation units // (full-pipeline integration test; unit-level coverage exists separately) diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 26c586ad1..b0069ac0c 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -307,6 +307,12 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly` overloads // guarded by mutually-exclusive `enable_if_t` predicates collapse diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-overload-ranking.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-overload-ranking.test.ts index 5cb679a37..5d35cefe1 100644 --- a/gitnexus/test/unit/scope-resolution/cpp/cpp-overload-ranking.test.ts +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-overload-ranking.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; import type { ParameterTypeClass, SymbolDefinition } from 'gitnexus-shared'; import { cppConversionRank } from '../../../../src/core/ingestion/languages/cpp/conversion-rank.js'; +import { + clearCppUserDefinedConversions, + registerCppUserDefinedConversion, +} from '../../../../src/core/ingestion/languages/cpp/user-defined-conversions.js'; import { narrowOverloadCandidates } from '../../../../src/core/ingestion/scope-resolution/passes/overload-narrowing.js'; const value = (base: string): ParameterTypeClass => ({ @@ -57,7 +61,36 @@ describe('cppConversionRank pointer/nullptr/ellipsis ranks (#1637)', () => { }); it('ranks ellipsis as the worst viable conversion', () => { - expect(cppConversionRank('int', '...', value('int'), ellipsis())).toBe(4); + expect(cppConversionRank('int', '...', value('int'), ellipsis())).toBe(5); + }); +}); + +describe('cppConversionRank user-defined conversion ranks (#1631)', () => { + it('ranks registered one-step user-defined conversions after standard conversions', () => { + clearCppUserDefinedConversions(); + registerCppUserDefinedConversion('int', 'Wrap'); + + expect(cppConversionRank('int', 'Wrap', value('int'), value('Wrap'))).toBe(4); + expect(cppConversionRank('int', 'double', value('int'), value('double'))).toBe(2); + + clearCppUserDefinedConversions(); + }); + + it('keeps tied user-defined conversion candidates ambiguous', () => { + clearCppUserDefinedConversions(); + registerCppUserDefinedConversion('int', 'WrapA'); + registerCppUserDefinedConversion('int', 'WrapB'); + + const byWrapA = mkDef('h:WrapA', ['WrapA'], [value('WrapA')]); + const byWrapB = mkDef('h:WrapB', ['WrapB'], [value('WrapB')]); + + const result = narrowOverloadCandidates([byWrapA, byWrapB], 1, ['int'], { + argumentTypeClasses: [value('int')], + conversionRankFn: cppConversionRank, + }); + + expect(result.map((d) => d.nodeId)).toEqual(['h:WrapA', 'h:WrapB']); + clearCppUserDefinedConversions(); }); });