Skip to content

Commit fd03f31

Browse files
colbymchenryclaude
andauthored
fix(cpp): resolve calls through singletons/factories/chained getters (colbymchenry#645) (colbymchenry#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>
1 parent a56d9e6 commit fd03f31

14 files changed

Lines changed: 421 additions & 8 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
2929

3030
### Fixes
3131

32+
- C++ method calls made through a singleton, factory, or chained getter now resolve to the correct class. A call like `Foo::instance().bar()`, `WidgetFactory::create().draw()`, `openSession()->run()`, or the same stored in an `auto` local first, used to lose the receiver's type — so when two classes had a same-named method the call silently attached to whichever was indexed first (or didn't resolve at all), corrupting callers, impact, and trace. CodeGraph now infers the receiver's type from what the inner call returns (capturing C++ return types for the first time) and creates the edge only when that class genuinely has the method, so a wrong guess produces no edge instead of a misleading one. Covers singletons and self-returning accessors, factories that return a different type, free-function factories, `make_unique` / `make_shared` / `new` / direct construction, and single-level member chains. Existing C/C++ indexes should be re-indexed (`codegraph index -f`) to benefit. Thanks @stabey. (#645) (C/C++)
3233
- The shared background server no longer logs a scary-looking `[error] … undefined` line on every session start. Attaching to the shared daemon is normal, healthy behavior, but the informational message was being surfaced by MCP hosts (Claude Code and others) as an error; it's now silent by default — set `CODEGRAPH_MCP_LOG_ATTACH=1` to surface it when debugging daemon attach. Thanks @mturac. (#618)
3334
- On Windows, CodeGraph's background processes no longer pile up without bound and saturate CPU over a long session. When the editor or agent that launched CodeGraph exited, its helper process couldn't tell its parent had gone — Windows reports process lineage differently than macOS and Linux — so the helper kept running, the shared background server never saw the client disconnect, and its idle timer never fired to shut it down. CodeGraph now detects parent-process exit directly on Windows, so helpers and the idle background server wind down promptly, the same as they already did on macOS and Linux. (#692, #576, #680)
3435
- The shared background server has two further safeguards against ever lingering: it now drops a client the moment it detects that client's process is gone (even if the disconnect arrived uncleanly — a force-quit or a dropped connection that never closed the socket), and it won't stay running indefinitely with clients attached but no activity. Together these guarantee it always winds down, on every platform. (#692)

__tests__/extraction.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2369,6 +2369,41 @@ end
23692369
});
23702370
});
23712371

2372+
describe('C/C++ return type capture (#645)', () => {
2373+
it('captures the normalized return type of a C++ method/function', () => {
2374+
const code = `
2375+
struct Widget { void draw(); };
2376+
class Factory { public: static Widget create(); };
2377+
Widget Factory::create() { return Widget(); }
2378+
void doNothing() {}
2379+
`;
2380+
const result = extractFromSource('f.cpp', code);
2381+
2382+
const create = result.nodes.find(
2383+
(n) => n.name === 'create' && (n.kind === 'method' || n.kind === 'function')
2384+
);
2385+
expect(create?.returnType).toBe('Widget');
2386+
2387+
// A `void` return records no type, so resolution never tries to resolve a
2388+
// method on it.
2389+
const doNothing = result.nodes.find((n) => n.name === 'doNothing');
2390+
expect(doNothing).toBeDefined();
2391+
expect(doNothing?.returnType).toBeUndefined();
2392+
});
2393+
2394+
it('unwraps a smart-pointer return type to its pointee', () => {
2395+
const code = `
2396+
#include <memory>
2397+
struct Widget {};
2398+
std::unique_ptr<Widget> makeWidget() { return nullptr; }
2399+
`;
2400+
const result = extractFromSource('f.cpp', code);
2401+
2402+
const make = result.nodes.find((n) => n.name === 'makeWidget');
2403+
expect(make?.returnType).toBe('Widget');
2404+
});
2405+
});
2406+
23722407
describe('C/C++ imports', () => {
23732408
it('should extract system include', () => {
23742409
const code = `#include <iostream>`;

__tests__/foundation.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ describe('Database Connection', () => {
242242

243243
const version = db.getSchemaVersion();
244244
expect(version).not.toBeNull();
245-
expect(version?.version).toBe(4);
245+
expect(version?.version).toBe(5);
246246

247247
db.close();
248248
});

__tests__/pr19-improvements.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ describe('Best-Candidate Resolution', () => {
299299
describe('Schema v2 Migration', () => {
300300
it.skipIf(!HAS_SQLITE)('should have correct current schema version', async () => {
301301
const { CURRENT_SCHEMA_VERSION } = await import('../src/db/migrations');
302-
expect(CURRENT_SCHEMA_VERSION).toBe(4);
302+
expect(CURRENT_SCHEMA_VERSION).toBe(5);
303303
});
304304

305305
it.skipIf(!HAS_SQLITE)('should have migration for version 2', async () => {

__tests__/resolution.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1918,4 +1918,112 @@ func main() {
19181918
}
19191919
});
19201920
});
1921+
1922+
describe('C++ chained-call receiver resolution (#645)', () => {
1923+
async function indexCpp(files: Record<string, string>): Promise<void> {
1924+
for (const [name, content] of Object.entries(files)) {
1925+
fs.writeFileSync(path.join(tempDir, name), content);
1926+
}
1927+
cg = await CodeGraph.init(tempDir, { index: true });
1928+
}
1929+
1930+
function callerNamesOf(qualifiedName: string): string[] {
1931+
const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName);
1932+
if (!target) return [];
1933+
const names = cg
1934+
.getIncomingEdges(target.id)
1935+
.filter((e) => e.kind === 'calls')
1936+
.map((e) => cg.getNode(e.source)?.name)
1937+
.filter((n): n is string => !!n);
1938+
return [...new Set(names)].sort();
1939+
}
1940+
1941+
it('resolves singleton chains and auto locals to the right class, never the first-sorted one', async () => {
1942+
// Two classes share writeLog; Logger sorts first so it wins any name-only
1943+
// tie. All three call forms target Metrics.
1944+
await indexCpp({
1945+
'logger.hpp': `#pragma once
1946+
#include <string>
1947+
class Logger { public: static Logger& instance(); void writeLog(const std::string&); };
1948+
class Metrics { public: static Metrics& instance(); void writeLog(const std::string&); };
1949+
`,
1950+
'impl.cpp': `#include "logger.hpp"
1951+
Logger& Logger::instance() { static Logger l; return l; }
1952+
Metrics& Metrics::instance() { static Metrics m; return m; }
1953+
void Logger::writeLog(const std::string&) {}
1954+
void Metrics::writeLog(const std::string&) {}
1955+
`,
1956+
'app.cpp': `#include "logger.hpp"
1957+
void a() { Metrics::instance().writeLog("x"); } // chained singleton
1958+
void b() { auto& m = Metrics::instance(); m.writeLog("x"); } // stored in auto
1959+
void c() { Metrics& m = Metrics::instance(); m.writeLog("x"); } // explicit type
1960+
`,
1961+
});
1962+
1963+
expect(callerNamesOf('Metrics::writeLog')).toEqual(['a', 'b', 'c']);
1964+
expect(callerNamesOf('Logger::writeLog')).toEqual([]);
1965+
});
1966+
1967+
it('resolves factories, free-function factories, and member chains via the inner call return type', async () => {
1968+
await indexCpp({
1969+
'types.hpp': `#pragma once
1970+
#include <memory>
1971+
struct Widget { void draw(); };
1972+
struct Session { void run(); };
1973+
struct View { void render(); };
1974+
class WidgetFactory { public: static Widget create(); };
1975+
class Manager { public: View view(); };
1976+
Session* openSession();
1977+
// Decoy that sorts first and has all three methods — must never win.
1978+
struct Aaa { void draw(); void run(); void render(); };
1979+
`,
1980+
'impl.cpp': `#include "types.hpp"
1981+
void Widget::draw() {}
1982+
void Session::run() {}
1983+
void View::render() {}
1984+
void Aaa::draw() {}
1985+
void Aaa::run() {}
1986+
void Aaa::render() {}
1987+
Widget WidgetFactory::create() { return Widget(); }
1988+
View Manager::view() { return View(); }
1989+
Session* openSession() { return nullptr; }
1990+
`,
1991+
'app.cpp': `#include "types.hpp"
1992+
void factory() { WidgetFactory::create().draw(); } // -> Widget::draw
1993+
void freefunc() { openSession()->run(); } // -> Session::run
1994+
void member() { Manager mgr; mgr.view().render(); } // -> View::render
1995+
void makeUnique() { auto w = std::make_unique<Widget>(); w->draw(); } // -> Widget::draw
1996+
`,
1997+
});
1998+
1999+
expect(callerNamesOf('Widget::draw')).toEqual(['factory', 'makeUnique']);
2000+
expect(callerNamesOf('Session::run')).toEqual(['freefunc']);
2001+
expect(callerNamesOf('View::render')).toEqual(['member']);
2002+
// The first-sorted decoy never captures any of them.
2003+
expect(callerNamesOf('Aaa::draw')).toEqual([]);
2004+
expect(callerNamesOf('Aaa::run')).toEqual([]);
2005+
expect(callerNamesOf('Aaa::render')).toEqual([]);
2006+
});
2007+
2008+
it('creates NO edge when the inferred type lacks the method (silent miss, not a wrong edge)', async () => {
2009+
await indexCpp({
2010+
'types.hpp': `#pragma once
2011+
struct Widget { void draw(); };
2012+
struct Other { void onlyOther(); };
2013+
class WidgetFactory { public: static Widget create(); };
2014+
`,
2015+
'impl.cpp': `#include "types.hpp"
2016+
void Widget::draw() {}
2017+
void Other::onlyOther() {}
2018+
Widget WidgetFactory::create() { return Widget(); }
2019+
`,
2020+
'app.cpp': `#include "types.hpp"
2021+
// Widget has no onlyOther() — must produce NO edge, never a wrong one to Other.
2022+
void wrong() { WidgetFactory::create().onlyOther(); }
2023+
`,
2024+
});
2025+
2026+
expect(callerNamesOf('Other::onlyOther')).toEqual([]);
2027+
});
2028+
});
19212029
});

src/db/migrations.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { SqliteDatabase } from './sqlite-adapter';
99
/**
1010
* Current schema version
1111
*/
12-
export const CURRENT_SCHEMA_VERSION = 4;
12+
export const CURRENT_SCHEMA_VERSION = 5;
1313

1414
/**
1515
* Migration definition
@@ -65,6 +65,16 @@ const migrations: Migration[] = [
6565
`);
6666
},
6767
},
68+
{
69+
version: 5,
70+
description:
71+
'Add nodes.return_type — normalized return/result type for receiver-type inference (C++ singletons/factories, #645)',
72+
up: (db) => {
73+
db.exec(`
74+
ALTER TABLE nodes ADD COLUMN return_type TEXT;
75+
`);
76+
},
77+
},
6878
];
6979

7080
/**

src/db/queries.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ interface NodeRow {
7272
is_abstract: number;
7373
decorators: string | null;
7474
type_parameters: string | null;
75+
return_type: string | null;
7576
updated_at: number;
7677
}
7778

@@ -133,6 +134,7 @@ function rowToNode(row: NodeRow): Node {
133134
isAbstract: row.is_abstract === 1,
134135
decorators: row.decorators ? safeJsonParse(row.decorators, undefined) : undefined,
135136
typeParameters: row.type_parameters ? safeJsonParse(row.type_parameters, undefined) : undefined,
137+
returnType: row.return_type ?? undefined,
136138
updatedAt: row.updated_at,
137139
};
138140
}
@@ -232,13 +234,13 @@ export class QueryBuilder {
232234
start_line, end_line, start_column, end_column,
233235
docstring, signature, visibility,
234236
is_exported, is_async, is_static, is_abstract,
235-
decorators, type_parameters, updated_at
237+
decorators, type_parameters, return_type, updated_at
236238
) VALUES (
237239
@id, @kind, @name, @qualifiedName, @filePath, @language,
238240
@startLine, @endLine, @startColumn, @endColumn,
239241
@docstring, @signature, @visibility,
240242
@isExported, @isAsync, @isStatic, @isAbstract,
241-
@decorators, @typeParameters, @updatedAt
243+
@decorators, @typeParameters, @returnType, @updatedAt
242244
)
243245
`);
244246
}
@@ -281,6 +283,7 @@ export class QueryBuilder {
281283
isAbstract: node.isAbstract ? 1 : 0,
282284
decorators: node.decorators ? JSON.stringify(node.decorators) : null,
283285
typeParameters: node.typeParameters ? JSON.stringify(node.typeParameters) : null,
286+
returnType: node.returnType ?? null,
284287
updatedAt: node.updatedAt ?? Date.now(),
285288
});
286289
}
@@ -321,6 +324,7 @@ export class QueryBuilder {
321324
is_abstract = @isAbstract,
322325
decorators = @decorators,
323326
type_parameters = @typeParameters,
327+
return_type = @returnType,
324328
updated_at = @updatedAt
325329
WHERE id = @id
326330
`);
@@ -355,6 +359,7 @@ export class QueryBuilder {
355359
isAbstract: node.isAbstract ? 1 : 0,
356360
decorators: node.decorators ? JSON.stringify(node.decorators) : null,
357361
typeParameters: node.typeParameters ? JSON.stringify(node.typeParameters) : null,
362+
returnType: node.returnType ?? null,
358363
updatedAt: node.updatedAt ?? Date.now(),
359364
});
360365
}

src/db/schema.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ CREATE TABLE IF NOT EXISTS nodes (
3737
is_abstract INTEGER DEFAULT 0,
3838
decorators TEXT, -- JSON array
3939
type_parameters TEXT, -- JSON array
40+
return_type TEXT, -- normalized return/result type name (e.g. C++ method return, for receiver-type inference)
4041
updated_at INTEGER NOT NULL
4142
);
4243

src/extraction/extraction-version.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,4 @@
2121
* turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty
2222
* in the product is load-bearing").
2323
*/
24-
export const EXTRACTION_VERSION = 2;
24+
export const EXTRACTION_VERSION = 3;

src/extraction/languages/c-cpp.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,56 @@ function extractCppReceiverType(node: SyntaxNode, source: string): string | unde
4545
return parts.length > 1 ? parts.slice(0, -1).join('::') : undefined;
4646
}
4747

48+
/**
49+
* Built-in / non-class return types that can never be a method receiver. We
50+
* store no `returnType` for these so resolution never tries to resolve a method
51+
* on `void` / `int` / etc.
52+
*/
53+
const CPP_NON_CLASS_RETURN = new Set([
54+
'void', 'bool', 'char', 'short', 'int', 'long', 'float', 'double', 'unsigned',
55+
'signed', 'size_t', 'ssize_t', 'auto', 'wchar_t', 'char8_t', 'char16_t',
56+
'char32_t', 'int8_t', 'int16_t', 'int32_t', 'int64_t', 'uint8_t', 'uint16_t',
57+
'uint32_t', 'uint64_t', 'intptr_t', 'uintptr_t', 'nullptr_t',
58+
]);
59+
60+
/**
61+
* Normalize a C++ return type to the bare class name a method could be called
62+
* on. Unwraps smart-pointer / optional wrappers to their element type
63+
* (`std::unique_ptr<Widget>` → `Widget`) so a factory's `->method()` resolves on
64+
* the pointee. Strips cv-qualifiers, `&`/`*`, namespace qualifiers, and other
65+
* template args. Returns undefined for primitives / void / `auto` / empty.
66+
*/
67+
export function normalizeCppReturnType(raw: string): string | undefined {
68+
let t = raw.trim();
69+
if (!t) return undefined;
70+
// Unwrap smart pointers / optional to their pointee (the thing you call `->` on).
71+
const wrapper = t.match(/\b(?:std\s*::\s*)?(?:unique_ptr|shared_ptr|weak_ptr|optional)\s*<\s*([^,>]+?)\s*>/);
72+
if (wrapper && wrapper[1]) t = wrapper[1];
73+
t = t
74+
.replace(/\b(?:const|volatile|typename|struct|class|enum)\b/g, ' ')
75+
.replace(/<[^>]*>/g, ' ')
76+
.replace(/[*&]+/g, ' ')
77+
.replace(/\s+/g, ' ')
78+
.trim();
79+
if (!t) return undefined;
80+
const last = t.split('::').filter(Boolean).pop();
81+
if (!last) return undefined;
82+
if (CPP_NON_CLASS_RETURN.has(last)) return undefined;
83+
if (!/^[A-Za-z_]\w*$/.test(last)) return undefined;
84+
return last;
85+
}
86+
87+
/**
88+
* A function/method's return type lives in the `function_definition`'s `type`
89+
* field (`Metrics& Metrics::instance()` → `Metrics`). Constructors, destructors,
90+
* and conversion operators have no `type` field → undefined.
91+
*/
92+
function extractCppReturnType(node: SyntaxNode, source: string): string | undefined {
93+
const typeNode = getChildByField(node, 'type');
94+
if (!typeNode) return undefined;
95+
return normalizeCppReturnType(getNodeText(typeNode, source));
96+
}
97+
4898
export const cExtractor: LanguageExtractor = {
4999
functionTypes: ['function_definition'],
50100
classTypes: [],
@@ -60,6 +110,7 @@ export const cExtractor: LanguageExtractor = {
60110
nameField: 'declarator',
61111
bodyField: 'body',
62112
paramsField: 'parameters',
113+
getReturnType: extractCppReturnType,
63114
resolveTypeAliasKind: (node, _source) => {
64115
// C typedef: `typedef enum { ... } name;` or `typedef struct { ... } name;`
65116
// The inner enum_specifier/struct_specifier is anonymous, but we want the typedef name
@@ -107,6 +158,7 @@ export const cppExtractor: LanguageExtractor = {
107158
paramsField: 'parameters',
108159
resolveName: extractCppQualifiedMethodName,
109160
getReceiverType: extractCppReceiverType,
161+
getReturnType: extractCppReturnType,
110162
getVisibility: (node) => {
111163
// Check for access specifier in parent
112164
const parent = node.parent;

0 commit comments

Comments
 (0)