From 079d6358ff37b68de4df03a4b674c89b62494bb4 Mon Sep 17 00:00:00 2001 From: stabey <36232531+stabey@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:06:00 +0800 Subject: [PATCH 1/6] fix(cpp): resolve singleton/factory/chained calls via return types (#645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C++ calls whose receiver was another call's result — `Foo::instance().bar()`, factories, free-function factories, and single-level member chains — lost the receiver's type during extraction and degraded to a bare method name. They then mis-resolved to the first-indexed same-named method, or went unresolved. - Capture C++ return types into method/function signatures so resolution has a type to recover (`extractCppSignature`, emitted as `(params) -> ReturnType`). - Encode chained-call receivers at extraction (`Recv().method`, `obj.getThing().method`) instead of dropping them to a bare name. - Resolve chained/auto calls by the callee's return type, with a self-returning-accessor-name fallback; infer `auto` locals from new/make_unique/make_shared/cast/construction/accessor/return-type; and single-level member chains via the chained method's return type. Every inferred type is validated against the graph before an edge is created, so a wrong guess falls through silently rather than producing a wrong edge. Deliberately out of scope: deep chains, multi-level member access, and overload/alias/template-correct selection (need a real type environment). Adds end-to-end + unit coverage; node count stable across re-index; full suite green (1110 passed). Co-Authored-By: Claude Opus 4.8 --- __tests__/frameworks-integration.test.ts | 218 +++++++++++++++++++ __tests__/resolution.test.ts | 65 +++++- src/extraction/languages/c-cpp.ts | 41 ++++ src/extraction/tree-sitter-types.ts | 31 +++ src/extraction/tree-sitter.ts | 60 ++++++ src/resolution/name-matcher.ts | 255 ++++++++++++++++++++++- 6 files changed, 668 insertions(+), 2 deletions(-) diff --git a/__tests__/frameworks-integration.test.ts b/__tests__/frameworks-integration.test.ts index 344a0f6c9..45d727807 100644 --- a/__tests__/frameworks-integration.test.ts +++ b/__tests__/frameworks-integration.test.ts @@ -266,6 +266,224 @@ describe('C++ end-to-end — virtual override synthesis', () => { } }); + it('resolves singleton/self-returning accessor calls to the right class when method names collide', async () => { + // The frontier this covers: a call whose receiver is itself an accessor + // call. `Worker::instance().run()` parses as a field_expression whose + // receiver is the `Worker::instance()` call_expression — the old extractor + // dropped that receiver and emitted a bare `run`, which then tie-broke to + // whichever same-named method indexed first (here Decoy::run). The fix + // emits a qualified `Worker::run`; the `auto` forms recover the type from + // the initializer (singleton accessor / new / make_unique / make_shared). + // Decoy::run exists only to make the bare-name fallback wrong, so a green + // test proves the receiver type — not indexing order — drove resolution. + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cpp-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'decoy.hpp'), // sorts before worker.hpp → indexed first + 'class Decoy { public: void run(); };\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'worker.hpp'), + 'class Worker {\n' + + ' public:\n' + + ' static Worker& instance();\n' + + ' static Worker* getInstance();\n' + + ' void run();\n' + + '};\n' + ); + // Out-of-line definitions so each method becomes a real node with a + // `Class::method` qualified name (a bodyless in-class declaration is not + // a function_definition and produces no method node). + fs.writeFileSync( + path.join(tmpDir, 'defs.cpp'), + '#include "worker.hpp"\n' + + '#include "decoy.hpp"\n' + + 'Worker& Worker::instance() { static Worker w; return w; }\n' + + 'Worker* Worker::getInstance() { return &instance(); }\n' + + 'void Worker::run() {}\n' + + 'void Decoy::run() {}\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'app.cpp'), + '#include "worker.hpp"\n' + + '#include "decoy.hpp"\n' + + '#include \n' + + 'void callDirectRef() { Worker::instance().run(); }\n' + + 'void callDirectPtr() { Worker::getInstance()->run(); }\n' + + 'void callAutoRef() { auto& w = Worker::instance(); w.run(); }\n' + + 'void callNew() { auto w = new Worker(); w->run(); }\n' + + 'void callMakeUnique() { auto w = std::make_unique(); w->run(); }\n' + + 'void callMakeShared() { auto w = std::make_shared(); w->run(); }\n' + ); + + cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const workerRun = cg + .getNodesByKind('method') + .find((n) => n.qualifiedName === 'Worker::run'); + const decoyRun = cg + .getNodesByKind('method') + .find((n) => n.qualifiedName === 'Decoy::run'); + expect(workerRun, 'Worker::run node').toBeDefined(); + expect(decoyRun, 'Decoy::run node').toBeDefined(); + + const workerCallers = cg.getCallers(workerRun!.id).map((c) => c.node.qualifiedName); + for (const fn of ['callDirectRef', 'callDirectPtr', 'callAutoRef', 'callNew', 'callMakeUnique', 'callMakeShared']) { + expect(workerCallers, `${fn} should call Worker::run`).toContain(fn); + } + + // Decoy::run is never called — none of the singleton calls may misroute. + const decoyCallers = cg.getCallers(decoyRun!.id).map((c) => c.node.qualifiedName); + expect(decoyCallers).not.toContain('callDirectRef'); + expect(decoyCallers).not.toContain('callAutoRef'); + expect(decoyCallers).not.toContain('callNew'); + } finally { + cg?.close(); + } + }); + + it('resolves chained/auto calls through the callee return type — factory returning another class, oddly-named accessor, free-function factory', async () => { + // The cases a name-based heuristic fundamentally can't reach, now driven by + // the captured return type of the receiver call: + // - WidgetFactory::create() returns *Widget* (a different class) + // - Engine::acquire() is a singleton accessor NOT named instance/getInstance + // - openSession() is a free function returning Session* + // Decoy shares every method name and sorts first, so a name-only tie would + // land there — a green test proves the return type drove resolution. + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cpp-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'decoy.hpp'), // sorts first → wins any name-only tie + 'class Decoy { public: void draw(); void run(); void log(); };\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'types.hpp'), + 'class Widget { public: void draw(); };\n' + + 'class WidgetFactory { public: static Widget create(); };\n' + + 'class Engine { public: static Engine& acquire(); void run(); };\n' + + 'class Session { public: void log(); };\n' + + 'Session* openSession();\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'defs.cpp'), + '#include "types.hpp"\n' + + '#include "decoy.hpp"\n' + + 'void Decoy::draw() {}\n' + + 'void Decoy::run() {}\n' + + 'void Decoy::log() {}\n' + + 'void Widget::draw() {}\n' + + 'Widget WidgetFactory::create() { return Widget(); }\n' + + 'Engine& Engine::acquire() { static Engine e; return e; }\n' + + 'void Engine::run() {}\n' + + 'void Session::log() {}\n' + + 'Session* openSession() { return nullptr; }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'app.cpp'), + '#include "types.hpp"\n' + + 'void useFactoryDirect() { WidgetFactory::create().draw(); }\n' + + 'void useFactoryAuto() { auto w = WidgetFactory::create(); w.draw(); }\n' + + 'void useOddAccessorDirect() { Engine::acquire().run(); }\n' + + 'void useOddAccessorAuto() { auto& e = Engine::acquire(); e.run(); }\n' + + 'void useFreeFactoryDirect() { openSession()->log(); }\n' + + 'void useFreeFactoryAuto() { auto s = openSession(); s->log(); }\n' + ); + + cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const callersOf = (qn: string) => { + const node = cg!.getNodesByKind('method').find((n) => n.qualifiedName === qn); + expect(node, `${qn} node`).toBeDefined(); + return cg!.getCallers(node!.id).map((c) => c.node.qualifiedName); + }; + + expect(callersOf('Widget::draw')).toEqual( + expect.arrayContaining(['useFactoryDirect', 'useFactoryAuto']) + ); + expect(callersOf('Engine::run')).toEqual( + expect.arrayContaining(['useOddAccessorDirect', 'useOddAccessorAuto']) + ); + expect(callersOf('Session::log')).toEqual( + expect.arrayContaining(['useFreeFactoryDirect', 'useFreeFactoryAuto']) + ); + + // No call may misroute to the same-named Decoy methods — assert each + // use* function individually so a single stray edge fails the test. + const useFns = [ + 'useFactoryDirect', 'useFactoryAuto', 'useOddAccessorDirect', + 'useOddAccessorAuto', 'useFreeFactoryDirect', 'useFreeFactoryAuto', + ]; + for (const decoyMethod of ['Decoy::draw', 'Decoy::run', 'Decoy::log']) { + const decoyCallers = callersOf(decoyMethod); + for (const fn of useFns) { + expect(decoyCallers, `${fn} must not misroute to ${decoyMethod}`).not.toContain(fn); + } + } + } finally { + cg?.close(); + } + }); + + it('resolves a single-level member chain through the chained method return type', async () => { + // `m.view().render()` / `auto p = m.view(); p.render()` — resolve m's type, + // then the return type of view() on it, then render() on that. Decoy::render + // sorts first and would win a bare-name tie, so landing on Panel::render + // proves the chain was followed by type, not name. + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cpp-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'decoy.hpp'), + 'class Decoy { public: void render(); };\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'types.hpp'), + 'class Panel { public: void render(); };\n' + + 'class Manager { public: Panel view(); Panel* viewPtr(); };\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'defs.cpp'), + '#include "types.hpp"\n' + + '#include "decoy.hpp"\n' + + 'void Decoy::render() {}\n' + + 'void Panel::render() {}\n' + + 'Panel Manager::view() { return Panel(); }\n' + + 'Panel* Manager::viewPtr() { return nullptr; }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'app.cpp'), + '#include "types.hpp"\n' + + 'void inlineLocal() { Manager m; m.view().render(); }\n' + + 'void autoLocal() { Manager m; auto p = m.view(); p.render(); }\n' + + 'void autoLocalPtr() { Manager m; auto p = m.viewPtr(); p->render(); }\n' + ); + + cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const panelRender = cg.getNodesByKind('method').find((n) => n.qualifiedName === 'Panel::render'); + const decoyRender = cg.getNodesByKind('method').find((n) => n.qualifiedName === 'Decoy::render'); + expect(panelRender, 'Panel::render node').toBeDefined(); + expect(decoyRender, 'Decoy::render node').toBeDefined(); + + const panelCallers = cg.getCallers(panelRender!.id).map((c) => c.node.qualifiedName); + expect(panelCallers).toEqual( + expect.arrayContaining(['inlineLocal', 'autoLocal', 'autoLocalPtr']) + ); + + const decoyCallers = cg.getCallers(decoyRender!.id).map((c) => c.node.qualifiedName); + for (const fn of ['inlineLocal', 'autoLocal', 'autoLocalPtr']) { + expect(decoyCallers, `${fn} must not misroute to Decoy::render`).not.toContain(fn); + } + } finally { + cg?.close(); + } + }); + it('bridges a base virtual method to the subclass override', async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cpp-')); fs.writeFileSync( diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 03b8ea6ab..6251fab9a 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -11,7 +11,7 @@ import * as os from 'os'; import { CodeGraph } from '../src'; import { Node, UnresolvedReference } from '../src/types'; import { ReferenceResolver, createResolver, ResolutionContext } from '../src/resolution'; -import { matchReference } from '../src/resolution/name-matcher'; +import { matchReference, inferCppTypeFromInitializer, parseCppReturnType } from '../src/resolution/name-matcher'; import { resolveImportPath, extractImportMappings, resolveJvmImport, loadCppIncludeDirs, clearCppIncludeDirCache } from '../src/resolution/import-resolver'; import type { UnresolvedRef } from '../src/resolution/types'; import { detectFrameworks, getAllFrameworkResolvers } from '../src/resolution/frameworks'; @@ -1603,4 +1603,67 @@ func main() { } }); }); + + describe('C++ auto-local type inference (inferCppTypeFromInitializer)', () => { + // Tier 1 — type is syntactically present in the initializer. + it('infers from smart-pointer factories', () => { + expect(inferCppTypeFromInitializer('std::make_unique()')).toBe('Widget'); + expect(inferCppTypeFromInitializer('make_shared(a, b)')).toBe('Widget'); + expect(inferCppTypeFromInitializer('std::make_unique()')).toBe('Widget'); + }); + it('infers from new expressions', () => { + expect(inferCppTypeFromInitializer('new Widget()')).toBe('Widget'); + expect(inferCppTypeFromInitializer('new ns::Widget{}')).toBe('Widget'); + expect(inferCppTypeFromInitializer('new Widget(3)')).toBe('Widget'); + }); + it('infers from cast expressions', () => { + expect(inferCppTypeFromInitializer('static_cast(p)')).toBe('Widget'); + expect(inferCppTypeFromInitializer('dynamic_cast(r)')).toBe('Widget'); + }); + it('infers from direct construction', () => { + expect(inferCppTypeFromInitializer('Widget(1, 2)')).toBe('Widget'); + expect(inferCppTypeFromInitializer('Widget{}')).toBe('Widget'); + }); + + // Tier 2 — self-returning singleton accessor: qualifier is the type. + it('infers from singleton accessors but not arbitrary scoped calls', () => { + expect(inferCppTypeFromInitializer('Widget::instance()')).toBe('Widget'); + expect(inferCppTypeFromInitializer('Widget::getInstance()')).toBe('Widget'); + expect(inferCppTypeFromInitializer('ns::Widget::instance()')).toBe('Widget'); + // A non-accessor scoped call may return some OTHER type — must NOT be + // mistaken for `Factory`. Returns null (Tier 3, uncovered). + expect(inferCppTypeFromInitializer('Factory::create()')).toBeNull(); + expect(inferCppTypeFromInitializer('Config::parseFlags()')).toBeNull(); + }); + + // Tier 3 — needs real return-type inference: deliberately uncovered. + it('returns null when the type is not syntactically evident', () => { + expect(inferCppTypeFromInitializer('helper()')).toBeNull(); // free function + expect(inferCppTypeFromInitializer('obj.getThing()')).toBeNull(); // member chain + expect(inferCppTypeFromInitializer('obj->getThing()')).toBeNull(); + expect(inferCppTypeFromInitializer('')).toBeNull(); + expect(inferCppTypeFromInitializer(' ')).toBeNull(); + }); + }); + + describe('C++ return-type parsing (parseCppReturnType)', () => { + it('reads the `-> ReturnType` suffix and normalizes it to a bare class', () => { + expect(parseCppReturnType('() -> Worker')).toBe('Worker'); + expect(parseCppReturnType('(int a) -> Widget')).toBe('Widget'); + expect(parseCppReturnType('() -> ns::Engine')).toBe('Engine'); + }); + it('unwraps smart-pointer return types to the pointee', () => { + expect(parseCppReturnType('() -> std::shared_ptr')).toBe('Foo'); + expect(parseCppReturnType('() -> unique_ptr')).toBe('Bar'); + expect(parseCppReturnType('() -> std::weak_ptr')).toBe('Baz'); + }); + it('rejects primitives, void, and missing return types', () => { + expect(parseCppReturnType('() -> void')).toBeNull(); + expect(parseCppReturnType('(int a) -> int')).toBeNull(); + expect(parseCppReturnType('() -> bool')).toBeNull(); + expect(parseCppReturnType('(int a)')).toBeNull(); // constructor: no `->` + expect(parseCppReturnType(undefined)).toBeNull(); + expect(parseCppReturnType(null)).toBeNull(); + }); + }); }); diff --git a/src/extraction/languages/c-cpp.ts b/src/extraction/languages/c-cpp.ts index fa511150d..b8cbd23e1 100644 --- a/src/extraction/languages/c-cpp.ts +++ b/src/extraction/languages/c-cpp.ts @@ -47,6 +47,46 @@ function extractCppReceiverType(node: SyntaxNode, source: string): string | unde return undefined; } +/** + * Build a C/C++ function/method signature as `() -> `. + * + * The `-> ` suffix is the part resolution relies on: the C++ + * receiver-type inference reads it back to learn what `Foo::instance()` / + * `makeWidget()` returns, so `auto x = makeWidget(); x.draw()` and + * `Foo::instance().bar()` resolve to the right class without real type + * inference. The return type's base lives in the function_definition's `type` + * field (`Worker`, `void`, `std::shared_ptr`); the `&`/`*` lives on the + * declarator and is intentionally dropped (resolution normalizes it away + * anyway). Constructors/destructors have no `type` field → params only. + */ +function extractCppSignature(node: SyntaxNode, source: string): string | undefined { + const typeField = getChildByField(node, 'type'); + + // The parameters live on the function_declarator, which may be wrapped in a + // pointer_/reference_declarator (e.g. `Worker& Worker::instance()`). Descend + // to the first function_declarator and read its `parameters` field. + let params: SyntaxNode | null = null; + const decl = getChildByField(node, 'declarator'); + const queue: SyntaxNode[] = decl ? [decl] : []; + while (queue.length > 0) { + const current = queue.shift()!; + if (current.type === 'function_declarator') { + params = getChildByField(current, 'parameters'); + break; + } + for (let i = 0; i < current.namedChildCount; i++) { + const child = current.namedChild(i); + if (child) queue.push(child); + } + } + + const paramText = params ? getNodeText(params, source) : '()'; + if (typeField) { + return `${paramText} -> ${getNodeText(typeField, source).trim()}`; + } + return paramText || undefined; +} + export const cExtractor: LanguageExtractor = { functionTypes: ['function_definition'], classTypes: [], @@ -109,6 +149,7 @@ export const cppExtractor: LanguageExtractor = { paramsField: 'parameters', resolveName: extractCppQualifiedMethodName, getReceiverType: extractCppReceiverType, + getSignature: extractCppSignature, getVisibility: (node) => { // Check for access specifier in parent const parent = node.parent; diff --git a/src/extraction/tree-sitter-types.ts b/src/extraction/tree-sitter-types.ts index 6c04fbaeb..a2be0bbf8 100644 --- a/src/extraction/tree-sitter-types.ts +++ b/src/extraction/tree-sitter-types.ts @@ -13,6 +13,37 @@ import { UnresolvedReference, } from '../types'; +/** + * C++ method names that, by convention, return an instance of their OWN + * enclosing class — singleton accessors (`Foo::instance()`) and similar + * self-returning statics. For these, the call's qualifier IS the receiver + * type, so `Foo::instance().bar()` can be resolved to `Foo::bar` without + * real return-type inference. + * + * Shared between extraction (tree-sitter.ts emits a qualified callee for + * inline `Foo::instance().bar()`) and resolution (name-matcher.ts infers + * the type of an `auto` local initialized from `Foo::instance()`), so the + * two sites can't drift apart. Compared case-insensitively (lower-cased). + * + * Intentionally curated, NOT permissive: a generic factory like + * `Widget::create()` may return some *other* type, so including loose names + * such as `get`/`create` here would mis-type those. Keep it to names that + * idiomatically return Self. Wrong matches are still bounded — both call + * sites validate that the inferred type actually has the method — but a + * tight set keeps that safety net from ever being exercised. + */ +export const CPP_SINGLETON_ACCESSORS: ReadonlySet = new Set([ + 'instance', + 'getinstance', + 'get_instance', + 'instanceptr', + 'getinstanceptr', + 'shared', + 'sharedinstance', + 'getsharedinstance', + 'singleton', +]); + /** * Information returned by a language's extractImport hook. */ diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 7db606234..38847b7c2 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1710,6 +1710,54 @@ export class TreeSitterExtractor { }); } + /** + * Encode the receiver of a C++ call chained off another call's result into a + * `Recv().method` reference the resolver can later type via return types. + * + * Foo::instance().method() → `Foo::instance().method` + * ns::Foo::create().method() → `Foo::create().method` (class is the + * segment before the accessor) + * makeWidget()->method() → `makeWidget().method` (free function) + * + * Returns null for receiver calls we can't name (e.g. the inner function is + * itself a member/field expression), so the caller falls back to a bare + * method name. We deliberately do NOT gate on accessor name here — that + * decision (return-type vs. self-returning-accessor heuristic) belongs to the + * resolver, which alone has the cross-file return-type information. + */ + private cppChainedCallReceiverCallee(receiverCall: SyntaxNode, methodName: string): string | null { + const innerFn = getChildByField(receiverCall, 'function'); + if (!innerFn) return null; + // Scoped accessor / factory: `Foo::instance()` / `ns::Foo::create()`. + if (innerFn.type === 'qualified_identifier' || innerFn.type === 'scoped_identifier') { + const parts = getNodeText(innerFn, this.source).trim().split('::').filter(Boolean); + if (parts.length < 2) return null; + const accessor = parts[parts.length - 1]!; + const klass = parts[parts.length - 2]!; + return `${klass}::${accessor}().${methodName}`; + } + // Free-function factory: `makeWidget()`. + if (innerFn.type === 'identifier') { + const fn = getNodeText(innerFn, this.source).trim(); + if (fn) return `${fn}().${methodName}`; + } + // Single-level member chain: `obj.getThing()` / `obj->getThing()`. Encode + // as `obj.getThing().method` (operator normalized to `.`) when the object is + // a plain identifier; the resolver infers obj's type then the chained + // method's return type. Deeper chains (`a().b().c()`) are left to the bare + // fallback — they need a real type environment, not a longer string. + if (innerFn.type === 'field_expression') { + const obj = getChildByField(innerFn, 'argument'); + const field = getChildByField(innerFn, 'field'); + if (obj && obj.type === 'identifier' && field) { + const objName = getNodeText(obj, this.source).trim(); + const midMethod = getNodeText(field, this.source).trim(); + if (objName && midMethod) return `${objName}.${midMethod}().${methodName}`; + } + } + return null; + } + /** * Extract a function call */ @@ -1831,6 +1879,18 @@ export class TreeSitterExtractor { } else { calleeName = methodName; } + } else if (this.language === 'cpp' && receiver && receiver.type === 'call_expression') { + // C++ call chained off another call's result, invoked inline: + // Foo::instance().method() / Foo::getInstance()->method() + // makeWidget()->method() (free-function factory) + // tree-sitter gives a field_expression whose receiver is the inner + // call_expression. We can't know its return type here (extraction + // is per-file), so encode the receiver call as `Recv().method` and + // let the resolver recover the type from the callee's return type + // (with a self-returning-accessor-name fallback). Better than + // dropping the receiver to a bare, ambiguous `method` that + // tie-breaks to whichever same-named method indexed first. + calleeName = this.cppChainedCallReceiverCallee(receiver, methodName) ?? methodName; } else { calleeName = methodName; } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 03fa79242..a8cdc9018 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -6,6 +6,7 @@ import { Node } from '../types'; import { UnresolvedRef, ResolvedRef, ResolutionContext } from './types'; +import { CPP_SINGLETON_ACCESSORS } from '../extraction/tree-sitter-types'; /** * Try to resolve a path-like reference (e.g., "snippets/drawer-menu.liquid") @@ -242,10 +243,161 @@ function buildDeclaratorRegex(escapedReceiver: string): RegExp { ); } +/** Bare last `::`-segment of a possibly-qualified C++ name (`ns::Foo` → `Foo`). */ +function lastCppSegment(qualified: string): string | null { + const parts = qualified.split('::').filter(Boolean); + const last = parts[parts.length - 1]; + return last || null; +} + +/** + * Infer the type of an `auto` local from the text of its initializer, when the + * type is syntactically evident (Tier 1) or comes from a self-returning + * singleton accessor (Tier 2). Returns a bare type name, or null when the + * initializer needs real return-type inference (free function, member chain, + * generic factory — Tier 3, deliberately uncovered: silent beats wrong). + * + * `init` is the source text to the right of `=` in the declaration. + */ +export function inferCppTypeFromInitializer(init: string): string | null { + const s = init.trim(); + if (!s) return null; + + // make_unique() / make_shared() (with or without a std:: prefix). + let m = s.match(/\bmake_(?:unique|shared)\s*<\s*([A-Za-z_][\w:]*)/); + if (m) return lastCppSegment(m[1]!); + + // new Foo(...) / new Foo<...>(...) / new Foo{...} + m = s.match(/\bnew\s+([A-Za-z_][\w:]*)/); + if (m) return lastCppSegment(m[1]!); + + // static_cast(...) / dynamic_cast / reinterpret_cast / const_cast + m = s.match(/\b(?:static|dynamic|reinterpret|const)_cast\s*<\s*([A-Za-z_][\w:]*)/); + if (m) return lastCppSegment(m[1]!); + + // Foo::instance() — self-returning singleton accessor (qualifier IS the type). + // Checked before bare construction so `Foo::instance()` doesn't fall into it. + m = s.match(/^([A-Za-z_][\w:]*)::([A-Za-z_]\w*)\s*\(/); + if (m && CPP_SINGLETON_ACCESSORS.has(m[2]!.toLowerCase())) return lastCppSegment(m[1]!); + + // Direct construction: Foo(...) or Foo{...}. Require an upper-case initial + // (C++ type convention) so a lower-case free-function call — `helper()` — + // isn't mistaken for a constructor. No `::`, so a scoped accessor call that + // wasn't a known singleton above can't slip through here either. (Lower-case + // std types like `string(...)` are missed, but they're never user-defined + // nodes the resolver could match anyway, so nothing is lost.) + m = s.match(/^([A-Z]\w*)\s*[({]/); + if (m) return m[1]!; + + return null; +} + +/** Pseudo-receivers that name no inferable local/field type. */ +const CPP_SKIP_RECEIVERS: ReadonlySet = new Set(['this', 'self', 'super']); + +/** C++ built-in / non-class return types that can't be a method receiver. */ +const CPP_PRIMITIVE_TYPES: ReadonlySet = new Set([ + 'void', 'bool', 'char', 'short', 'int', 'long', 'float', 'double', 'unsigned', + 'signed', 'wchar_t', 'char8_t', 'char16_t', 'char32_t', 'size_t', 'ssize_t', + 'ptrdiff_t', 'intptr_t', 'uintptr_t', 'int8_t', 'int16_t', 'int32_t', 'int64_t', + 'uint8_t', 'uint16_t', 'uint32_t', 'uint64_t', 'auto', +]); + +/** + * Normalize a C++ return-type string to the bare user-defined class name it + * yields a receiver of, or null. Unwraps the common smart-pointer wrappers + * (`std::shared_ptr` → `Foo`) — a `ptr->method()` call on the result + * dispatches to the pointee — and rejects primitives/`void`. + */ +function normalizeCppReturnType(raw: string): string | null { + if (!raw) return null; + const smart = raw.match(/\b(?:shared_ptr|unique_ptr|weak_ptr|auto_ptr)\s*<\s*([A-Za-z_][\w:]*)/); + if (smart) { + const inner = lastCppSegment(smart[1]!); + return inner && !CPP_PRIMITIVE_TYPES.has(inner) ? inner : null; + } + const base = normalizeCppTypeName(raw); + if (!base || CPP_PRIMITIVE_TYPES.has(base)) return null; + return base; +} + +/** + * Pull the `-> ReturnType` suffix out of a C++ signature (built by + * `extractCppSignature` as `(params) -> ReturnType`) and normalize it. + */ +export function parseCppReturnType(signature: string | undefined | null): string | null { + if (!signature) return null; + const idx = signature.lastIndexOf('->'); + if (idx < 0) return null; + return normalizeCppReturnType(signature.slice(idx + 2).trim()); +} + +/** + * Return type (as a bare class name) of a C++ callable named by `callee` — a + * qualified `Foo::bar` or a bare free function `makeFoo` — read from the + * resolved node's signature. Null when the callable isn't indexed, has no + * captured return type, or returns a primitive/void. + */ +function cppReturnTypeOf(callee: string, context: ResolutionContext): string | null { + let candidates: Node[] = []; + if (callee.includes('::')) { + candidates = context.getNodesByQualifiedName(callee); + if (candidates.length === 0) { + // Namespaced definition (`ns::Foo::bar`) won't match the class-qualified + // `Foo::bar` exactly — fall back to a suffix match on the bare name. + const last = lastCppSegment(callee); + if (last) { + candidates = context + .getNodesByName(last) + .filter((n) => n.qualifiedName.endsWith(callee)); + } + } + } else { + candidates = context + .getNodesByName(callee) + .filter((n) => n.kind === 'function' || n.kind === 'method'); + } + for (const n of candidates) { + if (n.language !== 'cpp') continue; + const rt = parseCppReturnType(n.signature); + if (rt) return rt; + } + return null; +} + +/** + * Return type (bare class name) of method `methodName` declared on C++ class + * `typeName` — i.e. the type of `objOfTypeName.methodName()`. Strictly scoped to + * methods whose qualified name ends in `typeName::methodName`, so a same-named + * method on an unrelated class can't leak in. Inherited methods (defined on a + * base class) are not followed — a deliberate single-level limit. + */ +function cppReturnTypeOfMethodOnType( + typeName: string, + methodName: string, + context: ResolutionContext, +): string | null { + const suffix = `${typeName}::${methodName}`; + const methods = context + .getNodesByName(methodName) + .filter( + (n) => + n.kind === 'method' && + n.language === 'cpp' && + n.qualifiedName.endsWith(suffix), + ); + for (const m of methods) { + const rt = parseCppReturnType(m.signature); + if (rt) return rt; + } + return null; +} + function inferCppReceiverType( receiverName: string, ref: UnresolvedRef, context: ResolutionContext, + depth = 0, ): string | null { const source = context.readFile(ref.filePath); if (!source) return null; @@ -263,7 +415,41 @@ function inferCppReceiverType( const declaratorMatch = line.match(declaratorRegex); if (declaratorMatch) { const normalized = normalizeCppTypeName(declaratorMatch[1] ?? ''); - if (normalized) return normalized; + if (normalized && normalized !== 'auto') return normalized; + if (normalized === 'auto') { + // `auto[&*] recv = ;` — the declared type is deduced, so recover + // it from the initializer. + const eqIdx = line.indexOf('=', (declaratorMatch.index ?? 0) + declaratorMatch[0].length); + if (eqIdx >= 0) { + const init = line.slice(eqIdx + 1); + // Tier 1/2: type is syntactically evident (new/make_*/cast) or a + // named self-returning accessor. + const syntactic = inferCppTypeFromInitializer(init); + if (syntactic) return syntactic; + // Tier 3: the initializer is a plain call — use the callee's captured + // return type (`auto w = makeWidget()`, `auto w = Factory::create()`). + const initText = init.trim(); + const callMatch = initText.match(/^([A-Za-z_][\w:]*)\s*\(/); + if (callMatch) { + const rt = cppReturnTypeOf(callMatch[1]!, context); + if (rt) return rt; + } + // Tier 3, single-level member chain: `auto x = obj.getThing();`. + // Resolve obj's type (one recursion, depth-bounded), then read the + // return type of getThing on that type. + const memberMatch = initText.match(/^([A-Za-z_]\w*)\s*(?:\.|->)\s*([A-Za-z_]\w*)\s*\(/); + if (memberMatch && depth < 2 && !CPP_SKIP_RECEIVERS.has(memberMatch[1]!)) { + const objType = inferCppReceiverType(memberMatch[1]!, ref, context, depth + 1); + if (objType) { + const rt = cppReturnTypeOfMethodOnType(objType, memberMatch[2]!, context); + if (rt) return rt; + } + } + } + // Un-inferable auto initializer (deep chain / unindexed callee): keep + // scanning earlier lines in case the receiver was also declared with an + // explicit type elsewhere — rare, but cheap and strictly more precise. + } } } @@ -350,6 +536,67 @@ function inferJavaFieldReceiverType( return lastPart; } +/** + * Resolve a C++ call chained off another call's result, which extraction + * encodes as `Recv().method` (see `cppChainedCallReceiverCallee`): + * + * Foo::instance().bar() → ref `Foo::instance().bar` + * WidgetFactory::create().draw() → ref `WidgetFactory::create().draw` + * makeWidget()->run() → ref `makeWidget().run` + * + * Primary path is return-type driven: whatever `Recv()` actually returns is the + * receiver type, so a factory that returns a *different* class resolves to that + * class — something the name-only heuristic can't do. Fallback (when the + * callee's return type isn't indexed, e.g. an external accessor) treats a + * self-returning-accessor *name* as evidence the qualifier is the type. + */ +export function matchCppChainedAccessor( + ref: UnresolvedRef, + context: ResolutionContext +): ResolvedRef | null { + if (ref.language !== 'cpp') return null; + + // Single-level member chain: `obj.getThing().method` — infer obj's type, then + // the return type of getThing on it, then resolve method on that. Matched + // before the simpler form because its two `()`-free segments are dot-joined. + const chain = ref.referenceName.match(/^([A-Za-z_]\w*)\.([A-Za-z_]\w*)\(\)\.([A-Za-z_]\w*)$/); + if (chain) { + const [, objVar, midMethod, finalMethod] = chain; + if (CPP_SKIP_RECEIVERS.has(objVar!)) return null; + const objType = inferCppReceiverType(objVar!, ref, context); + if (!objType) return null; + const midType = cppReturnTypeOfMethodOnType(objType, midMethod!, context); + if (!midType) return null; + return resolveMethodOnType(midType, finalMethod!, ref, context, 0.85, 'instance-method'); + } + + const m = ref.referenceName.match(/^([A-Za-z_][\w:]*)\(\)\.([A-Za-z_]\w*)$/); + if (!m) return null; + const callee = m[1]!; + const method = m[2]!; + + // Return-type-driven: what does the receiver call actually return? + const returnType = cppReturnTypeOf(callee, context); + if (returnType) { + const hit = resolveMethodOnType(returnType, method, ref, context, 0.9, 'instance-method'); + if (hit) return hit; + } + + // Fallback: a known self-returning accessor name (`Foo::instance()`) means the + // qualifier is the receiver type, even when we couldn't read its return type. + if (callee.includes('::')) { + const parts = callee.split('::').filter(Boolean); + const accessor = parts[parts.length - 1]!; + const klass = parts[parts.length - 2]; + if (klass && CPP_SINGLETON_ACCESSORS.has(accessor.toLowerCase())) { + const hit = resolveMethodOnType(klass, method, ref, context, 0.85, 'instance-method'); + if (hit) return hit; + } + } + + return null; +} + /** * Try to resolve by method name on a class/object */ @@ -692,6 +939,12 @@ export function matchReference( result = matchByFilePath(ref, context); if (result) return result; + // 0.5. C++ chained accessor/factory call (`Foo::instance().bar`) — resolved + // via the receiver call's return type. Runs before qualified-name match, + // whose partial matcher would otherwise mis-handle the `()` in the name. + result = matchCppChainedAccessor(ref, context); + if (result) return result; + // 1. Qualified name match (highest confidence) result = matchByQualifiedName(ref, context); if (result) return result; From 40dc91165cea4863772ccd9a5e3ebf64718a17b2 Mon Sep 17 00:00:00 2001 From: stabey <36232531+stabey@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:17:15 +0800 Subject: [PATCH 2/6] fix(cpp): require a :: boundary for return-type suffix matches (#645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Codex review on #646. The return-type lookups matched a qualified name with a plain `endsWith`, so `Foo::create` also accepted `OtherFoo::create` (and `Manager::view` accepted `OtherManager::view`). If the same-suffix class indexed first and returned a different type, the chained/auto call misrouted. Match at a `::` boundary instead (exact, or `::`), mirroring resolveMethodOnType. Adds a regression test with an `OtherManager` decoy that sorts first — verified red before the fix, green after. Co-Authored-By: Claude Opus 4.8 --- __tests__/frameworks-integration.test.ts | 54 ++++++++++++++++++++++++ src/resolution/name-matcher.ts | 14 +++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/__tests__/frameworks-integration.test.ts b/__tests__/frameworks-integration.test.ts index 45d727807..48e61e75b 100644 --- a/__tests__/frameworks-integration.test.ts +++ b/__tests__/frameworks-integration.test.ts @@ -484,6 +484,60 @@ describe('C++ end-to-end — virtual override synthesis', () => { } }); + it('does not match a same-suffix class when inferring a chained method return type (Manager vs OtherManager)', async () => { + // Regression for the suffix-boundary bug (Codex review, PR #646): the + // chained-method return-type lookup matched a qualified name by `endsWith`, + // so resolving `view()` on `Manager` could pick `OtherManager::view` + // (its name ends with `Manager::view`). OtherManager sorts first and returns + // a different type, so without a `::` boundary check the final `render()` + // misroutes to that type's class. + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cpp-')); + let cg: CodeGraph | undefined; + try { + // `other.hpp` sorts before `types.hpp`, so OtherManager indexes first and + // would win a naive endsWith('Manager::view') match. + fs.writeFileSync( + path.join(tmpDir, 'other.hpp'), + 'class Decoy { public: void render(); };\n' + + 'class OtherManager { public: Decoy view(); };\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'types.hpp'), + 'class Panel { public: void render(); };\n' + + 'class Manager { public: Panel view(); };\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'defs.cpp'), + '#include "other.hpp"\n' + + '#include "types.hpp"\n' + + 'void Decoy::render() {}\n' + + 'Decoy OtherManager::view() { return Decoy(); }\n' + + 'void Panel::render() {}\n' + + 'Panel Manager::view() { return Panel(); }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'app.cpp'), + '#include "types.hpp"\n' + + 'void useManager() { Manager m; m.view().render(); }\n' + ); + + cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const panelRender = cg.getNodesByKind('method').find((n) => n.qualifiedName === 'Panel::render'); + const decoyRender = cg.getNodesByKind('method').find((n) => n.qualifiedName === 'Decoy::render'); + expect(panelRender, 'Panel::render node').toBeDefined(); + expect(decoyRender, 'Decoy::render node').toBeDefined(); + + // Manager::view() returns Panel — resolve render() on Panel, never on the + // same-suffix OtherManager::view() (which returns Decoy). + expect(cg.getCallers(panelRender!.id).map((c) => c.node.qualifiedName)).toContain('useManager'); + expect(cg.getCallers(decoyRender!.id).map((c) => c.node.qualifiedName)).not.toContain('useManager'); + } finally { + cg?.close(); + } + }); + it('bridges a base virtual method to the subclass override', async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cpp-')); fs.writeFileSync( diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index a8cdc9018..ced14e089 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -250,6 +250,16 @@ function lastCppSegment(qualified: string): string | null { return last || null; } +/** + * Whether a qualified name matches `suffix` at a `::` boundary — exact, or a + * deeper namespace (`ns::Foo::bar` matches suffix `Foo::bar`). Crucially does + * NOT match `OtherFoo::bar`, which a plain `endsWith('Foo::bar')` would. Mirrors + * the boundary check in `resolveMethodOnType`. + */ +function cppQualifiedMatchesSuffix(qualifiedName: string, suffix: string): boolean { + return qualifiedName === suffix || qualifiedName.endsWith(`::${suffix}`); +} + /** * Infer the type of an `auto` local from the text of its initializer, when the * type is syntactically evident (Tier 1) or comes from a self-returning @@ -349,7 +359,7 @@ function cppReturnTypeOf(callee: string, context: ResolutionContext): string | n if (last) { candidates = context .getNodesByName(last) - .filter((n) => n.qualifiedName.endsWith(callee)); + .filter((n) => cppQualifiedMatchesSuffix(n.qualifiedName, callee)); } } } else { @@ -384,7 +394,7 @@ function cppReturnTypeOfMethodOnType( (n) => n.kind === 'method' && n.language === 'cpp' && - n.qualifiedName.endsWith(suffix), + cppQualifiedMatchesSuffix(n.qualifiedName, suffix), ); for (const m of methods) { const rt = parseCppReturnType(m.signature); From 261ada417725e076fc55fbd1d24a9f9a55b00a7a Mon Sep 17 00:00:00 2001 From: stabey <36232531+stabey@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:30:02 +0800 Subject: [PATCH 3/6] fix(cpp): restrict bare factory return-type lookup to free functions (#645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a third Codex finding on #646. cppReturnTypeOf's bare (unqualified) branch — used for `makeWidget()->draw()`-style chained calls — also matched same-named *methods*, so an unrelated `Decoy::makeWidget` indexed first could supply the return type and misroute the chained call. A bare unqualified call is a free function; restrict the lookup to kind 'function'. Adds a regression test with a same-named method that sorts first. Co-Authored-By: Claude Opus 4.8 --- __tests__/frameworks-integration.test.ts | 51 ++++++++++++++++++++++++ src/resolution/name-matcher.ts | 7 +++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/__tests__/frameworks-integration.test.ts b/__tests__/frameworks-integration.test.ts index 48e61e75b..f0fe06a7a 100644 --- a/__tests__/frameworks-integration.test.ts +++ b/__tests__/frameworks-integration.test.ts @@ -538,6 +538,57 @@ describe('C++ end-to-end — virtual override synthesis', () => { } }); + it('resolves a bare factory call to the free function, not a same-named method', async () => { + // Regression for Codex review (PR #646): a bare `make()->draw()` call encodes + // as `make().draw`, an unqualified callee. The return-type lookup must use + // the free function `make`, not an unrelated `Decoy::make` method that + // happens to share the name and sorts first — otherwise draw() misroutes to + // whatever that method returns. + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cpp-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'other.hpp'), // sorts first → Decoy::make indexed first + 'class Decoy { public: void draw(); };\n' + + 'class Other { public: Decoy make(); };\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'widget.hpp'), + 'class Widget { public: void draw(); };\n' + + 'Widget* make();\n' // free function sharing the name `make` + ); + fs.writeFileSync( + path.join(tmpDir, 'defs.cpp'), + '#include "other.hpp"\n' + + '#include "widget.hpp"\n' + + 'void Decoy::draw() {}\n' + + 'Decoy Other::make() { return Decoy(); }\n' + + 'void Widget::draw() {}\n' + + 'Widget* make() { return nullptr; }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'app.cpp'), + '#include "widget.hpp"\n' + + 'void useFree() { make()->draw(); }\n' + ); + + cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const widgetDraw = cg.getNodesByKind('method').find((n) => n.qualifiedName === 'Widget::draw'); + const decoyDraw = cg.getNodesByKind('method').find((n) => n.qualifiedName === 'Decoy::draw'); + expect(widgetDraw, 'Widget::draw node').toBeDefined(); + expect(decoyDraw, 'Decoy::draw node').toBeDefined(); + + // The free function make() returns Widget* — draw() resolves on Widget, + // never on Other::make()'s return type (Decoy). + expect(cg.getCallers(widgetDraw!.id).map((c) => c.node.qualifiedName)).toContain('useFree'); + expect(cg.getCallers(decoyDraw!.id).map((c) => c.node.qualifiedName)).not.toContain('useFree'); + } finally { + cg?.close(); + } + }); + it('bridges a base virtual method to the subclass override', async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cpp-')); fs.writeFileSync( diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index ced14e089..1b4dd18f0 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -363,9 +363,14 @@ function cppReturnTypeOf(callee: string, context: ResolutionContext): string | n } } } else { + // A bare (unqualified) callee like `makeWidget()` is a free function. Do NOT + // include same-named *methods* of unrelated classes — `Decoy::makeWidget` + // could otherwise supply the return type and misroute the chained call. (A + // method invoked via implicit `this` would need caller-class scoping we + // don't do; missing it is silent, which beats resolving to the wrong class.) candidates = context .getNodesByName(callee) - .filter((n) => n.kind === 'function' || n.kind === 'method'); + .filter((n) => n.kind === 'function'); } for (const n of candidates) { if (n.language !== 'cpp') continue; From 3a6fc078a234eabdface2cd9119e23caef76e39f Mon Sep 17 00:00:00 2001 From: stabey <36232531+stabey@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:46:35 +0800 Subject: [PATCH 4/6] fix(cpp): preserve namespaces for chained scoped calls (#645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a fourth Codex finding on #646. A chained scoped call kept only the segment before the accessor, so `a::Factory::create().draw()` and `b::Factory::create().draw()` both collapsed to `Factory::create` and the first captured return type won — misrouting when the same class name exists in multiple namespaces. - Encode the FULL qualifier (`a::Factory::create().draw`) so the resolver can tell the namespaces apart by return type. - cppReturnTypeOf matches most-specific first (exact qn → full-qualifier suffix → Class::accessor) and bails when matches disagree on the return type (uniqueCppReturnType) — silent beats a wrong edge. - cppReturnTypeOfMethodOnType bails on the same ambiguity; the accessor-name fallback keeps the namespace in the owner type. Adds a regression test resolving a Factory class shared across two namespaces. Co-Authored-By: Claude Opus 4.8 --- __tests__/frameworks-integration.test.ts | 56 ++++++++++++++ src/extraction/tree-sitter.ts | 13 ++-- src/resolution/name-matcher.ts | 95 ++++++++++++++---------- 3 files changed, 118 insertions(+), 46 deletions(-) diff --git a/__tests__/frameworks-integration.test.ts b/__tests__/frameworks-integration.test.ts index f0fe06a7a..4005c43a2 100644 --- a/__tests__/frameworks-integration.test.ts +++ b/__tests__/frameworks-integration.test.ts @@ -589,6 +589,62 @@ describe('C++ end-to-end — virtual override synthesis', () => { } }); + it('disambiguates a factory class name shared across namespaces by its return type', async () => { + // Regression for Codex review (PR #646): a chained scoped call kept only the + // segment before the accessor, so `a::Factory::create()` and + // `b::Factory::create()` both collapsed to `Factory::create` and the first + // captured return type won. Encoding the full qualifier lets each resolve to + // its own namespace's return type. + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cpp-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'a.hpp'), + 'class Widget { public: void draw(); };\n' + + 'namespace a { class Factory { public: Widget create(); }; }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'b.hpp'), + 'class Gadget { public: void draw(); };\n' + + 'namespace b { class Factory { public: Gadget create(); }; }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'defs.cpp'), + '#include "a.hpp"\n' + + '#include "b.hpp"\n' + + 'void Widget::draw() {}\n' + + 'void Gadget::draw() {}\n' + + 'Widget a::Factory::create() { return Widget(); }\n' + + 'Gadget b::Factory::create() { return Gadget(); }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'app.cpp'), + '#include "a.hpp"\n' + + '#include "b.hpp"\n' + + 'void useA() { a::Factory::create().draw(); }\n' + + 'void useB() { b::Factory::create().draw(); }\n' + ); + + cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const widgetDraw = cg.getNodesByKind('method').find((n) => n.qualifiedName === 'Widget::draw'); + const gadgetDraw = cg.getNodesByKind('method').find((n) => n.qualifiedName === 'Gadget::draw'); + expect(widgetDraw, 'Widget::draw node').toBeDefined(); + expect(gadgetDraw, 'Gadget::draw node').toBeDefined(); + + // a::Factory::create() returns Widget; b::Factory::create() returns Gadget. + const widgetCallers = cg.getCallers(widgetDraw!.id).map((c) => c.node.qualifiedName); + const gadgetCallers = cg.getCallers(gadgetDraw!.id).map((c) => c.node.qualifiedName); + expect(widgetCallers).toContain('useA'); + expect(widgetCallers).not.toContain('useB'); + expect(gadgetCallers).toContain('useB'); + expect(gadgetCallers).not.toContain('useA'); + } finally { + cg?.close(); + } + }); + it('bridges a base virtual method to the subclass override', async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cpp-')); fs.writeFileSync( diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 38847b7c2..90b54a563 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1728,13 +1728,14 @@ export class TreeSitterExtractor { private cppChainedCallReceiverCallee(receiverCall: SyntaxNode, methodName: string): string | null { const innerFn = getChildByField(receiverCall, 'function'); if (!innerFn) return null; - // Scoped accessor / factory: `Foo::instance()` / `ns::Foo::create()`. + // Scoped accessor / factory: `Foo::instance()` / `ns::Foo::create()`. Keep + // the FULL qualifier (namespace + class + accessor), so the resolver can + // disambiguate a class name shared across namespaces by its return type — + // `a::Factory::create` and `b::Factory::create` must not collapse together. if (innerFn.type === 'qualified_identifier' || innerFn.type === 'scoped_identifier') { - const parts = getNodeText(innerFn, this.source).trim().split('::').filter(Boolean); - if (parts.length < 2) return null; - const accessor = parts[parts.length - 1]!; - const klass = parts[parts.length - 2]!; - return `${klass}::${accessor}().${methodName}`; + const qualifier = getNodeText(innerFn, this.source).trim(); + if (qualifier.split('::').filter(Boolean).length < 2) return null; + return `${qualifier}().${methodName}`; } // Free-function factory: `makeWidget()`. if (innerFn.type === 'identifier') { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 1b4dd18f0..b86469079 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -342,39 +342,58 @@ export function parseCppReturnType(signature: string | undefined | null): string return normalizeCppReturnType(signature.slice(idx + 2).trim()); } +/** + * The single return type shared by every cpp candidate that has a parseable + * one — or null when there are none, or they disagree. Disagreement means the + * lookup is ambiguous (e.g. the same class name in two namespaces returning + * different types), and guessing would misroute the call — so we stay silent. + */ +function uniqueCppReturnType(nodes: Node[]): string | null { + const types = new Set(); + for (const n of nodes) { + if (n.language !== 'cpp') continue; + const rt = parseCppReturnType(n.signature); + if (rt) types.add(rt); + } + return types.size === 1 ? [...types][0]! : null; +} + /** * Return type (as a bare class name) of a C++ callable named by `callee` — a - * qualified `Foo::bar` or a bare free function `makeFoo` — read from the - * resolved node's signature. Null when the callable isn't indexed, has no - * captured return type, or returns a primitive/void. + * qualified `a::Foo::bar` / `Foo::bar`, or a bare free function `makeFoo` — read + * from the resolved node's signature. Null when the callable isn't indexed, has + * no captured return type, returns a primitive/void, or is ambiguous. */ function cppReturnTypeOf(callee: string, context: ResolutionContext): string | null { - let candidates: Node[] = []; - if (callee.includes('::')) { - candidates = context.getNodesByQualifiedName(callee); - if (candidates.length === 0) { - // Namespaced definition (`ns::Foo::bar`) won't match the class-qualified - // `Foo::bar` exactly — fall back to a suffix match on the bare name. - const last = lastCppSegment(callee); - if (last) { - candidates = context - .getNodesByName(last) - .filter((n) => cppQualifiedMatchesSuffix(n.qualifiedName, callee)); - } - } - } else { + if (!callee.includes('::')) { // A bare (unqualified) callee like `makeWidget()` is a free function. Do NOT // include same-named *methods* of unrelated classes — `Decoy::makeWidget` // could otherwise supply the return type and misroute the chained call. (A // method invoked via implicit `this` would need caller-class scoping we // don't do; missing it is silent, which beats resolving to the wrong class.) - candidates = context - .getNodesByName(callee) - .filter((n) => n.kind === 'function'); + return uniqueCppReturnType( + context.getNodesByName(callee).filter((n) => n.kind === 'function'), + ); } - for (const n of candidates) { - if (n.language !== 'cpp') continue; - const rt = parseCppReturnType(n.signature); + // Qualified callee like `a::Factory::create` — match most-specific first so a + // class name shared across namespaces is disambiguated by its return type. + const last = lastCppSegment(callee); + if (!last) return null; + const named = context.getNodesByName(last); + // 1. Exact qualified name — out-of-line definitions keep the namespace. + let rt = uniqueCppReturnType(context.getNodesByQualifiedName(callee)); + if (rt) return rt; + // 2. The full qualifier at a `::` boundary (deeper-nested namespaces). + rt = uniqueCppReturnType(named.filter((n) => cppQualifiedMatchesSuffix(n.qualifiedName, callee))); + if (rt) return rt; + // 3. In-class definitions drop the namespace from the qualified name, so fall + // back to `Class::accessor` — but only when every match agrees on the + // return type. Distinct types means the class name is ambiguous across + // namespaces and we must not guess. + const parts = callee.split('::').filter(Boolean); + if (parts.length > 2) { + const classAccessor = parts.slice(-2).join('::'); + rt = uniqueCppReturnType(named.filter((n) => cppQualifiedMatchesSuffix(n.qualifiedName, classAccessor))); if (rt) return rt; } return null; @@ -384,7 +403,8 @@ function cppReturnTypeOf(callee: string, context: ResolutionContext): string | n * Return type (bare class name) of method `methodName` declared on C++ class * `typeName` — i.e. the type of `objOfTypeName.methodName()`. Strictly scoped to * methods whose qualified name ends in `typeName::methodName`, so a same-named - * method on an unrelated class can't leak in. Inherited methods (defined on a + * method on an unrelated class can't leak in. Returns null when the matches + * disagree (same class name across namespaces). Inherited methods (defined on a * base class) are not followed — a deliberate single-level limit. */ function cppReturnTypeOfMethodOnType( @@ -393,19 +413,11 @@ function cppReturnTypeOfMethodOnType( context: ResolutionContext, ): string | null { const suffix = `${typeName}::${methodName}`; - const methods = context - .getNodesByName(methodName) - .filter( - (n) => - n.kind === 'method' && - n.language === 'cpp' && - cppQualifiedMatchesSuffix(n.qualifiedName, suffix), - ); - for (const m of methods) { - const rt = parseCppReturnType(m.signature); - if (rt) return rt; - } - return null; + return uniqueCppReturnType( + context + .getNodesByName(methodName) + .filter((n) => n.kind === 'method' && cppQualifiedMatchesSuffix(n.qualifiedName, suffix)), + ); } function inferCppReceiverType( @@ -602,9 +614,12 @@ export function matchCppChainedAccessor( if (callee.includes('::')) { const parts = callee.split('::').filter(Boolean); const accessor = parts[parts.length - 1]!; - const klass = parts[parts.length - 2]; - if (klass && CPP_SINGLETON_ACCESSORS.has(accessor.toLowerCase())) { - const hit = resolveMethodOnType(klass, method, ref, context, 0.85, 'instance-method'); + // The owning type is everything before the accessor — keep the namespace + // (`a::Factory`), not just the bare class, so resolveMethodOnType doesn't + // match a same-named class in another namespace. + const ownerType = parts.slice(0, -1).join('::'); + if (ownerType && CPP_SINGLETON_ACCESSORS.has(accessor.toLowerCase())) { + const hit = resolveMethodOnType(ownerType, method, ref, context, 0.85, 'instance-method'); if (hit) return hit; } } From 4dd64080ae8da7a27f04893e0f5619516334b93e Mon Sep 17 00:00:00 2001 From: stabey <36232531+stabey@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:45:37 +0800 Subject: [PATCH 5/6] fix(cpp): bail when a chained return type is ambiguous across namespaces (#645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a fifth Codex finding on #646. The return type is normalized to a bare basename, so `a::Factory::create()` returning `a::Widget` and another `b::Widget` collapse to `Widget`, and the final method could link to whichever `::Widget::draw` was indexed first. The return-type-driven chained resolutions now go through resolveUniqueMethodOnType, which bails when the type's basename has more than one owner across namespaces (overloads on the same owner still resolve) — silent beats a wrong edge. Adds a regression test (verified red before the guard). Co-Authored-By: Claude Opus 4.8 --- __tests__/frameworks-integration.test.ts | 50 ++++++++++++++++++++++++ src/resolution/name-matcher.ts | 30 +++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/__tests__/frameworks-integration.test.ts b/__tests__/frameworks-integration.test.ts index 4005c43a2..2b6cb5008 100644 --- a/__tests__/frameworks-integration.test.ts +++ b/__tests__/frameworks-integration.test.ts @@ -645,6 +645,56 @@ describe('C++ end-to-end — virtual override synthesis', () => { } }); + it('does not misroute when the chained return type basename is shared across namespaces', async () => { + // Regression for Codex review (PR #646): the return type is normalized to a + // bare basename, so `a::Factory::create()` returning `a::Widget` and another + // `b::Widget` collapse to `Widget`. With two `Widget::draw` owners we can't + // tell which one — so the chained call must stay silent, never link to the + // wrong namespace's method. + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cpp-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'a.hpp'), + 'namespace a { class Widget { public: void draw(); }; class Factory { public: Widget create(); }; }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'b.hpp'), + 'namespace b { class Widget { public: void draw(); }; }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'defs.cpp'), + '#include "a.hpp"\n' + + '#include "b.hpp"\n' + + 'void a::Widget::draw() {}\n' + + 'void b::Widget::draw() {}\n' + + 'a::Widget a::Factory::create() { return a::Widget(); }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'app.cpp'), + '#include "a.hpp"\n' + + 'void useA() { a::Factory::create().draw(); }\n' + ); + + cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const aDraw = cg.getNodesByKind('method').find((n) => n.qualifiedName === 'a::Widget::draw'); + const bDraw = cg.getNodesByKind('method').find((n) => n.qualifiedName === 'b::Widget::draw'); + expect(aDraw, 'a::Widget::draw node').toBeDefined(); + expect(bDraw, 'b::Widget::draw node').toBeDefined(); + + // Ambiguous basename (a::Widget vs b::Widget) — resolution bails, so the + // call links to NEITHER. (Without the bail it would link to whichever + // sorts first — a::Widget::draw here — so asserting both are absent is the + // non-vacuous check.) + expect(cg.getCallers(aDraw!.id).map((c) => c.node.qualifiedName)).not.toContain('useA'); + expect(cg.getCallers(bDraw!.id).map((c) => c.node.qualifiedName)).not.toContain('useA'); + } finally { + cg?.close(); + } + }); + it('bridges a base virtual method to the subclass override', async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cpp-')); fs.writeFileSync( diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index b86469079..ed57fe89d 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -577,6 +577,32 @@ function inferJavaFieldReceiverType( * callee's return type isn't indexed, e.g. an external accessor) treats a * self-returning-accessor *name* as evidence the qualifier is the type. */ +/** + * Resolve `method` on C++ `typeName`, but only when it lands on a single owner. + * `typeName` is an inferred return type whose namespace we may have lost (the + * source often writes a bare `Widget`, not `a::Widget`), so if its basename is + * shared across namespaces — multiple distinct owners define `method` — we bail + * rather than link to whichever was indexed first. Overloads on the *same* + * owner are fine (one owner). Used for the return-type-driven chained paths. + */ +function resolveUniqueMethodOnType( + typeName: string, + method: string, + ref: UnresolvedRef, + context: ResolutionContext, + confidence: number, +): ResolvedRef | null { + const want = `${typeName}::${method}`; + const owners = new Set( + context + .getNodesByName(method) + .filter((n) => n.kind === 'method' && n.language === 'cpp' && cppQualifiedMatchesSuffix(n.qualifiedName, want)) + .map((n) => n.qualifiedName.slice(0, -(method.length + 2))), + ); + if (owners.size !== 1) return null; // 0 = not found, >1 = ambiguous across namespaces + return resolveMethodOnType(typeName, method, ref, context, confidence, 'instance-method'); +} + export function matchCppChainedAccessor( ref: UnresolvedRef, context: ResolutionContext @@ -594,7 +620,7 @@ export function matchCppChainedAccessor( if (!objType) return null; const midType = cppReturnTypeOfMethodOnType(objType, midMethod!, context); if (!midType) return null; - return resolveMethodOnType(midType, finalMethod!, ref, context, 0.85, 'instance-method'); + return resolveUniqueMethodOnType(midType, finalMethod!, ref, context, 0.85); } const m = ref.referenceName.match(/^([A-Za-z_][\w:]*)\(\)\.([A-Za-z_]\w*)$/); @@ -605,7 +631,7 @@ export function matchCppChainedAccessor( // Return-type-driven: what does the receiver call actually return? const returnType = cppReturnTypeOf(callee, context); if (returnType) { - const hit = resolveMethodOnType(returnType, method, ref, context, 0.9, 'instance-method'); + const hit = resolveUniqueMethodOnType(returnType, method, ref, context, 0.9); if (hit) return hit; } From e5fe18604140d384addfe7a5b0ebfe2b41522323 Mon Sep 17 00:00:00 2001 From: stabey <36232531+stabey@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:07:22 +0800 Subject: [PATCH 6/6] fix(cpp): handle trailing return types and cv-qualified smart-pointer pointees (#645) Addresses two Codex findings on #646, both common modern-C++ syntax: - Trailing return types: `auto Factory::create() -> Widget` has `auto` as the type field and the real type on the declarator's trailing-return node. extractCppSignature now reads that, so `Factory::create().draw()` and the `auto` forms resolve instead of seeing `() -> auto`. - cv-qualified smart-pointer pointees: `std::shared_ptr` matched `const` as the type. The smart-pointer/make_*/cast patterns now skip a leading const/volatile and capture `Widget`. Adds unit + end-to-end regression tests. Co-Authored-By: Claude Opus 4.8 --- __tests__/frameworks-integration.test.ts | 47 ++++++++++++++++++++++++ __tests__/resolution.test.ts | 9 +++++ src/extraction/languages/c-cpp.ts | 27 ++++++++++++-- src/resolution/name-matcher.ts | 13 ++++--- 4 files changed, 88 insertions(+), 8 deletions(-) diff --git a/__tests__/frameworks-integration.test.ts b/__tests__/frameworks-integration.test.ts index 2b6cb5008..f814bab4f 100644 --- a/__tests__/frameworks-integration.test.ts +++ b/__tests__/frameworks-integration.test.ts @@ -695,6 +695,53 @@ describe('C++ end-to-end — virtual override synthesis', () => { } }); + it('resolves a chained call through a trailing return type', async () => { + // Regression for Codex review (PR #646): `auto Factory::create() -> Widget` + // has `auto` as its type field and the real return type on the declarator's + // trailing-return node. Reading that lets the chained call resolve. + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cpp-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'decoy.hpp'), // sorts first → wins any name-only tie + 'class Decoy { public: void draw(); };\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'widget.hpp'), + 'class Widget { public: void draw(); };\n' + + 'class Factory { public: auto create() -> Widget; };\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'defs.cpp'), + '#include "decoy.hpp"\n' + + '#include "widget.hpp"\n' + + 'void Decoy::draw() {}\n' + + 'void Widget::draw() {}\n' + + 'auto Factory::create() -> Widget { return Widget(); }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'app.cpp'), + '#include "widget.hpp"\n' + + 'void useTrailing() { Factory::create().draw(); }\n' + ); + + cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const widgetDraw = cg.getNodesByKind('method').find((n) => n.qualifiedName === 'Widget::draw'); + const decoyDraw = cg.getNodesByKind('method').find((n) => n.qualifiedName === 'Decoy::draw'); + expect(widgetDraw, 'Widget::draw node').toBeDefined(); + expect(decoyDraw, 'Decoy::draw node').toBeDefined(); + + // Factory::create() -> Widget, so draw() resolves on Widget, not the + // first-sorted Decoy. + expect(cg.getCallers(widgetDraw!.id).map((c) => c.node.qualifiedName)).toContain('useTrailing'); + expect(cg.getCallers(decoyDraw!.id).map((c) => c.node.qualifiedName)).not.toContain('useTrailing'); + } finally { + cg?.close(); + } + }); + it('bridges a base virtual method to the subclass override', async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cpp-')); fs.writeFileSync( diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 6251fab9a..fadb1a71d 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -1611,6 +1611,11 @@ func main() { expect(inferCppTypeFromInitializer('make_shared(a, b)')).toBe('Widget'); expect(inferCppTypeFromInitializer('std::make_unique()')).toBe('Widget'); }); + it('skips a leading cv-qualifier on the type argument', () => { + expect(inferCppTypeFromInitializer('std::make_unique()')).toBe('Widget'); + expect(inferCppTypeFromInitializer('make_shared()')).toBe('Widget'); + expect(inferCppTypeFromInitializer('static_cast(p)')).toBe('Widget'); + }); it('infers from new expressions', () => { expect(inferCppTypeFromInitializer('new Widget()')).toBe('Widget'); expect(inferCppTypeFromInitializer('new ns::Widget{}')).toBe('Widget'); @@ -1657,6 +1662,10 @@ func main() { expect(parseCppReturnType('() -> unique_ptr')).toBe('Bar'); expect(parseCppReturnType('() -> std::weak_ptr')).toBe('Baz'); }); + it('unwraps a cv-qualified smart-pointer pointee to the type, not the qualifier', () => { + expect(parseCppReturnType('() -> std::shared_ptr')).toBe('Widget'); + expect(parseCppReturnType('() -> unique_ptr')).toBe('Gadget'); + }); it('rejects primitives, void, and missing return types', () => { expect(parseCppReturnType('() -> void')).toBeNull(); expect(parseCppReturnType('(int a) -> int')).toBeNull(); diff --git a/src/extraction/languages/c-cpp.ts b/src/extraction/languages/c-cpp.ts index b8cbd23e1..d4537bfa9 100644 --- a/src/extraction/languages/c-cpp.ts +++ b/src/extraction/languages/c-cpp.ts @@ -58,20 +58,33 @@ function extractCppReceiverType(node: SyntaxNode, source: string): string | unde * field (`Worker`, `void`, `std::shared_ptr`); the `&`/`*` lives on the * declarator and is intentionally dropped (resolution normalizes it away * anyway). Constructors/destructors have no `type` field → params only. + * + * Trailing return types (`auto Factory::create() -> Widget`) are handled too: + * the `type` field is just the `auto` placeholder, so the real type is read + * from the `trailing_return_type` node on the function_declarator instead. */ function extractCppSignature(node: SyntaxNode, source: string): string | undefined { const typeField = getChildByField(node, 'type'); // The parameters live on the function_declarator, which may be wrapped in a // pointer_/reference_declarator (e.g. `Worker& Worker::instance()`). Descend - // to the first function_declarator and read its `parameters` field. + // to the first function_declarator and read its `parameters` field — and any + // trailing return type while we're there. let params: SyntaxNode | null = null; + let trailingReturn: SyntaxNode | null = null; const decl = getChildByField(node, 'declarator'); const queue: SyntaxNode[] = decl ? [decl] : []; while (queue.length > 0) { const current = queue.shift()!; if (current.type === 'function_declarator') { params = getChildByField(current, 'parameters'); + for (let i = 0; i < current.namedChildCount; i++) { + const child = current.namedChild(i); + if (child && child.type === 'trailing_return_type') { + trailingReturn = child; + break; + } + } break; } for (let i = 0; i < current.namedChildCount; i++) { @@ -81,8 +94,16 @@ function extractCppSignature(node: SyntaxNode, source: string): string | undefin } const paramText = params ? getNodeText(params, source) : '()'; - if (typeField) { - return `${paramText} -> ${getNodeText(typeField, source).trim()}`; + // Prefer the trailing return type when present (`-> Widget`); the `type` field + // is then just the `auto`/`decltype(auto)` placeholder. + let returnText: string | null = null; + if (trailingReturn) { + returnText = getNodeText(trailingReturn, source).replace(/^->\s*/, '').trim(); + } else if (typeField) { + returnText = getNodeText(typeField, source).trim(); + } + if (returnText) { + return `${paramText} -> ${returnText}`; } return paramText || undefined; } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index ed57fe89d..7f205dbff 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -274,15 +274,16 @@ export function inferCppTypeFromInitializer(init: string): string | null { if (!s) return null; // make_unique() / make_shared() (with or without a std:: prefix). - let m = s.match(/\bmake_(?:unique|shared)\s*<\s*([A-Za-z_][\w:]*)/); + // Skip a leading cv-qualifier on the type argument (`make_unique`). + let m = s.match(/\bmake_(?:unique|shared)\s*<\s*(?:(?:const|volatile)\s+)*([A-Za-z_][\w:]*)/); if (m) return lastCppSegment(m[1]!); - // new Foo(...) / new Foo<...>(...) / new Foo{...} - m = s.match(/\bnew\s+([A-Za-z_][\w:]*)/); + // new Foo(...) / new Foo<...>(...) / new Foo{...} / new const Foo(...) + m = s.match(/\bnew\s+(?:(?:const|volatile)\s+)*([A-Za-z_][\w:]*)/); if (m) return lastCppSegment(m[1]!); // static_cast(...) / dynamic_cast / reinterpret_cast / const_cast - m = s.match(/\b(?:static|dynamic|reinterpret|const)_cast\s*<\s*([A-Za-z_][\w:]*)/); + m = s.match(/\b(?:static|dynamic|reinterpret|const)_cast\s*<\s*(?:(?:const|volatile)\s+)*([A-Za-z_][\w:]*)/); if (m) return lastCppSegment(m[1]!); // Foo::instance() — self-returning singleton accessor (qualifier IS the type). @@ -321,7 +322,9 @@ const CPP_PRIMITIVE_TYPES: ReadonlySet = new Set([ */ function normalizeCppReturnType(raw: string): string | null { if (!raw) return null; - const smart = raw.match(/\b(?:shared_ptr|unique_ptr|weak_ptr|auto_ptr)\s*<\s*([A-Za-z_][\w:]*)/); + // Unwrap smart pointers, skipping a leading cv-qualifier on the pointee + // (`shared_ptr` → `Widget`, not `const`). + const smart = raw.match(/\b(?:shared_ptr|unique_ptr|weak_ptr|auto_ptr)\s*<\s*(?:(?:const|volatile)\s+)*([A-Za-z_][\w:]*)/); if (smart) { const inner = lastCppSegment(smart[1]!); return inner && !CPP_PRIMITIVE_TYPES.has(inner) ? inner : null;