Skip to content

fix(cpp): a member declared in an anonymous union, like protobuf's union { Impl_ _impl_; };, is found as a member of the class around it - #2731

Merged
colbymchenry merged 1 commit into
mainfrom
claude/cpp-member-chains-4
Oct 21, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/cpp-member-chains-4

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Summary

C++ looks up the members of an anonymous union or struct as members of the class it is declared in ([class.union.anon]). codegraph reads a class's data members from its own lines, skipping those of the functions and classes nested in it. The index keeps an anonymous union as a nested <anonymous> node, so its lines were skipped too.

Every generated protobuf message keeps its fields in union { Impl_ _impl_; };, so calls through _impl_ were guessed by the method's name:

  • this_._impl_.type_url_.Destroy() reached Arena::Destroy;
  • _impl_._cached_size_.Get() reached FieldArenaRep::Get;
  • _impl_._has_bits_.Clear() reached the message's own Clear.

fmt's value keeps its argument in a union of string, custom and the rest in the same way.

This is the first PR of follow-up 1 from #2628 (C++ member chains, step 4).

Why this one: re-verified on main 81fd007

A temporary, log-only diagnostic on main recorded every member-chain call and its outcome. For each call left to the name strategies, it also recorded the first hop that couldn't be typed. Its dumps were byte-identical to an uninstrumented baseline.

repo chain calls linked linked to nothing left to bare-name guessing
rocksdb 9,749 5,461 3,638 650
protobuf 2,121 477 280 1,364
leveldb 236 214 15 7
fmt 111 76 12 23

Most of protobuf's largest buckets were _impl_:

  • 402 untyped bases, whose declaration the scan found inside the union;
  • 16 bases found nowhere;
  • X::_impl_ as most of the 265 members not found.

Fix

cppNestedSpans leaves out an anonymous union or struct that has no name and no declarator (isCppAnonymousMembers): its head is a bare union { or struct {, and }; follows its closing brace. Two cases keep their members to themselves:

  • a union with a declarator (union { … } u;);
  • a partial specialization. The index also keeps it as <anonymous>, but its head names its template.

Validation

Kit A/B against main 81fd007 (leveldb, fmt, protobuf, rocksdb, redis):

repo removed added retargeted same target, now typed
protobuf 2 54 349 231
fmt 0 0 1 1
rocksdb 0 0 2 0
leveldb, redis identical
  • Retargeted: from bare-name guesses to the member's class:
    • ArenaAlign::IsDefault, FieldArenaRep::Get, Arena::Destroy and Formatter::Set → ArenaStringPtr;
    • FieldArenaRep::Get → CachedSize::Get;
    • the message's own Clear → HasBits::Clear or RepeatedPtrField::Clear;
    • Any::GetCachedSize/MergeFrom → SourceContext through source_context_;
    • Any::ByteSizeLong/New → MessageLite through ptr.message_value.
  • Removed: both are ptr.string_value->clear() on a std::string*, which had reached Map::clear.
  • Added: typed RepeatedPtrField and UntypedMapBase calls through _impl_ and map_, and sub.GetCachedSize() on locals typed through _impl_.
  • fmt and rocksdb: their retargets pick the overload their argument fits, now that a union member has a type:
    • map(value_.uint128_value) takes native_uint128, not native_int128;
    • Add(…, props.oldest_ancestor_time) takes the uint64_t overload;
    • AppendProperty(…, oldest_ancestor_time, …) takes the template, not the const std::string& one.
  • Hand check: about 45 changed sites across every group were all right.

Tests. __tests__/cpp-anonymous-union-members.test.ts has 5 tests:

  • an out-of-class inline method;
  • an in-class method reading a union member declared below it;
  • a this_._impl_.… chain through a reference parameter;
  • fmt's shape;
  • a control: a union with a declarator, a named nested struct and a partial specialization keep their members to themselves.

The first four fail on main, with each namesake placed where the bare-name guess lands on it. The control passes on both.

Mutations:

  • Killed: dropping the nested-span exclusion, the declarator check or the head check.
  • Survived: dropping the <anonymous> name check. The head check already covers named types; the name check stays as a cheap early exit.
  • Dropped from the change: a matching edit to the caller's-declaration check survived too, and with it removed the kit A/B was byte-identical, so it isn't in this PR.

Cost: protobuf was indexed four times, alternating main and fix: 117 s and 130 s on main, 120 s and 129 s with the fix. That is the same within noise. leveldb and redis stay byte-identical.

Full suite (--maxWorkers=4 --minWorkers=1, kernel built, CODEGRAPH_KERNEL_EXPECT=1): 677 files and 8,115 tests passed, 6 files and 82 tests were skipped, and none failed.

CHANGELOG: one bullet under [Unreleased] → ### Fixes, placed next to the member-chain bullet. Re-index C++ projects after upgrading.

Left for follow-up 1's next PRs

From the same diagnosis, before this PR:

  • pimpl Rep (rocksdb 103, leveldb 7): the member's type is looked up from the header, where Rep is only forward-declared. This is the next candidate.
  • classes the index doesn't hold (protobuf): descriptor.pb.h is over the 1 MiB cap. That accounts for about 470 unplaced hops, 84 bases in its methods and 19 final types.
  • assorted rocksdb shapes:
    • untyped bases, mostly unreadable auto and structured bindings (224);
    • members whose type spans several lines (TraceStats, 38);
    • iterator classes reached through operator-> (60), which belong to follow-up 2.

🤖 Generated with Claude Code

…nion { Impl_ _impl_; };`, is found as a member of the class around it

A class's data members are read from its own lines, skipping those of
the functions and classes nested in it. The index keeps an anonymous
union or struct as a nested `<anonymous>` node, so its lines were skipped
too, though C++ looks its members up as the enclosing class's
([class.union.anon]).

Every generated protobuf message keeps its fields in `union { Impl_
_impl_; };`, so a call through `_impl_` was guessed by the method's name:
- `this_._impl_.type_url_.Destroy()` reached `Arena::Destroy`;
- `_impl_._cached_size_.Get()` reached `FieldArenaRep::Get`;
- `_impl_._has_bits_.Clear()` reached the message's own `Clear`.

fmt's `value` keeps its argument in a union of `string`, `custom` and the
rest in the same way.

cppNestedSpans now leaves out an anonymous union or struct with no name
and no declarator (isCppAnonymousMembers):
- a bare `union {` or `struct {` head;
- `};` after the closing brace.
A union with a declarator, `union { … } u;`, keeps its members to itself,
and so does a partial specialization, which the index also keeps as
`<anonymous>` but whose head names its template.

A member-chain diagnostic on main 81fd007 found the anonymous union
behind most of protobuf's untyped chains. Kit A/B against 81fd007:

| repo | removed | added | retargeted | same target, now typed |
|---|---|---|---|---|
| protobuf | 2 | 54 | 349 | 231 |
| fmt | 0 | 0 | 1 | 1 |
| rocksdb | 0 | 0 | 2 | 0 |
| leveldb, redis | identical | | | |

- Retargets go from bare-name guesses to the member's class:
  ArenaStringPtr (Set, Get, IsDefault, Destroy), CachedSize::Get,
  HasBits::Clear, RepeatedPtrField, SourceContext and MessageLite.
- The 2 removals are `ptr.string_value->clear()` on a `std::string*`, which
  had reached `Map::clear`.
- fmt's and rocksdb's retargets pick the overload their argument fits now
  that a union member has a type: `map(value_.uint128_value)` takes
  `native_uint128`, and `Add(…, props.oldest_ancestor_time)` the `uint64_t`
  overload.
- About 45 changed sites read by hand were all right.

Mutations: dropping the nested-span exclusion, the declarator check or
the head check each fail the new tests. Dropping the name check survives,
because the head check covers named types; it stays as a cheap early exit.
A matching change to the caller's-declaration check left the kit A/B
byte-identical, so it was not kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant