diff --git a/gitnexus-shared/src/scope-resolution/symbol-definition.ts b/gitnexus-shared/src/scope-resolution/symbol-definition.ts index e27605ac41..d1b30abcce 100644 --- a/gitnexus-shared/src/scope-resolution/symbol-definition.ts +++ b/gitnexus-shared/src/scope-resolution/symbol-definition.ts @@ -53,6 +53,10 @@ export interface SymbolDefinition { * `ScopeResolver.constraintCompatibility` hook during overload narrowing. * Absent for symbols that have no constraints (the common case). */ templateConstraints?: unknown; + /** True when the producing language marked this callable as explicit. + * Currently used by C++ overload ranking to exclude explicit constructors + * from implicit user-defined conversion candidates. */ + isExplicit?: boolean; /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */ ownerId?: string; } diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index 1c669738dc..354f6ce4c8 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -126,6 +126,13 @@ export function emitCppScopeCaptures( JSON.stringify(arity.parameterTypeClasses), ); } + if (hasExplicitSpecifier(fnNode)) { + grouped['@declaration.is-explicit'] = syntheticCapture( + '@declaration.is-explicit', + fnNode, + 'true', + ); + } // Detect static storage class (file-local linkage) if (hasStaticStorageClass(fnNode)) { @@ -1542,6 +1549,20 @@ function extractDeclaratorLeafName(node: SyntaxNode): string | null { return null; } +/** + * Check if a C++ declaration has an `explicit` specifier. Tree-sitter-cpp + * exposes `explicit` as a direct keyword child on constructor declarations in + * current grammar builds; the bounded text prefix keeps this resilient across + * small grammar shape differences without scanning whole function bodies. + */ +function hasExplicitSpecifier(node: SyntaxNode): boolean { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child !== null && child.text === 'explicit') return true; + } + return /\bexplicit\b/.test(node.text.slice(0, 128)); +} + /** * Check if a C++ function_definition or declaration has `static` storage class. */ diff --git a/gitnexus/src/core/ingestion/languages/cpp/conversion-rank.ts b/gitnexus/src/core/ingestion/languages/cpp/conversion-rank.ts index bea3600a7c..330deea09e 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/query.ts b/gitnexus/src/core/ingestion/languages/cpp/query.ts index d42b586cea..af15e81e6e 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/query.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/query.ts @@ -225,6 +225,12 @@ const CPP_SCOPE_QUERY = ` declarator: (function_declarator declarator: (field_identifier) @declaration.name))) @declaration.method +;; Constructor prototype in class body: User(int id); +(field_declaration_list + (declaration + declarator: (function_declarator + declarator: (identifier) @declaration.name)) @declaration.method) + ;; Method prototype with reference return: User& getRef(); (field_declaration declarator: (reference_declarator diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts index 9a5ac85a55..40ff978d6d 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): implicit converting constructors only, + // with no chaining or conversion-operator 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 0000000000..85fc951dfc --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/user-defined-conversions.ts @@ -0,0 +1,140 @@ +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import type { ScopeId } from 'gitnexus-shared'; +import { normalizeCppParamType } from './arity-metadata.js'; + +const userDefinedConversions = new Set(); +const pendingUserDefinedConversions: PendingUserDefinedConversion[] = []; +const classIdentitiesBySimpleName = new Map>(); + +interface PendingUserDefinedConversion { + readonly argType: string; + readonly paramType: string; + readonly ownerClassName: string; +} + +export function clearCppUserDefinedConversions(): void { + userDefinedConversions.clear(); + pendingUserDefinedConversions.length = 0; + classIdentitiesBySimpleName.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) recordClassIdentity(classDef); + } + + 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) { + if (def.isExplicit === true) continue; + registerPendingCppUserDefinedConversion(def.parameterTypes[0], className, className); + } + } + } + + rebuildCppUserDefinedConversions(); +} + +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 conversionKey(argType: string, paramType: string): string { + return `${argType}\0${paramType}`; +} + +function registerPendingCppUserDefinedConversion( + argType: string, + paramType: string, + ownerClassName: string, +): void { + if (argType === '' || paramType === '') return; + if (argType === paramType) return; + pendingUserDefinedConversions.push({ argType, paramType, ownerClassName }); +} + +function rebuildCppUserDefinedConversions(): void { + userDefinedConversions.clear(); + for (const conversion of pendingUserDefinedConversions) { + if (isAmbiguousClassName(conversion.ownerClassName)) continue; + userDefinedConversions.add(conversionKey(conversion.argType, conversion.paramType)); + } +} + +function recordClassIdentity(def: SymbolDefinition): void { + const simpleName = normalizedSimpleName(def); + if (simpleName === '') return; + const identities = classIdentitiesBySimpleName.get(simpleName) ?? new Set(); + identities.add(normalizedQualifiedClassName(def)); + classIdentitiesBySimpleName.set(simpleName, identities); +} + +function isAmbiguousClassName(simpleName: string): boolean { + return (classIdentitiesBySimpleName.get(simpleName)?.size ?? 0) > 1; +} + +function normalizedQualifiedClassName(def: SymbolDefinition): string { + const qualifiedName = def.qualifiedName ?? simpleNameOf(def); + if (qualifiedName === '' || !qualifiedName.includes('.')) return `${def.filePath}:${def.nodeId}`; + return qualifiedName + .split('.') + .map((part) => normalizeCppParamType(part)) + .join('.'); +} + +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/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 7a57e704a5..963cf4862a 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -574,6 +574,7 @@ function buildDefFromDeclarationMatch( const declaredType = match['@declaration.field-type']?.text; const returnType = match['@declaration.return-type']?.text; const templateConstraints = parseJsonCapture(match['@declaration.template-constraints']); + const isExplicit = parseBooleanCapture(match['@declaration.is-explicit']); return { nodeId: makeDefId(filePath, anchor.range, type, nameCap.text), @@ -588,6 +589,7 @@ function buildDefFromDeclarationMatch( ...(returnType !== undefined ? { returnType } : {}), ...(templateArguments !== undefined ? { templateArguments } : {}), ...(templateConstraints !== undefined ? { templateConstraints } : {}), + ...(isExplicit === true ? { isExplicit: true } : {}), }; } @@ -610,6 +612,13 @@ function parseIntCapture(cap: { readonly text: string } | undefined): number | u return Number.isFinite(n) ? n : undefined; } +function parseBooleanCapture(cap: { readonly text: string } | undefined): boolean | undefined { + if (cap === undefined) return undefined; + if (cap.text === 'true') return true; + if (cap.text === 'false') return false; + return undefined; +} + function parseJsonParameterTypeClassesCapture( cap: { readonly text: string } | undefined, ): ParameterTypeClass[] | undefined { @@ -1079,6 +1088,7 @@ const KNOWN_SUB_TAGS: ReadonlySet = new Set([ '@declaration.parameter-types', '@declaration.parameter-type-classes', '@declaration.template-constraints', + '@declaration.is-explicit', ]); /** diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-udc-namespace-collision/lib.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-overload-udc-namespace-collision/lib.cpp new file mode 100644 index 0000000000..380679ba2a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-udc-namespace-collision/lib.cpp @@ -0,0 +1,15 @@ +#include "lib.h" + +namespace alpha { + +Other::Other(int value) {} +void Service::f(Token value) {} +void Service::f(Other value) {} + +} // namespace alpha + +namespace beta { + +Token::Token(int value) {} + +} // namespace beta diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-udc-namespace-collision/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-overload-udc-namespace-collision/lib.h new file mode 100644 index 0000000000..10517d6d75 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-udc-namespace-collision/lib.h @@ -0,0 +1,31 @@ +#pragma once + +namespace alpha { + +class Token {}; + +class Other { +public: + Other(int value); +}; + +class Service { +public: + void f(Token value); + void f(Other value); + + void run() { + f(42); + } +}; + +} // namespace alpha + +namespace beta { + +class Token { +public: + Token(int value); +}; + +} // namespace beta 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 0000000000..30111c7f39 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-user-defined-conversion/lib.cpp @@ -0,0 +1,14 @@ +#include "lib.h" + +Wrap::Wrap(int value) {} +WrapA::WrapA(int value) {} +WrapB::WrapB(int value) {} +ExplicitWrap::ExplicitWrap(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) {} +void Service::e(Wrap value) {} +void Service::e(ExplicitWrap 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 0000000000..f6e1172410 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-user-defined-conversion/lib.h @@ -0,0 +1,39 @@ +#pragma once + +class Wrap { +public: + Wrap(int value); +}; + +class WrapA { +public: + WrapA(int value); +}; + +class WrapB { +public: + WrapB(int value); +}; + +class ExplicitWrap { +public: + explicit ExplicitWrap(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 e(Wrap value); + void e(ExplicitWrap value); + + void run() { + f(42); + g(42); + h(42); + e(42); + } +}; diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index b00aa9632d..babdaf5695 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -1983,6 +1983,71 @@ 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); + }); + + it('e(42) ignores the explicit-constructor overload and keeps the implicit UDC viable', () => { + const calls = getRelationships(result, 'CALLS'); + const eCalls = calls.filter((c) => c.source === 'run' && c.target === 'e'); + + expect(eCalls.length).toBe(1); + const target = result.graph.getNode(eCalls[0].rel.targetId); + expect(target?.properties.parameterTypes).toEqual(['Wrap']); + }); +}); + +describe('C++ overload resolution — UDC namespace collision guard (#1631)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-overload-udc-namespace-collision'), + () => {}, + ); + }, 60000); + + it('does not let beta::Token(int) tie the valid alpha::Other(int) conversion', () => { + 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(['Other']); + }); +}); + // --------------------------------------------------------------------------- // 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 c8a1269772..bd17483ba6 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -313,6 +313,14 @@ 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 5cb679a371..93cf86dc2f 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 { afterEach, 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 => ({ @@ -40,6 +44,10 @@ const mkDef = ( parameterTypeClasses: [...parameterTypeClasses], }); +afterEach(() => { + clearCppUserDefinedConversions(); +}); + describe('cppConversionRank pointer/nullptr/ellipsis ranks (#1637)', () => { it('ranks nullptr -> T* ahead of nullptr -> bool', () => { expect(cppConversionRank('null', 'int', value('null'), pointer('int'))).toBe(2); @@ -57,7 +65,33 @@ 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); + }); + + 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']); }); });