feat(cpp): rank user-defined conversions

This commit is contained in:
azizur100389 2026-05-26 04:13:11 +01:00
parent d4449b4ec8
commit a1d81164fd
8 changed files with 229 additions and 4 deletions

View file

@ -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;
}

View file

@ -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

View file

@ -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<string>();
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<ScopeId, (typeof parsed.scopes)[number]>();
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<ScopeId, (typeof parsed.scopes)[number]>,
): 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';
}

View file

@ -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) {}

View file

@ -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);
}
};

View file

@ -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)

View file

@ -307,6 +307,12 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
'g(1, 2) resolves to fixed-arity g(int, int), not g(int, ...)',
"h(1, 'a') resolves to h(int, double), not h(int, ...)",
'k(1, 2, 3) keeps the ellipsis overload viable when it is the only match',
// User-defined conversion ranking (#1631) builds on the C++
// conversion-rank hook and the registry-primary C++ owner sidecars.
// Legacy DAG has no user-defined-conversion sidecar or ranking path.
'f(42) resolves to f(double) because standard conversion beats constructor UDC',
'g(42) keeps a single constructor UDC viable when no standard conversion overload exists',
'h(42) emits zero CALLS edges when two single-step constructor UDCs tie',
// The legacy DAG path lacks the SFINAE / `requires`-clause aware
// overload filter (issue #1579). The two `process<T>` overloads
// guarded by mutually-exclusive `enable_if_t` predicates collapse

View file

@ -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();
});
});