Skip to content

fix(c,cpp): a struct links the types its members are declared with - #3144

Merged
colbymchenry merged 1 commit into
mainfrom
claude/c-struct-member-refs
Oct 31, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/c-struct-member-refs

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Summary

C and C++ structs linked to nothing through their members. On 1.6.2 many did, by accident, as false extends edges; #2394 was right to remove that label, but left nothing in its place, so impact pause_event on redis-shaped code lost redisServer (2 symbols on 1.6.2, 1 on main). rocksdb lost 4,284 such relations and redis 697: the largest loss found against 1.6.2.

A class, struct or union now has a references edge to each type its members are declared with, and a typedef or using alias has one to what it names. extends does not come back: the type hierarchy is untouched.

Root cause

extractInheritance used to read a C member with a nested declarator as Go's embedding. #2394 gated that to Go. Nothing else read a member's type: C and C++ have no field nodes, and impact never climbs from a member to its container, so a type held by a struct had no incoming edge at all.

Fix

Extraction, wasm and kernel mirrored (cMemberTypeNames / cAliasTypeNames in languages/c-cpp.ts, member_type_names / alias_type_names in ccpp/mod.rs):

  • Every field_declaration with a declarator pushes a references ref from the holder, on the name, like Go's fields (fix(go): a struct links the types its fields are declared with #2655). Members mint no nodes.
  • Read: plain, pointer, reference, array, const, tagged (struct foo *f), enum, union, function pointer (return and parameters), qualified and namespaced names, and template arguments (std::vector<Foo> links Foo).
  • Members in #if groups and in unnamed structs or unions belong to the holder: nothing can name the unnamed type.
  • Not read: builtins, std:: names, the type names C defines itself (_Bool, uintmax_t, ...), the holder's own name (a list's next), decltype/auto, and a nested definition that declares no member (class Rep;).

Why impact needs no change: getImpactRadius follows every incoming edge kind but contains, so references from a holder reach it, and on to the holder's own dependents. It never climbs from a member to its container (#536), which is why the edge is the class's and members mint no nodes.

Resolution (matchCppDeclaredType in cpp-supertypes.ts): the same C++ name lookup base classes and receivers use, through cppClassWritten, widened to enums and stopping at an alias rather than following it. Only type declarations are candidates, so struct stat st; beside int stat(...) or a #define of the same name links nothing. No other strategy sees these refs. Three rules on top:

  • C spreads a type over files: redis's dictEntry is a typedef in src/dict.h and a struct in src/dict.c, while deps/hiredis/dict.h declares its own, the only struct src/dict.h can see by name. What a file declares or includes comes first, and an alias is the class's when a file including it defines it.
  • A type only a source file defines is found for a header it includes (a pimpl's Rep), in a scope the class can reach.
  • Base-class lookup's last resort, the only class of that name anywhere visible, is C-only here. In C++ it linked the system pthread_key_t to rocksdb's port::pthread_key_t.

Explore (src/mcp/tools.ts): these edges take no render window and stay out of the rank walk, for C and C++ only. Each member line had become an edge site, and on rocksdb's read-path query those windows pushed four named symbols out of the output.

Calls for review

  • Method return types are in. Status Put(...); in a class links Status; parameters don't. They are 2,850 of rocksdb's 4,259 recovered sites, so leaving them out would have left two thirds of the loss in place, and a member with a trailing macro, Version* v GUARDED_BY(mu);, parses the same way, so the two can't be told apart.
  • Alias edges go beyond 1.6.2. Without them impact redisObject never reaches what holds a robj *. They're one call in each engine if you want them out: 929 of rocksdb's added edges, 411 of redis's.
  • EXTRACTION_VERSION stays 28. 1.6.2 is 27 and 28 is unreleased, so upgraders are already told to re-index.
  • No equivalence gate needed. §5 of the migration plan gates default-routing a language, and C/C++ is already routed. The parity sweep and the full-index dumps below cover the same ground.

Not covered

  • Objective-C structs stay unlinked, as on main; fix(c,cpp): a pointer, array or function member is not a supertype #2394 removed their extends too. No REG repo covers them.
  • A member typed by a function-pointer typedef, ConnectionCallbackFunc cb;, links nothing: such typedefs index as (*Name), an existing naming bug: 138 member refs on redis, 23 on rocksdb.
  • struct foo_s *x where the only declaration is typedef struct foo_s {...} foo_t;: the tag isn't indexed.
  • A class local to an out-of-line method loses the method's class scope in its qualified name, so nested types of that class aren't found from it: one 1.6.2 site on leveldb.

Validation

Red to green: __tests__/c-cpp-member-type-refs.test.ts, 36 tests on the kernel and wasm, LF and CRLF: 29 fail on main, all pass here. The explore rule's test was also run with the rule off, and fails. Torture fixtures extended.

Fixture cstruct: impact pause_event is 2 symbols on 1.6.2, 1 on main, 2 here. cstruct2: 1.6.2 links 3 of 4 members, this links all 4.

A/B against main. Same base, each arm its own build and kernel, keyed by site. No edge lost, moved or changed metadata on any repo, and no node changed. REG's 1.6.2 indexes supply the member sites.

Repo Edges main → fix Added 1.6.2 member sites Same target Corrected Gone
rocksdb (C++) 331,846 → 341,991 10,145 4,284 4,201 58 25
redis (C) 76,447 → 78,415 1,968 697 610 87 0
leveldb (C++) 10,381 → 10,821 440 246 225 20 1
fmt (C++) 19,564 → 20,186 622 78 65 10 3
jq (C) 7,666 → 7,780 114 18 11 6 1
gin (control) 8,073 → 8,073 0
flask (control) 5,292 → 5,292 0

Control dumps are byte-identical. Corrected means 1.6.2 had the wrong target: rocksdb's Iterator* pointed at WriteThread::WriteGroup::Iterator, Statistics at BlockCacheTier::Statistics, redis's client * at redis-benchmark.c's struct. Gone means no edge is right: on rocksdb, 18 template parameters and 7 classes naming themselves. leveldb's one is the local-class gap above, and fmt's are 2 inline methods and a template parameter.

Every added edge, by class:

Repo Recovered from 1.6.2 New coverage
rocksdb 4,259: method return 2,850, pointer 1,274, reference 105, other 30 5,886: by value 2,794, template argument 1,874, alias 929, method return 115, pointer 100, function-pointer parameter 40, other 34
redis 697: pointer 630, array 57, function-pointer return 10 1,271: by value 605, alias 411, function-pointer parameter 136, pointer 91, macro type 23, array 5
leveldb 245: pointer 142, method return 98, other 5 195: by value 142, template argument 18, pointer 16, alias 11, other 8
fmt 75: method return 36, pointer 28, other 11 547: alias 238, by value 215, template argument 61, other 33
jq 17: pointer 10, function-pointer return 4, array 3 97: by value 59, function-pointer parameter 14, pointer 12, alias 9, array 3

By-value members never linked on 1.6.2, so they are new coverage, not recovered edges.

Reading the new edges. About 500 read against the source, by fixed-seed sample per class on all five repos, by value and template arguments included, with extra weight on names several declarations share and all 85 of rocksdb's fallback matches. That reading found four wrong patterns, all fixed and pinned by tests: rocksdb's system pthread_key_t bound to port::pthread_key_t; a macro-imported Hash bound to a sibling class's; redis's dictEntry in src/dict.h bound to hiredis's copy; jemalloc's MSVC shims taking _Bool and uintmax_t. Nothing wrong remained in the final read. An independent check with its own #include reader then covered all 13,289 added edges: 26 fail it, and all 26 were read and are right: opaque handles a .c file defines, jemalloc tests that reach their types through a generated header, and one #include <ae.h> the checker misread.

Impact (impact <name> -j, default depth):

Symbol 1.6.2 main fix
redis pause_event 3 1 3
redis robj 49 1 72
redis redisObject 1 1 90
redis dict 48 3 103
redis client 29 2 53
leveldb Status 725 425 857
leveldb FileMetaData 49 32 111
rocksdb Iterator 4,386 1,258 5,587
rocksdb ColumnFamilyData 3,071 1,202 2,468
rocksdb FileMetaData 356 316 2,544

rocksdb's ColumnFamilyData stays under 1.6.2, and not through members: every struct 1.6.2 linked to it is linked here. The gap is callers of those structs' methods, like ArenaWrappedDBIter's, with 6,822 calls edges on 1.6.2 and 74 here, which main has re-resolved since. rocksdb's Status can't be compared (18,656 on 1.6.2, 10,334 here): impact now stops at its safety budget of 10,000 nodes, and 1.6.2 had none.

Kernel: scripts/kernel-parity.mjs: 0 diffs on rocksdb (1,196 of 1,413 files compared, 217 deferred), redis, leveldb, fmt and jq. With CODEGRAPH_KERNEL_CCPP_ERROR_EXTRACT=1, the files that differ are the same on main and here (redis 18, leveldb 2). Full-index dump-graph wasm vs kernel is byte-identical on redis, leveldb, fmt and jq. A sync after editing headers equals a fresh index on leveldb and jq.

Explore: 22 queries on the five repos, flows and type questions, on main and here: every Flow section unchanged, and the same 68 of 72 named symbols shown on both. Budgets are untouched and output stays under the cap. 5 queries render a different file set: rocksdb's flush query swaps a Java file for db/flush_job.h and listener.h, redis's command path moves sds.c to "Not shown". Type questions gain the holders: pause_event now shows redisServer.

Index cost, median of 3, interleaved, kernel path:

Repo Wall CPU Peak memory Database
rocksdb 52.9 → 49.2 s (noise) 170.6 → 176.4 s (+3.4%) 4,357 → 4,440 MB 274.3 → 281.3 MB (+2.6%)
redis 10.3 → 9.5 s (noise) 30.0 → 30.3 s (+0.9%) 1,573 → 1,607 MB 65.5 → 66.6 MB
leveldb 3.03 → 3.27 s 5.53 → 5.95 s (+7.6%) 673 → 675 MB 8.3 → 8.5 MB
fmt 6.27 → 6.58 s 20.7 → 22.0 s (+6.3%) 1,122 → 1,137 MB 17.8 → 18.3 MB

Unresolved rows grow 3.6% on rocksdb, nearly all gtest's T1…T50, which name no type.

Suite: CODEGRAPH_KERNEL_EXPECT=1 npx vitest run --maxWorkers=4 on Vitest 4, on this commit: 846 files passed, 6 skipped; 10,503 tests passed, 2 expected failures, 87 skipped, 0 failed.

🤖 Generated with Claude Code

C and C++ structs referenced nothing through their members. 1.6.2 linked
many of them by accident, as false `extends` edges; PR #2394 was right to
remove that label but left nothing in its place, so impact on a type missed
everything that holds one. On redis `impact pause_event` went from 2 symbols
to 1, and rocksdb lost 4,284 such relations, redis 697: the largest loss
against 1.6.2.

Extraction, wasm extractor and kernel mirrored: a class, struct or union
pushes a `references` ref, on the name, for each type its members are
declared with: plain, pointer, reference, array, tagged, enum, union,
function pointer (return and parameters), qualified, and template arguments,
so `std::vector<Foo>` links `Foo`. Members mint no nodes, like Go's fields
(PR #2655). Members in `#if` groups and in unnamed structs and unions are the
holder's. A method's return type counts, its parameters don't: a member a
macro trails (`Version* v GUARDED_BY(mu);`) parses the same way. Not read:
builtins, `std::` names, the type names C defines itself, the holder's own
name, `decltype` and `auto`. An alias references what it names, so a change
to `redisObject` reaches what holds a `robj *`.

Resolution: the C++ name lookup base classes and receivers use, widened to
enums and stopping at an alias. Only type declarations are candidates, so a
function or macro of that name links nothing. In C, where one type spreads
over files, what the file declares or includes comes first and an alias is
the class's when a file including it defines it, and a type only a source
file defines is found for a header it includes.

Explore: these edges take no render window and stay out of the rank walk.
With them in, rocksdb's read-path query lost four named symbols to member
lines of its headers.

Validation against main: redis +1,968 edges, rocksdb +10,145, leveldb +440,
fmt +622, jq +114, 0 lost or moved, nodes identical; gin and flask dumps
byte-identical. Of 1.6.2's member sites, redis 697: 610 same target, 87
corrected; rocksdb 4,284: 4,201 same, 58 corrected, 25 rightly gone (the
class itself, a template parameter). By-value members, 2,794 on rocksdb, are
new coverage. Kernel parity 0 diffs on all five repos, and full-index dumps
wasm vs kernel byte-identical on redis, leveldb, fmt and jq. Index cost on
rocksdb, median of 3 interleaved: CPU +3.4%, peak memory +1.9%, database
+2.6%, wall within noise.

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