Skip to content

fix(cpp): resolve singleton/factory/chained calls via return types#646

Open
stabey wants to merge 6 commits into
colbymchenry:mainfrom
stabey:fix/cpp-receiver-type-resolution
Open

fix(cpp): resolve singleton/factory/chained calls via return types#646
stabey wants to merge 6 commits into
colbymchenry:mainfrom
stabey:fix/cpp-receiver-type-resolution

Conversation

@stabey
Copy link
Copy Markdown

@stabey stabey commented Jun 2, 2026

Fixes #645.

Problem

In C++, a method call whose receiver is another call's result lost the
receiver's type during extraction and degraded to a bare method name. When two
classes shared a method name, the call silently resolved to whichever class was
indexed first — or didn't resolve at all. This corrupted callers, callees,
impact, and trace for the most common C++ idioms (singletons, factories,
chained getters), and did so silently with a plausible-looking wrong edge.

Approach

Resolve the receiver by what the inner call returns. This required first
capturing C++ return types, which weren't extracted at all before.

  • Extraction — capture C++ return types into method/function signatures
    (extractCppSignature, emitted as (params) -> ReturnType; base type from the
    type field, smart-pointer template arg preserved). Encode chained-call
    receivers as a resolvable reference (Recv().method, obj.getThing().method)
    instead of dropping them to a bare name.
  • ResolutionmatchCppChainedAccessor resolves those by the callee's
    return type, with a self-returning-accessor-name fallback when the return
    type isn't indexed (e.g. an external accessor). auto locals are inferred from
    new / std::make_unique / std::make_shared / casts / direct construction /
    named accessors / the initializer call's return type. Single-level member
    chains resolve the object's type, then the chained method's return type.

Covered

  • Singletons / self-returning accessors: Foo::instance().bar(),
    Foo::getInstance()->bar() (any accessor name, not just instance).
  • Factories returning a different type: WidgetFactory::create().draw()
    resolves on Widget, not WidgetFactory.
  • Free-function factories: openSession()->run().
  • The same patterns stored in an auto local first, plus new /
    std::make_unique / std::make_shared / casts / direct construction.
  • Single-level member chains: manager.view().render().

Deliberately out of scope

Need a real type environment (symbol tables + overload resolution by argument
types), not a heuristic — left uncovered (silent, not wrong):

  • Deep chains a().b().c().
  • Multi-level member access h.mgr.view().render().
  • Overload-correct selection, typedef/using alias resolution, templated
    return types, inherited methods.

Safety

Every inferred type is validated against the graph (the class must actually have
the method) before an edge is created, so a wrong guess falls through silently
rather than producing a wrong edge.

Testing

  • New end-to-end tests in frameworks-integration.test.ts for the singleton,
    factory-returns-other-class, oddly-named-accessor, free-function-factory, and
    member-chain cases — each with a same-named decoy class that sorts first, so a
    green test proves resolution was driven by type, not indexing order.
  • New unit tests in resolution.test.ts for inferCppTypeFromInitializer and
    parseCppReturnType (tier boundaries, smart-pointer unwrap, primitive/void
    rejection).
  • Node count stable across re-index (no node explosion).
  • Full suite green (1110 passed / 2 skipped).

🤖 Generated with Claude Code

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cb22d8f0e6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/resolution/name-matcher.ts Outdated
if (last) {
candidates = context
.getNodesByName(last)
.filter((n) => n.qualifiedName.endsWith(callee));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require a separator for scoped return lookups

When the extractor encodes a namespaced chained call as Foo::create().draw, this fallback also accepts owners whose names merely end with Foo, e.g. ns::OtherFoo::create satisfies endsWith('Foo::create'). If that method is indexed before the real ns::Foo::create and returns a different type, the new return-type path resolves the following call to the wrong class; the suffix check needs to require an exact match or a :: boundary.

Useful? React with 👍 / 👎.

Comment thread src/resolution/name-matcher.ts Outdated
(n) =>
n.kind === 'method' &&
n.language === 'cpp' &&
n.qualifiedName.endsWith(suffix),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require a separator when matching owner types

For member-chain inference such as Manager m; m.view().render(), this suffix check also matches unrelated classes whose names end with the requested type (OtherManager::view ends with Manager::view). If such a method is indexed first and has a different return type, the newly added chained/auto resolver will infer that wrong type and misroute the final method call; this should use an exact match or a ::${suffix} boundary like resolveMethodOnType does.

Useful? React with 👍 / 👎.

stabey added a commit to stabey/codegraph that referenced this pull request Jun 2, 2026
…mchenry#645)

Addresses Codex review on colbymchenry#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 `::<suffix>`), 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 <noreply@anthropic.com>
@stabey
Copy link
Copy Markdown
Author

stabey commented Jun 2, 2026

Thanks @codex — both P2 findings were valid. The return-type lookups (cppReturnTypeOf namespace fallback and cppReturnTypeOfMethodOnType member-chain) matched qualified names 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.

Fixed in e424e31: both now match at a :: boundary (exact or ::<suffix>), mirroring resolveMethodOnType. Added a regression test with an OtherManager decoy that sorts first — verified red before the fix, green after.

@chatgpt-codex-connector
Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e424e3170d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/resolution/name-matcher.ts Outdated
Comment on lines +367 to +368
.getNodesByName(callee)
.filter((n) => n.kind === 'function' || n.kind === 'method');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Filter bare factory lookups to free functions

When callee has no ::, extraction represents an unqualified call like makeWidget()->draw() as makeWidget().draw, so this branch is resolving a bare callee name. Including every method with that name lets an unrelated class method supply the return type; if Decoy::makeWidget() is indexed before the real free function makeWidget(), cppReturnTypeOf returns Decoy and the final draw call is linked to Decoy::draw. Restrict bare lookups to free functions, or otherwise scope unqualified member calls to the caller's class before using a method signature as the receiver type.

Useful? React with 👍 / 👎.

stabey and others added 3 commits June 2, 2026 23:27
…olbymchenry#645)

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 <noreply@anthropic.com>
…mchenry#645)

Addresses Codex review on colbymchenry#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 `::<suffix>`), 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 <noreply@anthropic.com>
…olbymchenry#645)

Addresses a third Codex finding on colbymchenry#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 <noreply@anthropic.com>
@stabey stabey force-pushed the fix/cpp-receiver-type-resolution branch from e424e31 to 261ada4 Compare June 2, 2026 15:30
@stabey
Copy link
Copy Markdown
Author

stabey commented Jun 2, 2026

Rebased onto latest main (resolving the conflict with #647 — dropped this PR's CHANGELOG entry so it no longer touches that file) and addressed the third finding.

The bare-callee branch of cppReturnTypeOf (used for makeWidget()->draw()-style unqualified chained calls) included same-named methods, so an unrelated Decoy::makeWidget indexed first could supply the return type and misroute the call. A bare unqualified call is a free function, so the lookup is now restricted to kind === 'function'. Added a regression test with a same-named method that sorts first (red before, green after). Full suite green (1126 passed).

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 261ada4177

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/extraction/tree-sitter.ts Outdated
Comment on lines +1736 to +1737
const klass = parts[parts.length - 2]!;
return `${klass}::${accessor}().${methodName}`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve namespaces for chained scoped calls

When the inner receiver call is explicitly namespaced, this keeps only the segment before the accessor, so a::Factory::create().draw() and b::Factory::create().draw() are both emitted as Factory::create().draw. In the chained-call resolver this is then suffix-matched against any ::Factory::create and the first captured return type is used, which can link the final method to the wrong class whenever the same factory class name exists in multiple namespaces. Encode the full qualifier before the accessor instead of only klass.

Useful? React with 👍 / 👎.

)

Addresses a fourth Codex finding on colbymchenry#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 <noreply@anthropic.com>
@stabey
Copy link
Copy Markdown
Author

stabey commented Jun 3, 2026

Addressed the namespace finding (4th Codex comment).

The chained scoped-call encoding kept only the segment before the accessor, so a::Factory::create() and b::Factory::create() collapsed to the same Factory::create. Now the full qualifier is encoded (a::Factory::create().draw), and the return-type lookup matches most-specific first (exact qualified name → full-qualifier :: suffix → Class::accessor) and bails when matches disagree on the return type, so an ambiguous class name across namespaces resolves to the right one (or stays silent) rather than guessing. The accessor-name fallback now keeps the namespace in the owner type too. Added a regression test with a Factory class shared across two namespaces. Full suite green (1127 passed).

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3a6fc078a2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +326 to +330
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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve namespaces in parsed C++ return types

When a scoped factory returns a namespaced type whose basename is shared, this normalization drops the namespace before resolveMethodOnType runs. For example, a::Factory::create() returning a::Widget and b::Factory::create() returning b::Widget both become Widget, so a::Factory::create().draw() can be linked to whichever ::Widget::draw was indexed first rather than the a::Widget method; keep the qualified return type through the chained-call resolver or otherwise use it to disambiguate the target class.

Useful? React with 👍 / 👎.

…ces (colbymchenry#645)

Addresses a fifth Codex finding on colbymchenry#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 <noreply@anthropic.com>
@stabey
Copy link
Copy Markdown
Author

stabey commented Jun 3, 2026

Addressed the 5th finding (ambiguous return-type basename across namespaces).

The return type is normalized to a bare basename, so a::Factory::create() returning a::Widget and a separate b::Widget collapse to Widget. The return-type-driven chained resolutions now go through a wrapper that bails when the basename has more than one owner across namespaces (overloads on the same owner still resolve normally) — so it stays silent rather than linking to whichever ::Widget::draw was indexed first. Added a regression test (verified red before the guard, green after). Full suite green (1128 passed).

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4dd64080ae

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/extraction/languages/c-cpp.ts Outdated

const paramText = params ? getNodeText(params, source) : '()';
if (typeField) {
return `${paramText} -> ${getNodeText(typeField, source).trim()}`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Read trailing return types instead of auto

When a C++ factory/accessor is written with a trailing return type, e.g. auto Factory::create() -> Widget, the function type field is just auto and the real Widget return type lives on the function declarator's trailing return node. Storing the signature as () -> auto makes parseCppReturnType reject it, so the new return-type resolver cannot resolve Factory::create().draw() or auto w = Factory::create(); w.draw() even though the return type is syntactically available.

Useful? React with 👍 / 👎.

Comment thread src/resolution/name-matcher.ts Outdated
*/
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:]*)/);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Unwrap cv-qualified smart-pointer pointees

For smart-pointer return types with a leading cv-qualified pointee such as std::shared_ptr<const Widget>, this regex captures const as the pointee name instead of Widget. The chained-call path then looks for methods on a type named const and leaves calls like Factory::create()->draw() unresolved, despite the new resolver explicitly supporting smart-pointer factory returns.

Useful? React with 👍 / 👎.

… pointees (colbymchenry#645)

Addresses two Codex findings on colbymchenry#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<const Widget>` 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 <noreply@anthropic.com>
@stabey
Copy link
Copy Markdown
Author

stabey commented Jun 3, 2026

Addressed both findings from the latest review — both common modern-C++ syntax, so good catches:

  • Trailing return types (auto Factory::create() -> Widget): the type field is just auto; the real type lives on the declarator's trailing-return node. Extraction now reads that, so the signature is () -> Widget and Factory::create().draw() / the auto forms resolve instead of hitting () -> auto.
  • cv-qualified smart-pointer pointees (std::shared_ptr<const Widget>): the unwrap regex matched const as the type. The smart-pointer, make_*, and cast patterns now skip a leading const/volatile and capture Widget.

Added unit tests (parseCppReturnType, inferCppTypeFromInitializer) and an end-to-end trailing-return test. Full suite green (1131 passed).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

C++: method calls through singletons, factories, and chained getters resolve to the wrong class (or not at all)

1 participant