fix(cpp): resolve calls through singletons/factories/chained getters (#645) (#742)

A C++ method call whose receiver is another call's result — `Foo::instance().bar()`,
`WidgetFactory::create().draw()`, `openSession()->run()`, or the same stored in an
`auto` local first — lost the receiver's type during extraction. The callee degraded
to a bare method name, so when two classes shared a method name the call silently
resolved to whichever was indexed first (or not at all), corrupting callers / impact /
trace with a plausible-but-wrong edge.

Three parts:
- Capture C++ return types (new nodes.return_type column, schema v5): the
  function_definition's `type` field, normalized — smart-pointer pointee unwrapped,
  void/primitives dropped.
- Preserve the inner-call receiver in extraction: a C/C++ field_expression whose
  receiver is itself a call is encoded `inner().method` instead of dropping to the
  bare name. Other languages keep the existing behavior.
- New resolution strategy (matchCppCallChain): infer the receiver's class from the
  inner call's return type, then resolve AND validate the method on it. Handles
  singletons/accessors, factories returning a different type, free-function
  factories, make_unique/make_shared/new/direct construction, single-level member
  chains, and namespace-qualified inner calls. A wrong inference yields no edge,
  never a wrong one.

EXTRACTION_VERSION 2->3 (re-index to populate return types).

Validated on the issue repro + spdlog: node count stable (no explosion),
deterministic, and ~100 pre-existing wrong `.size()`-style edges removed.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-08 20:18:17 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent a56d9e6941
commit fd03f31b2c
14 changed files with 421 additions and 8 deletions
+1 -1
View File
@@ -21,4 +21,4 @@
* turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty
* in the product is load-bearing").
*/
export const EXTRACTION_VERSION = 2;
export const EXTRACTION_VERSION = 3;
+52
View File
@@ -45,6 +45,56 @@ function extractCppReceiverType(node: SyntaxNode, source: string): string | unde
return parts.length > 1 ? parts.slice(0, -1).join('::') : undefined;
}
/**
* Built-in / non-class return types that can never be a method receiver. We
* store no `returnType` for these so resolution never tries to resolve a method
* on `void` / `int` / etc.
*/
const CPP_NON_CLASS_RETURN = new Set([
'void', 'bool', 'char', 'short', 'int', 'long', 'float', 'double', 'unsigned',
'signed', 'size_t', 'ssize_t', 'auto', 'wchar_t', 'char8_t', 'char16_t',
'char32_t', 'int8_t', 'int16_t', 'int32_t', 'int64_t', 'uint8_t', 'uint16_t',
'uint32_t', 'uint64_t', 'intptr_t', 'uintptr_t', 'nullptr_t',
]);
/**
* Normalize a C++ return type to the bare class name a method could be called
* on. Unwraps smart-pointer / optional wrappers to their element type
* (`std::unique_ptr<Widget>` → `Widget`) so a factory's `->method()` resolves on
* the pointee. Strips cv-qualifiers, `&`/`*`, namespace qualifiers, and other
* template args. Returns undefined for primitives / void / `auto` / empty.
*/
export function normalizeCppReturnType(raw: string): string | undefined {
let t = raw.trim();
if (!t) return undefined;
// Unwrap smart pointers / optional to their pointee (the thing you call `->` on).
const wrapper = t.match(/\b(?:std\s*::\s*)?(?:unique_ptr|shared_ptr|weak_ptr|optional)\s*<\s*([^,>]+?)\s*>/);
if (wrapper && wrapper[1]) t = wrapper[1];
t = t
.replace(/\b(?:const|volatile|typename|struct|class|enum)\b/g, ' ')
.replace(/<[^>]*>/g, ' ')
.replace(/[*&]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!t) return undefined;
const last = t.split('::').filter(Boolean).pop();
if (!last) return undefined;
if (CPP_NON_CLASS_RETURN.has(last)) return undefined;
if (!/^[A-Za-z_]\w*$/.test(last)) return undefined;
return last;
}
/**
* A function/method's return type lives in the `function_definition`'s `type`
* field (`Metrics& Metrics::instance()` → `Metrics`). Constructors, destructors,
* and conversion operators have no `type` field → undefined.
*/
function extractCppReturnType(node: SyntaxNode, source: string): string | undefined {
const typeNode = getChildByField(node, 'type');
if (!typeNode) return undefined;
return normalizeCppReturnType(getNodeText(typeNode, source));
}
export const cExtractor: LanguageExtractor = {
functionTypes: ['function_definition'],
classTypes: [],
@@ -60,6 +110,7 @@ export const cExtractor: LanguageExtractor = {
nameField: 'declarator',
bodyField: 'body',
paramsField: 'parameters',
getReturnType: extractCppReturnType,
resolveTypeAliasKind: (node, _source) => {
// C typedef: `typedef enum { ... } name;` or `typedef struct { ... } name;`
// The inner enum_specifier/struct_specifier is anonymous, but we want the typedef name
@@ -107,6 +158,7 @@ export const cppExtractor: LanguageExtractor = {
paramsField: 'parameters',
resolveName: extractCppQualifiedMethodName,
getReceiverType: extractCppReceiverType,
getReturnType: extractCppReturnType,
getVisibility: (node) => {
// Check for access specifier in parent
const parent = node.parent;
+9
View File
@@ -205,6 +205,15 @@ export interface LanguageExtractor {
*/
getReceiverType?: (node: SyntaxNode, source: string) => string | undefined;
/**
* Extract a function/method's normalized return type name (bare class name,
* smart-pointer pointee unwrapped), stored on the node as `returnType`. Used
* by C/C++ so resolution can infer a chained receiver's type from what the
* inner call returns (`Foo::instance().bar()` → resolve `bar` on `Foo`,
* issue #645). Return undefined for primitives / void / constructors.
*/
getReturnType?: (node: SyntaxNode, source: string) => string | undefined;
/**
* Resolve the actual node kind for a type alias declaration.
* Used by Go where `type_spec` is the named declaration wrapper for structs/interfaces:
+21
View File
@@ -811,6 +811,7 @@ export class TreeSitterExtractor {
const isExported = this.extractor.isExported?.(node, this.source);
const isAsync = this.extractor.isAsync?.(node);
const isStatic = this.extractor.isStatic?.(node);
const returnType = this.extractor.getReturnType?.(node, this.source);
const funcNode = this.createNode('function', name, node, {
docstring,
@@ -819,6 +820,7 @@ export class TreeSitterExtractor {
isExported,
isAsync,
isStatic,
returnType,
});
if (!funcNode) return;
@@ -930,12 +932,14 @@ export class TreeSitterExtractor {
const visibility = this.extractor.getVisibility?.(node);
const isAsync = this.extractor.isAsync?.(node);
const isStatic = this.extractor.isStatic?.(node);
const returnType = this.extractor.getReturnType?.(node, this.source);
const extraProps: Partial<Node> = {
docstring,
signature,
visibility,
isAsync,
isStatic,
returnType,
};
if (receiverType) {
extraProps.qualifiedName = `${receiverType}::${name}`;
@@ -2457,6 +2461,23 @@ export class TreeSitterExtractor {
} else {
calleeName = methodName;
}
} else if (
(this.language === 'cpp' || this.language === 'c') &&
receiver &&
receiver.type === 'call_expression'
) {
// C/C++ receiver that is itself a call — `Foo::instance().bar()`,
// `openSession()->run()`, `mgr.view().render()`. Keep the inner
// call so resolution can infer bar()'s class from what the inner
// call RETURNS (#645). Encode as `<innerCallee>().<method>`; the
// `().` marker never appears in an ordinary ref, so the C++
// resolver can detect and split it. Other languages keep the
// bare-name behavior (dropping the receiver) below.
const innerFn = getChildByField(receiver, 'function');
const innerCallee = innerFn
? getNodeText(innerFn, this.source).replace(/->/g, '.').replace(/\s+/g, '')
: '';
calleeName = innerCallee ? `${innerCallee}().${methodName}` : methodName;
} else {
calleeName = methodName;
}