diff --git a/gitnexus-shared/src/scope-resolution/symbol-definition.ts b/gitnexus-shared/src/scope-resolution/symbol-definition.ts index 06814db3c..3bcdb43eb 100644 --- a/gitnexus-shared/src/scope-resolution/symbol-definition.ts +++ b/gitnexus-shared/src/scope-resolution/symbol-definition.ts @@ -57,6 +57,10 @@ export interface SymbolDefinition { * Currently used by C++ overload ranking to exclude explicit constructors * from implicit user-defined conversion candidates. */ isExplicit?: boolean; + /** True when the callable is declared unavailable (for example C++ `= delete`). + * Unavailable callables still participate in overload selection, but a + * selected unavailable target must suppress edge emission. */ + isDeleted?: boolean; /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */ ownerId?: string; /** #1982/#1993: bridge-held enclosing-namespace path (e.g. `NS1`, `Outer.Inner`) diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index bb0a3f5f4..f6f68252a 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -18,10 +18,10 @@ "_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)." }, "cpp": { - "fingerprint": "f56625342f73e182170e2c964d538e316c079fa6e9466a7f076bff2ebcf8aac4", + "fingerprint": "9b5b4393d158d76dcf1ef9807e0326462c45a5266310f0ae7894d017f3858219", "scaling_budget": 1.5, "_added": "#1956: cpp added to the scope-capture bench (was UNBENCHED). Heritage-bearing scale source (: public Base, public Mixin) drives emitCppInheritanceCaptures at scale. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in cpp/captures.ts (~12 sites, threaded c.node, byte-identical over 263 cpp-* fixtures); scaling 2.30 -> 1.12.", - "_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression).", + "_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression). #2094: deleted C++ declarations retain @declaration.is-deleted metadata; deleted operator and pointer-return shapes plus the expanded deleted-overload fixture are included. Intended capture drift; scaling remains linear (1.139 < 1.5).", "_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift — no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures — pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture — pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5)." }, "csharp": { diff --git a/gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts index e2afd51a5..6bd08710c 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts @@ -222,8 +222,14 @@ function findFuncDeclarator(node: SyntaxNode): SyntaxNode | null { } return null; } - // Unwrap pointer_declarator / reference_declarator - while (decl.type === 'pointer_declarator' || decl.type === 'reference_declarator') { + // Unwrap declarator wrappers. Deleted free functions are represented as + // `init_declarator(function_declarator, delete_expression)` by + // tree-sitter-cpp 0.23. + while ( + decl.type === 'pointer_declarator' || + decl.type === 'reference_declarator' || + decl.type === 'init_declarator' + ) { const next = decl.childForFieldName('declarator'); if (next === null) { // reference_declarator may not use field name diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index 883f571c5..ef39076f1 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -163,6 +163,13 @@ export function emitCppScopeCaptures( 'true', ); } + if (hasDeletedMethodClause(fnNode, grouped['@declaration.name']?.text)) { + grouped['@declaration.is-deleted'] = syntheticCapture( + '@declaration.is-deleted', + fnNode, + 'true', + ); + } // Detect static storage class (file-local linkage) if (hasStaticStorageClass(fnNode)) { @@ -1685,7 +1692,13 @@ function extractDeclaratorLeafName(node: SyntaxNode): string | null { let cur: SyntaxNode = node; let safety = 16; while (safety-- > 0) { - if (cur.type === 'identifier' || cur.type === 'type_identifier') return cur.text; + if ( + cur.type === 'identifier' || + cur.type === 'type_identifier' || + cur.type === 'operator_name' + ) { + return cur.text; + } // Common wrapper nodes — follow the 'declarator' field when present. const next = cur.childForFieldName('declarator') ?? @@ -1713,6 +1726,25 @@ function hasExplicitSpecifier(node: SyntaxNode): boolean { return /\bexplicit\b/.test(node.text.slice(0, 128)); } +function hasDeletedMethodClause(node: SyntaxNode, callableName: string | undefined): boolean { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'delete_method_clause') return true; + // tree-sitter-cpp 0.23 parses a deleted free-function declaration as + // `declaration > init_declarator > delete_expression`, while class + // members use the dedicated `delete_method_clause`. + if ( + child?.type === 'init_declarator' && + child.childForFieldName('value')?.type === 'delete_expression' && + callableName !== undefined && + extractDeclaratorLeafName(child.childForFieldName('declarator') ?? child) === callableName + ) { + return true; + } + } + return false; +} + /** * Check if a C++ function_definition or declaration has `static` storage class. */ diff --git a/gitnexus/src/core/ingestion/languages/cpp/query.ts b/gitnexus/src/core/ingestion/languages/cpp/query.ts index aa3202abe..b50463a7f 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/query.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/query.ts @@ -194,6 +194,29 @@ const CPP_SCOPE_QUERY = ` declarator: (function_declarator declarator: (identifier) @declaration.name)) @declaration.function +;; tree-sitter-cpp 0.23 represents a deleted free function as an +;; init_declarator whose value is a delete_expression. +(declaration + declarator: (init_declarator + declarator: (function_declarator + declarator: (identifier) @declaration.name) + value: (delete_expression))) @declaration.function + +;; Deleted free operator declaration. +(declaration + declarator: (init_declarator + declarator: (function_declarator + declarator: (operator_name) @declaration.name) + value: (delete_expression))) @declaration.function + +;; Deleted free function with a pointer return type. +(declaration + declarator: (init_declarator + declarator: (pointer_declarator + declarator: (function_declarator + declarator: (identifier) @declaration.name)) + value: (delete_expression))) @declaration.function + ;; Free operator prototype: std::ostream& operator<<(std::ostream&, T) (declaration declarator: (function_declarator diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/c-cpp.ts b/gitnexus/src/core/ingestion/method-extractors/configs/c-cpp.ts index dccc3f52f..1ca737793 100644 --- a/gitnexus/src/core/ingestion/method-extractors/configs/c-cpp.ts +++ b/gitnexus/src/core/ingestion/method-extractors/configs/c-cpp.ts @@ -42,18 +42,11 @@ function findFunctionDeclarator(node: SyntaxNode): SyntaxNode | null { return null; } -/** - * Detect `= delete` and `= default` special member function declarations. - * These are not callable methods and should be suppressed from extraction. - * tree-sitter-cpp ^0.23.4 emits `delete_method_clause` / `default_method_clause` - * as named children of the function_definition node. - */ -function isDeletedOrDefaulted(node: SyntaxNode): boolean { +/** Detect a C++ special member clause by its tree-sitter node type. */ +function hasSpecialMethodClause(node: SyntaxNode, clauseType: string): boolean { for (let i = 0; i < node.namedChildCount; i++) { const child = node.namedChild(i); - if (child?.type === 'delete_method_clause' || child?.type === 'default_method_clause') { - return true; - } + if (child?.type === clauseType) return true; } return false; } @@ -67,10 +60,6 @@ function extractCppMethodName(node: SyntaxNode): string | undefined { const funcDecl = findFunctionDeclarator(node); if (!funcDecl) return undefined; - // Suppress `= delete` and `= default` special members — these are not callable - // methods and should not appear in HAS_METHOD edges. - if (isDeletedOrDefaulted(node)) return undefined; - const nameNode = funcDecl.childForFieldName('declarator'); if (!nameNode) return undefined; // destructor_name: ~ClassName @@ -387,6 +376,10 @@ export const cppMethodConfig: MethodExtractionConfig = { } return false; }, + + isDeleted(node) { + return hasSpecialMethodClause(node, 'delete_method_clause'); + }, }; // --------------------------------------------------------------------------- diff --git a/gitnexus/src/core/ingestion/method-extractors/generic.ts b/gitnexus/src/core/ingestion/method-extractors/generic.ts index f02faef35..ecb25d939 100644 --- a/gitnexus/src/core/ingestion/method-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/method-extractors/generic.ts @@ -252,6 +252,7 @@ function buildMethod( ...(config.isAsync?.(node) ? { isAsync: true } : {}), ...(config.isPartial?.(node) ? { isPartial: true } : {}), ...(config.isConst?.(node) ? { isConst: true } : {}), + ...(config.isDeleted?.(node) ? { isDeleted: true } : {}), annotations: config.extractAnnotations?.(node) ?? [], sourceFile: context.filePath, line: node.startPosition.row + 1, diff --git a/gitnexus/src/core/ingestion/method-types.ts b/gitnexus/src/core/ingestion/method-types.ts index e1b91eca5..d263fd5db 100644 --- a/gitnexus/src/core/ingestion/method-types.ts +++ b/gitnexus/src/core/ingestion/method-types.ts @@ -33,6 +33,7 @@ export interface MethodInfo { isAsync?: boolean; isPartial?: boolean; isConst?: boolean; + isDeleted?: boolean; annotations: string[]; sourceFile: string; line: number; @@ -84,6 +85,7 @@ export interface MethodExtractionConfig { isAsync?: (node: SyntaxNode) => boolean; isPartial?: (node: SyntaxNode) => boolean; isConst?: (node: SyntaxNode) => boolean; + isDeleted?: (node: SyntaxNode) => boolean; /** Owner node types where member functions are effectively static (e.g. * Ruby singleton_class, Kotlin companion_object / object_declaration). * When the ownerNode matches one of these types, isStatic is forced true. */ diff --git a/gitnexus/src/core/ingestion/model/symbol-table.ts b/gitnexus/src/core/ingestion/model/symbol-table.ts index 04ed8ca18..d7330a093 100644 --- a/gitnexus/src/core/ingestion/model/symbol-table.ts +++ b/gitnexus/src/core/ingestion/model/symbol-table.ts @@ -131,6 +131,7 @@ export interface AddMetadata { templateArguments?: string[]; ownerId?: string; qualifiedName?: string; + isDeleted?: boolean; } /** @@ -285,6 +286,7 @@ export const createSymbolTable = (): InternalSymbolTable => { ? { templateArguments: metadata.templateArguments } : {}), ...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}), + ...(metadata?.isDeleted === true ? { isDeleted: true } : {}), }; // A. File Index — unconditional. diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index bf11e9f32..815099674 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -104,6 +104,7 @@ export const mergeChunkResults = ( templateArguments: sym.templateArguments, ownerId: sym.ownerId, qualifiedName: sym.qualifiedName, + isDeleted: sym.isDeleted, }); } if (exportedTypeMap) { diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index e5b9a71ac..1977767d4 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -575,6 +575,7 @@ function buildDefFromDeclarationMatch( const returnType = match['@declaration.return-type']?.text; const templateConstraints = parseJsonCapture(match['@declaration.template-constraints']); const isExplicit = parseBooleanCapture(match['@declaration.is-explicit']); + const isDeleted = parseBooleanCapture(match['@declaration.is-deleted']); return { nodeId: makeDefId(filePath, anchor.range, type, nameCap.text), @@ -590,6 +591,7 @@ function buildDefFromDeclarationMatch( ...(templateArguments !== undefined ? { templateArguments } : {}), ...(templateConstraints !== undefined ? { templateConstraints } : {}), ...(isExplicit === true ? { isExplicit: true } : {}), + ...(isDeleted === true ? { isDeleted: true } : {}), }; } @@ -1158,6 +1160,7 @@ const KNOWN_SUB_TAGS: ReadonlySet = new Set([ '@declaration.return-type', '@declaration.template-constraints', '@declaration.is-explicit', + '@declaration.is-deleted', ]); /** 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 8c79fdb41..1a8d1d0f4 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 @@ -383,6 +383,18 @@ export function emitFreeCallFallback( ); } if (fnDef === undefined) continue; + if (fnDef.isDeleted === true) { + recordSuppressedOutcome(options.recordResolutionOutcome, { + phase: 'free-call-fallback', + filePath: parsed.filePath, + name: site.name, + range: site.atRange, + reason: 'selected-callable-deleted', + candidates: [fnDef], + }); + handledSites.add(siteKey(parsed.filePath, site)); + continue; + } if ( (fnDefFromImplicitThis || fnDef.type === 'Method' || fnDef.type === 'Constructor') && options.isCallableVisibleFromCaller !== undefined && 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 1b7429708..f1e014772 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 @@ -205,8 +205,14 @@ export function emitReceiverBoundCalls( if (impls === undefined) return 0; let n = 0; for (const implDef of impls) { - const implMember = findOwnedMember(implDef.nodeId, memberName, model); - if (implMember === undefined) continue; + const implMember = pickOverload(implDef.nodeId, memberName, site, model, provider); + if ( + implMember === undefined || + implMember === OVERLOAD_AMBIGUOUS || + implMember.isDeleted === true + ) { + continue; + } if (implMember.nodeId === primaryMemberDef.nodeId) continue; const ok = tryEmitEdge( graph, @@ -257,11 +263,46 @@ export function emitReceiverBoundCalls( ? extendsOnly(enclosingClass.nodeId) : scopes.methodDispatch.mroFor(enclosingClass.nodeId); let memberDef: SymbolDefinition | undefined; + let ambiguousOwnerId: string | undefined; for (const ownerId of ancestors) { - memberDef = findOwnedMember(ownerId, memberName, model); - if (memberDef !== undefined) break; + const picked = + site.kind === 'call' + ? pickOverload(ownerId, memberName, site, model, provider) + : findOwnedMember(ownerId, memberName, model); + if (picked === OVERLOAD_AMBIGUOUS) { + ambiguousOwnerId = ownerId; + break; + } + if (picked !== undefined) { + memberDef = picked; + break; + } + } + if (ambiguousOwnerId !== undefined) { + recordReceiverOverloadSuppression( + options.recordResolutionOutcome, + parsed.filePath, + site, + ambiguousOwnerId, + memberName, + model, + provider, + ); + handledSites.add(siteKey); + continue; } if (memberDef !== undefined) { + if ( + suppressDeletedCallTarget( + options.recordResolutionOutcome, + parsed.filePath, + site, + memberDef, + ) + ) { + handledSites.add(siteKey); + continue; + } // Super/base calls resolve through the MRO chain, not // through imports — the ancestor method is found by // walking `methodDispatch.mroFor(enclosingClass)`, which @@ -312,9 +353,9 @@ export function emitReceiverBoundCalls( if (currentClass !== undefined) { const chain = [currentClass.nodeId, ...scopes.methodDispatch.mroFor(currentClass.nodeId)]; let memberDef: SymbolDefinition | undefined; + let ambiguousOwnerId: string | undefined; // Static-only filter (#1756 / U3): same shape as Case 4's - // chain walk (skip-and-walk-on) but without overload - // narrowing — Case 0 uses `findOwnedMember` directly. When + // overload-aware chain walk (skip-and-walk-on). When // an owner's resolved candidate is static-only (Kotlin // companion-promoted), continue to the next ancestor in // the MRO chain so a legitimate instance member can bind. @@ -326,16 +367,45 @@ export function emitReceiverBoundCalls( // shapes like `Logger.create("a")`), so there's no wrong // target to suppress. for (const ownerId of chain) { - const candidate = findOwnedMember(ownerId, memberName, model); - if (candidate === undefined) continue; - if (provider.isStaticOnly?.(candidate) === true) { - // Skip static-only candidate; walk to next ancestor. + const picked = + site.kind === 'call' + ? pickFirstNonStaticOnly(ownerId, memberName, site, model, provider) + : findOwnedMember(ownerId, memberName, model); + if (picked === OVERLOAD_AMBIGUOUS) { + ambiguousOwnerId = ownerId; + break; + } + if (picked === STATIC_ONLY_FILTERED || picked === undefined) { continue; } - memberDef = candidate; + memberDef = picked; break; } + if (ambiguousOwnerId !== undefined) { + recordReceiverOverloadSuppression( + options.recordResolutionOutcome, + parsed.filePath, + site, + ambiguousOwnerId, + memberName, + model, + provider, + ); + handledSites.add(siteKey); + continue; + } if (memberDef !== undefined) { + if ( + suppressDeletedCallTarget( + options.recordResolutionOutcome, + parsed.filePath, + site, + memberDef, + ) + ) { + handledSites.add(siteKey); + continue; + } const ok = tryEmitEdge( graph, scopes, @@ -398,6 +468,17 @@ export function emitReceiverBoundCalls( } if (languageResolution?.kind === 'resolved') { const memberDef = languageResolution.definition; + if ( + suppressDeletedCallTarget( + options.recordResolutionOutcome, + parsed.filePath, + site, + memberDef, + ) + ) { + handledSites.add(siteKey); + continue; + } const reason = site.kind === 'write' || site.kind === 'read' ? site.kind @@ -482,6 +563,17 @@ export function emitReceiverBoundCalls( continue; } if (memberDef !== undefined) { + if ( + suppressDeletedCallTarget( + options.recordResolutionOutcome, + parsed.filePath, + site, + memberDef, + ) + ) { + handledSites.add(siteKey); + continue; + } const reason = site.kind === 'write' || site.kind === 'read' ? site.kind @@ -509,11 +601,23 @@ export function emitReceiverBoundCalls( // ── Case 1: namespace receiver ─────────────────────────────── const targetFiles = namespaceTargets.get(receiverName); - if (targetFiles !== undefined) { + if (targetFiles !== undefined && provider.resolveQualifiedReceiverMember === undefined) { let found = false; for (const targetFile of targetFiles) { const memberDef = findExportedDef(targetFile, memberName, index); if (memberDef !== undefined) { + if ( + suppressDeletedCallTarget( + options.recordResolutionOutcome, + parsed.filePath, + site, + memberDef, + ) + ) { + handledSites.add(siteKey); + found = true; + break; + } const ok = tryEmitEdge( graph, scopes, @@ -565,6 +669,17 @@ export function emitReceiverBoundCalls( continue; } if (memberDef !== undefined) { + if ( + suppressDeletedCallTarget( + options.recordResolutionOutcome, + parsed.filePath, + site, + memberDef, + ) + ) { + handledSites.add(siteKey); + continue; + } const ok = tryEmitEdge( graph, scopes, @@ -587,9 +702,18 @@ export function emitReceiverBoundCalls( if (classDef !== undefined) { const chain = [classDef.nodeId, ...scopes.methodDispatch.mroFor(classDef.nodeId)]; let memberDef: SymbolDefinition | undefined; + let ambiguousOwnerId: string | undefined; for (const ownerId of chain) { - memberDef = findOwnedMember(ownerId, memberName, model); - if (memberDef !== undefined) { + const picked = + site.kind === 'call' + ? pickOverload(ownerId, memberName, site, model, provider) + : findOwnedMember(ownerId, memberName, model); + if (picked === OVERLOAD_AMBIGUOUS) { + ambiguousOwnerId = ownerId; + break; + } + if (picked !== undefined) { + memberDef = picked; // The MRO chain is most-derived-first ([classDef, ...ancestors]). // If the most-derived definition is arity-incompatible with the // call site, PHP throws ArgumentCountError at runtime — it does @@ -605,7 +729,31 @@ export function emitReceiverBoundCalls( break; } } + if (ambiguousOwnerId !== undefined) { + recordReceiverOverloadSuppression( + options.recordResolutionOutcome, + parsed.filePath, + site, + ambiguousOwnerId, + memberName, + model, + provider, + ); + handledSites.add(siteKey); + continue; + } if (memberDef !== undefined) { + if ( + suppressDeletedCallTarget( + options.recordResolutionOutcome, + parsed.filePath, + site, + memberDef, + ) + ) { + handledSites.add(siteKey); + continue; + } const reason = site.kind === 'write' || site.kind === 'read' ? site.kind @@ -641,8 +789,38 @@ export function emitReceiverBoundCalls( for (const targetFile3 of targetFiles3) { const classDef3 = findExportedDef(targetFile3, className, index); if (classDef3 !== undefined) { - const memberDef = findOwnedMember(classDef3.nodeId, memberName, model); - if (memberDef !== undefined) { + const picked = + site.kind === 'call' + ? pickOverload(classDef3.nodeId, memberName, site, model, provider) + : findOwnedMember(classDef3.nodeId, memberName, model); + if (picked === OVERLOAD_AMBIGUOUS) { + recordReceiverOverloadSuppression( + options.recordResolutionOutcome, + parsed.filePath, + site, + classDef3.nodeId, + memberName, + model, + provider, + ); + handledSites.add(siteKey); + found3 = true; + break; + } + if (picked !== undefined) { + const memberDef = picked; + if ( + suppressDeletedCallTarget( + options.recordResolutionOutcome, + parsed.filePath, + site, + memberDef, + ) + ) { + handledSites.add(siteKey); + found3 = true; + break; + } const ok = tryEmitEdge( graph, scopes, @@ -700,8 +878,9 @@ export function emitReceiverBoundCalls( if (ownerDef !== undefined) { const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)]; let memberDef: SymbolDefinition | undefined; - // Static-only filter (#1756 / U3): mirrors Case 0's chain - // walk — `findOwnedMember` without overload narrowing. When + let ambiguousOwnerId: string | undefined; + // Static-only filter (#1756 / U3): mirrors Case 0's + // overload-aware chain walk. When // a static-only candidate is found at an ancestor, walk on // so a legitimate instance member can bind. If the entire // chain is static-only, no edge is emitted (Case 3b is fed @@ -709,15 +888,45 @@ export function emitReceiverBoundCalls( // `emitReferencesViaLookup` for compound shapes, so no // handled-site marker is needed for chain-only-static). for (const ownerId of chain) { - const candidate = findOwnedMember(ownerId, memberName, model); - if (candidate === undefined) continue; - if (provider.isStaticOnly?.(candidate) === true) { + const picked = + site.kind === 'call' + ? pickFirstNonStaticOnly(ownerId, memberName, site, model, provider) + : findOwnedMember(ownerId, memberName, model); + if (picked === OVERLOAD_AMBIGUOUS) { + ambiguousOwnerId = ownerId; + break; + } + if (picked === STATIC_ONLY_FILTERED || picked === undefined) { continue; } - memberDef = candidate; + memberDef = picked; break; } + if (ambiguousOwnerId !== undefined) { + recordReceiverOverloadSuppression( + options.recordResolutionOutcome, + parsed.filePath, + site, + ambiguousOwnerId, + memberName, + model, + provider, + ); + handledSites.add(siteKey); + continue; + } if (memberDef !== undefined) { + if ( + suppressDeletedCallTarget( + options.recordResolutionOutcome, + parsed.filePath, + site, + memberDef, + ) + ) { + handledSites.add(siteKey); + continue; + } const ok = tryEmitEdge( graph, scopes, @@ -790,6 +999,17 @@ export function emitReceiverBoundCalls( } if (languageResolution?.kind === 'resolved') { const memberDef = languageResolution.definition; + if ( + suppressDeletedCallTarget( + options.recordResolutionOutcome, + parsed.filePath, + site, + memberDef, + ) + ) { + handledSites.add(siteKey); + continue; + } const reason = site.kind === 'write' || site.kind === 'read' ? site.kind @@ -892,6 +1112,17 @@ export function emitReceiverBoundCalls( continue; } if (memberDef !== undefined) { + if ( + suppressDeletedCallTarget( + options.recordResolutionOutcome, + parsed.filePath, + site, + memberDef, + ) + ) { + handledSites.add(siteKey); + continue; + } // For read/write ACCESSES, mirror the legacy DAG's reason // convention so consumers asserting `reason === 'write'` // keep working. @@ -968,6 +1199,17 @@ export function emitReceiverBoundCalls( continue; } if (picked !== undefined) { + if ( + suppressDeletedCallTarget( + options.recordResolutionOutcome, + parsed.filePath, + site, + picked, + ) + ) { + handledSites.add(siteKey); + continue; + } // Static-only filter (#1756 / U3): unlike Case 4 there's no // MRO chain to walk here — Case 5 dispatches on a single // owner via `pickOverload`. When the picked candidate is @@ -1152,6 +1394,25 @@ function pickFirstNonStaticOnly( return candidates[0] ?? overloads[0]; } +function suppressDeletedCallTarget( + record: ResolutionOutcomeRecorder | undefined, + filePath: string, + site: ParsedFile['referenceSites'][number], + target: SymbolDefinition, +): boolean { + if (site.kind !== 'call' || target.isDeleted !== true) return false; + record?.({ + kind: 'suppressed', + phase: 'receiver-bound-calls', + filePath, + name: site.name, + range: site.atRange, + reason: 'selected-callable-deleted', + candidateIds: [target.nodeId], + }); + return true; +} + function recordReceiverOverloadSuppression( record: ResolutionOutcomeRecorder | undefined, filePath: string, diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/reconcile-ownership.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/reconcile-ownership.ts index 2ec47d489..a5a4ba50d 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/reconcile-ownership.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/reconcile-ownership.ts @@ -84,13 +84,41 @@ export function reconcileOwnership( for (const parsed of parsedFiles) { for (const def of parsed.localDefs) { const ownerId = (def as { ownerId?: string }).ownerId; - if (ownerId === undefined) continue; const simple = simpleQualifiedName(def); if (simple === undefined) continue; if (def.type === 'Method' || def.type === 'Function' || def.type === 'Constructor') { + if (ownerId === undefined) { + if (def.isDeleted !== true) continue; + const existingDef = model.symbols + .lookupExactAll(def.filePath, simple) + .find((candidate) => callableSignatureMatches(candidate, def)); + if (existingDef !== undefined) { + existingDef.isDeleted = true; + skippedAlreadyPresent++; + continue; + } + model.symbols.add(def.filePath, simple, def.nodeId, def.type, { + parameterCount: def.parameterCount, + requiredParameterCount: def.requiredParameterCount, + parameterTypes: def.parameterTypes, + parameterTypeClasses: def.parameterTypeClasses, + returnType: def.returnType, + qualifiedName: def.qualifiedName, + isDeleted: true, + }); + continue; + } const existing = model.methods.lookupAllByOwner(ownerId, simple); - if (existing.some((e) => e.nodeId === def.nodeId)) { + const existingDef = existing.find( + (candidate) => + candidate.nodeId === def.nodeId || + (def.isDeleted === true && callableSignatureMatches(candidate, def)), + ); + if (existingDef !== undefined) { + if (def.isDeleted === true) { + existingDef.isDeleted = true; + } skippedAlreadyPresent++; continue; } @@ -124,6 +152,24 @@ export function reconcileOwnership( return { methodsRegistered, fieldsRegistered, nestedTypesRegistered, skippedAlreadyPresent }; } +function callableSignatureMatches( + left: ParsedFile['localDefs'][number], + right: ParsedFile['localDefs'][number], +): boolean { + if (left.filePath !== right.filePath) return false; + if (left.parameterCount !== right.parameterCount) return false; + if (left.requiredParameterCount !== right.requiredParameterCount) return false; + const leftTypes = left.parameterTypes; + const rightTypes = right.parameterTypes; + if (leftTypes === undefined || rightTypes === undefined) { + return leftTypes === rightTypes; + } + return ( + leftTypes.length === rightTypes.length && + leftTypes.every((parameterType, index) => parameterType === rightTypes[index]) + ); +} + /** * Debug-mode parity validator. Runs only when * `VALIDATE_SEMANTIC_MODEL !== '0'` AND `NODE_ENV !== 'production'`. diff --git a/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts b/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts index b46b47f72..ad6a19370 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts @@ -5,6 +5,7 @@ export type ResolutionSuppressionReason = | 'conversion-rank-tied' | 'inline-ns-ambiguous' | 'member-lookup-ambiguous' + | 'selected-callable-deleted' | 'overload-ambiguous' | 'overload-ambiguous-normalization'; diff --git a/gitnexus/src/core/ingestion/utils/method-props.ts b/gitnexus/src/core/ingestion/utils/method-props.ts index b97b5de4b..5f8fa1a6b 100644 --- a/gitnexus/src/core/ingestion/utils/method-props.ts +++ b/gitnexus/src/core/ingestion/utils/method-props.ts @@ -213,6 +213,7 @@ export function buildMethodProps(info: MethodInfo): Record { ...(info.isAsync ? { isAsync: info.isAsync } : {}), ...(info.isPartial ? { isPartial: info.isPartial } : {}), ...(info.isConst ? { isConst: info.isConst } : {}), + ...(info.isDeleted ? { isDeleted: info.isDeleted } : {}), ...(info.annotations.length > 0 ? { annotations: info.annotations } : {}), }; } diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index dcb674258..f5004f8fe 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -226,6 +226,7 @@ interface ParsedSymbol { isReadonly?: boolean; isAbstract?: boolean; isFinal?: boolean; + isDeleted?: boolean; annotations?: string[]; } @@ -2207,6 +2208,9 @@ const processFileGroup = ( isReadonly: methodProps.isReadonly as boolean | undefined, isAbstract: methodProps.isAbstract as boolean | undefined, isFinal: methodProps.isFinal as boolean | undefined, + ...(methodProps.isDeleted !== undefined + ? { isDeleted: methodProps.isDeleted as boolean } + : {}), ...(methodProps.isVirtual !== undefined ? { isVirtual: methodProps.isVirtual as boolean } : {}), diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-deleted-overload/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-deleted-overload/main.cpp new file mode 100644 index 000000000..2aeaf680f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-deleted-overload/main.cpp @@ -0,0 +1,84 @@ +void choose(double) {} +void choose(int) = delete; + +void call_live_free() { + choose(1.5); +} + +void call_deleted_free() { + choose(1); +} + +struct Gadget { + Gadget() = default; + + void touch(int) {} + void touch(double) = delete; +}; + +struct BaseChoice { + void select(double) = delete; + void select(int) {} +}; + +struct DerivedChoice : BaseChoice { + void call_base_qualified_live() { + BaseChoice::select(1); + } +}; + +struct StaticChoice { + static void select(double) = delete; + static void select(int) {} +}; + +struct DefaultedChoice { + DefaultedChoice(const DefaultedChoice&) = default; + DefaultedChoice(int) {} +}; + +namespace choices { +void select(double) = delete; +void select(int); +} + +void call_live_member(Gadget& gadget) { + gadget.touch(1); +} + +void call_deleted_member(Gadget& gadget) { + gadget.touch(1.5); +} + +void call_defaulted_constructor() { + auto gadget = Gadget(); +} + +void call_same_arity_defaulted_constructor() { + DefaultedChoice source(1); + auto copy = DefaultedChoice(source); +} + +void call_inherited_live(DerivedChoice& choice) { + choice.select(1); +} + +void call_inherited_deleted(DerivedChoice& choice) { + choice.select(1.5); +} + +void call_static_live() { + StaticChoice::select(1); +} + +void call_static_deleted() { + StaticChoice::select(1.5); +} + +void call_namespace_live() { + choices::select(1); +} + +void call_namespace_deleted() { + choices::select(1.5); +} diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 9b6a98bcb..925602a5c 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -4385,3 +4385,78 @@ describe('C++ root-anchored base ignores enclosing-relative type (issue #1982)', expect(e!.rel.targetId).not.toContain('Wrap'); }); }); + +describe('C++ deleted overload selection (#1893 A2)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-deleted-overload'), () => {}); + }, 60000); + + const callsFrom = (source: string, target: string) => + getRelationships(result, 'CALLS').filter( + (edge) => edge.source === source && edge.target === target, + ); + const targetParameterTypes = (source: string, target: string) => { + const edge = callsFrom(source, target); + expect(edge).toHaveLength(1); + return result.graph.getNode(edge[0]!.rel.targetId)?.properties.parameterTypes; + }; + + it('keeps a live free-function winner callable', () => { + expect(callsFrom('call_live_free', 'choose')).toHaveLength(1); + }); + + it('suppresses a deleted best free-function match instead of rerouting', () => { + expect(callsFrom('call_deleted_free', 'choose')).toHaveLength(0); + }); + + it('keeps a live member winner callable', () => { + expect(targetParameterTypes('call_live_member', 'touch')).toEqual(['int']); + }); + + it('suppresses a deleted best member match instead of rerouting', () => { + expect(callsFrom('call_deleted_member', 'touch')).toHaveLength(0); + }); + + it('keeps a defaulted constructor callable', () => { + expect(callsFrom('call_defaulted_constructor', 'Gadget')).toHaveLength(1); + }); + + it('ranks a live base-qualified overload declared after a deleted sibling', () => { + expect(targetParameterTypes('call_base_qualified_live', 'select')).toEqual(['int']); + }); + + it('ranks inherited overloads before applying deleted suppression', () => { + expect(targetParameterTypes('call_inherited_live', 'select')).toEqual(['int']); + expect(callsFrom('call_inherited_deleted', 'select')).toHaveLength(0); + }); + + it('ranks class-qualified static overloads before applying deleted suppression', () => { + expect(targetParameterTypes('call_static_live', 'select')).toEqual(['int']); + expect(callsFrom('call_static_deleted', 'select')).toHaveLength(0); + }); + + it('ranks namespace-qualified overloads before applying deleted suppression', () => { + expect(targetParameterTypes('call_namespace_live', 'select')).toEqual(['int']); + expect(callsFrom('call_namespace_deleted', 'select')).toHaveLength(0); + }); + + it('keeps a same-arity defaulted copy constructor callable', () => { + expect(callsFrom('call_same_arity_defaulted_constructor', 'DefaultedChoice')).toHaveLength(1); + }); + + it('records every deleted-winner suppression explicitly', () => { + const outcomes = getResolutionOutcomes(result).filter( + (outcome) => outcome.kind === 'suppressed' && outcome.reason === 'selected-callable-deleted', + ); + expect(outcomes).toHaveLength(5); + expect(outcomes.map((outcome) => outcome.name).sort()).toEqual([ + 'choose', + 'select', + 'select', + 'select', + 'touch', + ]); + }); +}); diff --git a/gitnexus/test/unit/method-extraction.test.ts b/gitnexus/test/unit/method-extraction.test.ts index cda7e7ee2..06711a958 100644 --- a/gitnexus/test/unit/method-extraction.test.ts +++ b/gitnexus/test/unit/method-extraction.test.ts @@ -2399,7 +2399,7 @@ describe('C++ MethodExtractor', () => { expect(result!.methods[1].visibility).toBe('public'); }); - it('suppresses = delete special members from extraction', () => { + it('retains = delete special members and marks them unavailable', () => { const tree = parseCPP(` class NonCopyable { public: @@ -2411,11 +2411,12 @@ describe('C++ MethodExtractor', () => { const classNode = tree.rootNode.child(0)!; const result = extractor.extract(classNode, cppCtx); - expect(result!.methods).toHaveLength(1); - expect(result!.methods[0].name).toBe('doWork'); + expect(result!.methods).toHaveLength(3); + expect(result!.methods.filter((method) => method.isDeleted)).toHaveLength(2); + expect(result!.methods.find((method) => method.name === 'doWork')?.isDeleted).toBeUndefined(); }); - it('suppresses = default special members from extraction', () => { + it('retains = default special members as callable', () => { const tree = parseCPP(` class Widget { public: @@ -2427,8 +2428,9 @@ describe('C++ MethodExtractor', () => { const classNode = tree.rootNode.child(0)!; const result = extractor.extract(classNode, cppCtx); - expect(result!.methods).toHaveLength(1); - expect(result!.methods[0].name).toBe('paint'); + expect(result!.methods).toHaveLength(3); + expect(result!.methods.map((method) => method.name)).toEqual(['Widget', '~Widget', 'paint']); + expect(result!.methods.every((method) => method.isDeleted !== true)).toBe(true); }); it('does not suppress = 0 (pure virtual) as deleted/defaulted', () => { diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts index 37b6de842..29b224d27 100644 --- a/gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts @@ -7,6 +7,8 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { emitCppScopeCaptures } from '../../../../src/core/ingestion/languages/cpp/captures.js'; +import { cppProvider } from '../../../../src/core/ingestion/languages/c-cpp.js'; +import { extractParsedFile } from '../../../../src/core/ingestion/scope-extractor-bridge.js'; import { clearFileLocalNames, isFileLocal, @@ -424,6 +426,72 @@ describe('emitCppScopeCaptures — arity enrichment', () => { expect(m!['@declaration.parameter-count'].text).toBe('2'); }); + it('tags deleted declarations but not defaulted declarations', () => { + const deleted = findMatch('void foo(int) = delete;', (tags) => + tags.includes('@declaration.is-deleted'), + ); + const defaulted = emitCppScopeCaptures('struct S { S() = default; };', 'test.cpp').find( + (match) => Object.values(match).some((capture) => capture.text.includes('= default')), + ); + + expect(deleted?.['@declaration.is-deleted'].text).toBe('true'); + expect(defaulted).toBeDefined(); + expect(defaulted?.['@declaration.is-deleted']).toBeUndefined(); + }); + + it('tags deleted free operators', () => { + const deleted = findMatch( + 'struct S {}; bool operator==(const S&, const S&) = delete;', + (tags) => tags.includes('@declaration.is-deleted'), + ); + + expect(deleted?.['@declaration.name'].text).toBe('operator=='); + expect(deleted?.['@declaration.is-deleted'].text).toBe('true'); + }); + + it('tags deleted pointer-return free functions', () => { + const deleted = findMatch('int* lookup(int) = delete;', (tags) => + tags.includes('@declaration.is-deleted'), + ); + + expect(deleted?.['@declaration.name'].text).toBe('lookup'); + expect(deleted?.['@declaration.is-deleted'].text).toBe('true'); + }); + + it('does not borrow a deleted initializer from another declarator', () => { + const declarations = allMatches('void f(int), g = delete(new int);', (tags) => + tags.includes('@declaration.function'), + ); + const f = declarations.find((match) => match['@declaration.name']?.text === 'f'); + + expect(f).toBeDefined(); + expect(f?.['@declaration.is-deleted']).toBeUndefined(); + }); + + it('preserves deleted-callable metadata in parsed local definitions', () => { + const parsed = extractParsedFile( + cppProvider, + ` + void choose(int) = delete; + struct S { + S() = default; + void touch(double) = delete; + }; + `, + 'test.cpp', + ); + + const choose = parsed?.localDefs.find((def) => def.qualifiedName === 'choose'); + const touch = parsed?.localDefs.find((def) => def.qualifiedName === 'touch'); + const constructor = parsed?.localDefs.find( + (def) => def.type === 'Constructor' && def.qualifiedName === 'S', + ); + + expect(choose?.isDeleted).toBe(true); + expect(touch?.isDeleted).toBe(true); + expect(constructor?.isDeleted).not.toBe(true); + }); + it('enriches call reference with arity', () => { const src = 'void f() { foo(1, 2, 3); }'; const m = findMatch(src, (t) => t.includes('@reference.arity')); diff --git a/gitnexus/test/unit/scope-resolution/reconcile-ownership.test.ts b/gitnexus/test/unit/scope-resolution/reconcile-ownership.test.ts index b466c8ed4..497b77db9 100644 --- a/gitnexus/test/unit/scope-resolution/reconcile-ownership.test.ts +++ b/gitnexus/test/unit/scope-resolution/reconcile-ownership.test.ts @@ -212,6 +212,62 @@ describe('reconcileOwnership', () => { expect(model.methods.lookupAllByOwner('def:User', 'save')).toHaveLength(1); }); + it('preserves deleted-callable metadata when the worker def is already registered', () => { + const model = createSemanticModel(); + model.symbols.add('models.cpp', 'touch', 'Method:models.cpp:Gadget.touch', 'Method', { + ownerId: 'def:Gadget', + qualifiedName: 'Gadget.touch', + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['double'], + }); + const deleted = { + ...mkMethod({ + nodeId: 'def:models.cpp#3:2:Method:touch', + filePath: 'models.cpp', + name: 'touch', + ownerId: 'def:Gadget', + }), + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['double'], + isDeleted: true, + }; + + const stats = reconcileOwnership([mkFile('models.cpp', [deleted])], model); + const registered = model.methods.lookupAllByOwner('def:Gadget', 'touch'); + + expect(stats.skippedAlreadyPresent).toBe(1); + expect(registered).toHaveLength(1); + expect(registered[0].isDeleted).toBe(true); + }); + + it('preserves deleted metadata for unowned free-function overloads', () => { + const model = createSemanticModel(); + model.symbols.add('helpers.cpp', 'choose', 'Function:helpers.cpp:choose#int', 'Function', { + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['int'], + }); + const deleted: SymbolDefinition = { + nodeId: 'def:helpers.cpp#4:0:Function:choose', + filePath: 'helpers.cpp', + type: 'Function', + qualifiedName: 'choose', + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['int'], + isDeleted: true, + }; + + const stats = reconcileOwnership([mkFile('helpers.cpp', [deleted])], model); + const registered = model.symbols.lookupCallableByName('choose'); + + expect(stats.skippedAlreadyPresent).toBe(1); + expect(registered).toHaveLength(1); + expect(registered[0].isDeleted).toBe(true); + }); + it('registers nested class-like types (Class/Enum/Interface) into TypeRegistry by owner', () => { const model = createSemanticModel(); const inner: SymbolDefinition = { diff --git a/gitnexus/test/unit/symbol-table.test.ts b/gitnexus/test/unit/symbol-table.test.ts index 748cbfb3c..0b00186e3 100644 --- a/gitnexus/test/unit/symbol-table.test.ts +++ b/gitnexus/test/unit/symbol-table.test.ts @@ -142,6 +142,24 @@ describe('SymbolTable', () => { }); }); + describe('callable availability metadata', () => { + it('preserves isDeleted in file, callable, and owner indexes', () => { + table.add('src/example.cpp', 'choose', 'func:choose:int', 'Function', { + parameterTypes: ['int'], + isDeleted: true, + }); + table.add('src/example.cpp', 'touch', 'method:touch:double', 'Method', { + ownerId: 'class:Widget', + parameterTypes: ['double'], + isDeleted: true, + }); + + expect(table.lookupExactAll('src/example.cpp', 'choose')[0]?.isDeleted).toBe(true); + expect(table.lookupCallableByName('choose')[0]?.isDeleted).toBe(true); + expect(model.methods.lookupAllByOwner('class:Widget', 'touch')[0]?.isDeleted).toBe(true); + }); + }); + describe('Property exclusion from callable index', () => { it('Property with ownerId is NOT in callable index', () => { table.add('src/models.ts', 'name', 'prop:name', 'Property', {