fix(cpp): suppress deleted overload winners (#2094)

* fix(cpp): suppress deleted overload winners

* test(cpp): update scope capture fingerprint

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
azizur100389 2026-06-10 18:41:30 +01:00 committed by GitHub
parent 31a2b19416
commit e26002c37a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 744 additions and 49 deletions

View file

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

View file

@ -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": {

View file

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

View file

@ -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.
*/

View file

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

View file

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

View file

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

View file

@ -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. */

View file

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

View file

@ -104,6 +104,7 @@ export const mergeChunkResults = (
templateArguments: sym.templateArguments,
ownerId: sym.ownerId,
qualifiedName: sym.qualifiedName,
isDeleted: sym.isDeleted,
});
}
if (exportedTypeMap) {

View file

@ -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<string> = new Set<string>([
'@declaration.return-type',
'@declaration.template-constraints',
'@declaration.is-explicit',
'@declaration.is-deleted',
]);
/**

View file

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

View file

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

View file

@ -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'`.

View file

@ -5,6 +5,7 @@ export type ResolutionSuppressionReason =
| 'conversion-rank-tied'
| 'inline-ns-ambiguous'
| 'member-lookup-ambiguous'
| 'selected-callable-deleted'
| 'overload-ambiguous'
| 'overload-ambiguous-normalization';

View file

@ -213,6 +213,7 @@ export function buildMethodProps(info: MethodInfo): Record<string, unknown> {
...(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 } : {}),
};
}

View file

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

View file

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

View file

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

View file

@ -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', () => {

View file

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

View file

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

View file

@ -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', {