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:
co-authored by
Claude Opus 4.8
parent
a56d9e6941
commit
fd03f31b2c
+11
-1
@@ -9,7 +9,7 @@ import { SqliteDatabase } from './sqlite-adapter';
|
||||
/**
|
||||
* Current schema version
|
||||
*/
|
||||
export const CURRENT_SCHEMA_VERSION = 4;
|
||||
export const CURRENT_SCHEMA_VERSION = 5;
|
||||
|
||||
/**
|
||||
* Migration definition
|
||||
@@ -65,6 +65,16 @@ const migrations: Migration[] = [
|
||||
`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 5,
|
||||
description:
|
||||
'Add nodes.return_type — normalized return/result type for receiver-type inference (C++ singletons/factories, #645)',
|
||||
up: (db) => {
|
||||
db.exec(`
|
||||
ALTER TABLE nodes ADD COLUMN return_type TEXT;
|
||||
`);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+7
-2
@@ -72,6 +72,7 @@ interface NodeRow {
|
||||
is_abstract: number;
|
||||
decorators: string | null;
|
||||
type_parameters: string | null;
|
||||
return_type: string | null;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
@@ -133,6 +134,7 @@ function rowToNode(row: NodeRow): Node {
|
||||
isAbstract: row.is_abstract === 1,
|
||||
decorators: row.decorators ? safeJsonParse(row.decorators, undefined) : undefined,
|
||||
typeParameters: row.type_parameters ? safeJsonParse(row.type_parameters, undefined) : undefined,
|
||||
returnType: row.return_type ?? undefined,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
@@ -232,13 +234,13 @@ export class QueryBuilder {
|
||||
start_line, end_line, start_column, end_column,
|
||||
docstring, signature, visibility,
|
||||
is_exported, is_async, is_static, is_abstract,
|
||||
decorators, type_parameters, updated_at
|
||||
decorators, type_parameters, return_type, updated_at
|
||||
) VALUES (
|
||||
@id, @kind, @name, @qualifiedName, @filePath, @language,
|
||||
@startLine, @endLine, @startColumn, @endColumn,
|
||||
@docstring, @signature, @visibility,
|
||||
@isExported, @isAsync, @isStatic, @isAbstract,
|
||||
@decorators, @typeParameters, @updatedAt
|
||||
@decorators, @typeParameters, @returnType, @updatedAt
|
||||
)
|
||||
`);
|
||||
}
|
||||
@@ -281,6 +283,7 @@ export class QueryBuilder {
|
||||
isAbstract: node.isAbstract ? 1 : 0,
|
||||
decorators: node.decorators ? JSON.stringify(node.decorators) : null,
|
||||
typeParameters: node.typeParameters ? JSON.stringify(node.typeParameters) : null,
|
||||
returnType: node.returnType ?? null,
|
||||
updatedAt: node.updatedAt ?? Date.now(),
|
||||
});
|
||||
}
|
||||
@@ -321,6 +324,7 @@ export class QueryBuilder {
|
||||
is_abstract = @isAbstract,
|
||||
decorators = @decorators,
|
||||
type_parameters = @typeParameters,
|
||||
return_type = @returnType,
|
||||
updated_at = @updatedAt
|
||||
WHERE id = @id
|
||||
`);
|
||||
@@ -355,6 +359,7 @@ export class QueryBuilder {
|
||||
isAbstract: node.isAbstract ? 1 : 0,
|
||||
decorators: node.decorators ? JSON.stringify(node.decorators) : null,
|
||||
typeParameters: node.typeParameters ? JSON.stringify(node.typeParameters) : null,
|
||||
returnType: node.returnType ?? null,
|
||||
updatedAt: node.updatedAt ?? Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ CREATE TABLE IF NOT EXISTS nodes (
|
||||
is_abstract INTEGER DEFAULT 0,
|
||||
decorators TEXT, -- JSON array
|
||||
type_parameters TEXT, -- JSON array
|
||||
return_type TEXT, -- normalized return/result type name (e.g. C++ method return, for receiver-type inference)
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -351,6 +351,7 @@ function inferCppReceiverType(
|
||||
receiverName: string,
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext,
|
||||
depth = 0,
|
||||
): string | null {
|
||||
const source = context.readFile(ref.filePath);
|
||||
if (!source) return null;
|
||||
@@ -368,7 +369,15 @@ function inferCppReceiverType(
|
||||
const declaratorMatch = line.match(declaratorRegex);
|
||||
if (declaratorMatch) {
|
||||
const normalized = normalizeCppTypeName(declaratorMatch[1] ?? '');
|
||||
if (normalized) return normalized;
|
||||
if (normalized === 'auto') {
|
||||
// `auto x = Foo::instance();` — the declared type is deduced; recover it
|
||||
// from the initializer (call return type / construction) (#645).
|
||||
const initType = inferCppAutoInitializerType(line, receiverName, ref, context, depth);
|
||||
if (initType) return initType;
|
||||
// No usable initializer on this line — keep scanning earlier ones.
|
||||
} else if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,13 +397,158 @@ function inferCppReceiverType(
|
||||
const declaratorMatch = line.match(declaratorRegex);
|
||||
if (!declaratorMatch) continue;
|
||||
const normalized = normalizeCppTypeName(declaratorMatch[1] ?? '');
|
||||
if (normalized) return normalized;
|
||||
if (normalized && normalized !== 'auto') return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Last `::`-separated segment of a (possibly namespace-qualified) C++ name.
|
||||
*/
|
||||
function cppLastSegment(name: string): string {
|
||||
const parts = name.split('::').filter(Boolean);
|
||||
return parts[parts.length - 1] ?? name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type captured at extraction for `Class::method` (or a free function),
|
||||
* read off the indexed node's `returnType` (#645). Null when not indexed or no
|
||||
* return type was recorded (e.g. a `void`/primitive return).
|
||||
*/
|
||||
function lookupCppReturnType(
|
||||
callee: string,
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext,
|
||||
): string | null {
|
||||
let method = callee;
|
||||
let cls: string | null = null;
|
||||
if (callee.includes('::')) {
|
||||
const parts = callee.split('::').filter(Boolean);
|
||||
method = parts[parts.length - 1] ?? callee;
|
||||
cls = parts.slice(0, -1).join('::');
|
||||
}
|
||||
const candidates = context.getNodesByName(method).filter(
|
||||
(n) =>
|
||||
(n.kind === 'method' || n.kind === 'function') &&
|
||||
n.language === ref.language &&
|
||||
!!n.returnType,
|
||||
);
|
||||
if (cls) {
|
||||
const want = `${cls}::${method}`;
|
||||
// The call site may name the class with MORE namespace qualification than
|
||||
// the stored node (`details::registry::instance` at the call vs
|
||||
// `registry::instance` on the node — the receiver type only carries the
|
||||
// immediate class), or LESS. Accept an exact match or either being a
|
||||
// namespace-suffix of the other; the shared `::<class>::<method>` tail keeps
|
||||
// it specific.
|
||||
const m = candidates.find(
|
||||
(n) =>
|
||||
n.qualifiedName === want ||
|
||||
n.qualifiedName.endsWith(`::${want}`) ||
|
||||
want.endsWith(`::${n.qualifiedName}`),
|
||||
);
|
||||
return m?.returnType ?? null;
|
||||
}
|
||||
return candidates.find((n) => n.kind === 'function')?.returnType ?? null;
|
||||
}
|
||||
|
||||
/** Does the graph contain a class/struct named `name`'s last segment? */
|
||||
function cppClassExists(name: string, ref: UnresolvedRef, context: ResolutionContext): boolean {
|
||||
const last = cppLastSegment(name);
|
||||
return context
|
||||
.getNodesByName(last)
|
||||
.some((n) => (n.kind === 'class' || n.kind === 'struct') && n.language === ref.language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer the class produced by a C++ call/construction expression, using return
|
||||
* types captured at extraction (#645). Handles, in order:
|
||||
* - `make_unique<T>()` / `make_shared<T>()` → T
|
||||
* - single-level member call `recv.method()` → recv's type, then method's return
|
||||
* - `Class::method()` / free `func()` → the callee's recorded return type
|
||||
* - direct construction `Type()` / `ns::Type()` → Type
|
||||
* Returns null when undeterminable. Callers MUST still validate the outer method
|
||||
* exists on the result before creating an edge, so a wrong guess stays silent.
|
||||
*/
|
||||
function resolveCppCallResultType(
|
||||
inner: string,
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext,
|
||||
depth = 0,
|
||||
): string | null {
|
||||
if (depth > 3) return null; // guard against pathological mutual recursion
|
||||
const expr = inner.trim();
|
||||
|
||||
const make = expr.match(/(?:^|::)(?:make_unique|make_shared)\s*<\s*([A-Za-z_]\w*)/);
|
||||
if (make) return make[1] ?? null;
|
||||
|
||||
// Single-level member call `recv.method` (the `manager.view().render()` shape).
|
||||
const dotIdx = expr.lastIndexOf('.');
|
||||
if (dotIdx > 0) {
|
||||
const recv = expr.slice(0, dotIdx);
|
||||
const method = expr.slice(dotIdx + 1);
|
||||
if (recv.includes('.') || recv.includes('(') || recv.includes('::')) return null; // single level only
|
||||
const recvType = inferCppReceiverType(recv, ref, context, depth + 1);
|
||||
if (!recvType) return null;
|
||||
return lookupCppReturnType(`${recvType}::${method}`, ref, context);
|
||||
}
|
||||
|
||||
const ret = lookupCppReturnType(expr, ref, context);
|
||||
if (ret) return ret;
|
||||
|
||||
// Direct construction — the callee itself names a class/struct.
|
||||
if (cppClassExists(expr, ref, context)) return cppLastSegment(expr);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the type of an `auto`-declared local from its initializer on the
|
||||
* declaration line — `auto x = Foo::instance();`, `auto w = make_unique<W>();`,
|
||||
* `auto p = new W();`, `auto w = Widget();` (#645).
|
||||
*/
|
||||
function inferCppAutoInitializerType(
|
||||
line: string,
|
||||
receiverName: string,
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext,
|
||||
depth: number,
|
||||
): string | null {
|
||||
const escaped = receiverName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const m = line.match(new RegExp(`\\b${escaped}\\b\\s*=\\s*([^;]+)`));
|
||||
if (!m || !m[1]) return null;
|
||||
const init = m[1].trim();
|
||||
|
||||
const neu = init.match(/^new\s+([A-Za-z_][\w:]*)/);
|
||||
if (neu && neu[1]) return cppLastSegment(neu[1]);
|
||||
|
||||
// A call or construction: `Foo(...)`, `A::b(...)`, `make_unique<T>(...)`.
|
||||
const call = init.match(/^([A-Za-z_][\w:]*(?:\s*<[^>;]*>)?)\s*\(/);
|
||||
if (call && call[1]) return resolveCppCallResultType(call[1].replace(/\s+/g, ''), ref, context, depth + 1);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a C++ chained call whose receiver is itself a call — encoded by the
|
||||
* extractor as `<innerCallee>().<method>` (#645). The receiver's type is what
|
||||
* the inner call returns; the outer method is then resolved and VALIDATED on it
|
||||
* (resolveMethodOnType requires `cls::method` to exist), so a wrong inference
|
||||
* produces no edge rather than a wrong one.
|
||||
*/
|
||||
export function matchCppCallChain(
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext,
|
||||
): ResolvedRef | null {
|
||||
const m = ref.referenceName.match(/^(.+)\(\)\.(\w+)$/);
|
||||
if (!m || !m[1] || !m[2]) return null;
|
||||
const cls = resolveCppCallResultType(m[1], ref, context);
|
||||
if (!cls) return null;
|
||||
return resolveMethodOnType(cls, m[2], ref, context, 0.85, 'instance-method');
|
||||
}
|
||||
|
||||
/**
|
||||
* Java/Kotlin: infer a receiver's declared type by walking field declarations
|
||||
* in the class enclosing the call site. The field's `signature` is already in
|
||||
@@ -809,6 +963,14 @@ export function matchReference(
|
||||
result = matchByQualifiedName(ref, context);
|
||||
if (result) return result;
|
||||
|
||||
// 1b. C++ chained call whose receiver is another call — `Foo::instance().bar()`
|
||||
// encoded as `Foo::instance().bar` by the extractor (#645). Resolve the
|
||||
// receiver's type from what the inner call returns, then the method on it.
|
||||
if (ref.language === 'cpp' || ref.language === 'c') {
|
||||
result = matchCppCallChain(ref, context);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
// 2. Method call pattern
|
||||
result = matchMethodCall(ref, context);
|
||||
if (result) return result;
|
||||
|
||||
@@ -163,6 +163,15 @@ export interface Node {
|
||||
/** Generic type parameters */
|
||||
typeParameters?: string[];
|
||||
|
||||
/**
|
||||
* Normalized return/result type name for a function/method (the bare class
|
||||
* name, smart-pointer pointee unwrapped). Captured for C/C++ so resolution
|
||||
* can infer a chained receiver's type from what the inner call returns —
|
||||
* `Foo::instance().bar()` resolves `bar` on `Foo` (issue #645). Undefined for
|
||||
* languages/symbols where it isn't captured.
|
||||
*/
|
||||
returnType?: string;
|
||||
|
||||
/** When the node was last updated */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user