fix(c,cpp): a struct, class, union or enum defined in a variable declaration is indexed
Problem
A class, struct, union or enum defined in the type of a C or C++ variable declaration was never indexed, by either engine:
// decl.cpp
namespace n {
struct Foo { int a; void f() {} } foo;
class Bar { void g() {} } bar, *pbar;
}
// main: only `variable n::foo` and `variable n::bar`; Foo, Bar, f and g are missing
// decl.c
struct Foo { int a; } foo;
static struct { int b; } anon_cfg;
enum { MODE_A, MODE_B } mode;
// main: nothing at all, not even MODE_A / MODE_B
The walker's declaration branch (variableTypes) hands the declaration to extractVariable and skips its children, so a class-like type with a body is never visited. The kernel's kind == "declaration" && !inside_class_like() branch mirrors it. Only declarations outside function and class bodies are affected, since inside a body the children walk already reaches the type. #2426 (now on main) added this walk for C++ files whose tree has errors, in brace scopes. Clean files, and C, were still open.
Fix
Both engines (src/extraction/tree-sitter.ts + languages/c-cpp.ts, mirrored in codegraph-kernel/src/ccpp/mod.rs):
- The type is walked before the variables, and the variables keep their nodes (C++: the generic fallback's
n::fooandn::bar; C: init, pointer and array declarators, as before). One walk covers every case: C always, and C++ when the tree is clean or the file is walked in #2426's brace scopes. The only file left out is an erroring C++ file whose braces don't balance (no brace scopes), which #2426 also leaves alone. There, error recovery can run a class past its own}, and the walked classes would land in the wrong scope (measured below). - An unnamed type takes the name of the first variable its declaration declares.
static struct { … } SPT;givesstruct SPT. This also applies inside function bodies, where such types were indexed as<anonymous>until now. It goes through the c/cppresolveNamehook, mirrored in the kernel'sextract_name_raw. - The comment above the declaration documents the type, as it already documents the variable (a new c/cpp
getDeclarationWrapper, mirrored byclass_like_docstring)./** The handler registry. */ struct Registry { … } registry;gives both nodes that docstring. - The declaration's function-as-value scan skips the walked type, which captured its own candidates with the type on the stack. So a function value in the type's body (a C++ member default like
void (*hs[1])() = {&on_open};) is captured once, by the type, not a second time from the file. Main's brace-scope walk captured those twice. cDeclaratorIdentifiermoves fromtree-sitter.tstolanguages/c-cpp.ts, where the naming rule shares it, and #2426'sCPP_CLASS_LIKE_SPECIFIERScheck becomesisClassLikeDefinitionthere.
Naming an unnamed type
No code can name the type of static struct { … } SPT;; it only ever names SPT. So the struct is indexed under that name, which is the rule typedef struct { … } Name; already follows. Before, an agent searching for SPT (redis's setproctitle.c) or rdbstate (redis-check-rdb.c) found nothing: C doesn't index a variable with a bare declarator (the macro-prototype rule in extractVariable, unchanged here), and <anonymous> isn't searchable. Now codegraph_explore on SPT returns the struct and its fields.
What stays <anonymous>: a type whose declaration declares no variable, like a function's return type (struct { int x; } make(void);), and the unnamed types of class members (field_declaration), which this PR doesn't touch.
Alternatives considered:
<anonymous>everywhere: consistent, but unsearchable, which defeats the redis-style idiom.- A synthetic name like ctags'
__anonNNN: also unsearchable. - No node, with the members hoisted to the enclosing scope: loses the type's body, which is what an agent wants to read.
One side effect: in fmt, struct { … operator()(…) … } enter_state; is a local functor, so a call enter_state(state::align) now links to the struct named enter_state. The edge is instantiates, because its target is a type. That is the right code, though a call to its operator() would be the more precise edge. Before, those calls linked to nothing.
Verification
-
New
__tests__/c-cpp-declaration-types.test.ts: 29 tests, on the kernel and on wasm, with LF and CRLF, plus an end-to-end index. It covers:- the issue's repro in both languages;
- unnamed types named after their variable (pointer, array and initialized declarators);
- a function prototype's type staying
<anonymous>; - the docstring;
- a function-local type;
- a single function-value capture;
- erroring files: C kept, C++ in brace scopes kept, C++ with unbalanced braces not walked;
- on an indexed project,
Registry::sizegets its callers,Worker extends Baseresolves, andsearchNodes('SPT')finds the struct.
All 29 fail on main's walker and main's kernel (ed199e60).
-
torture.candtorture.cppgain the same shapes, so the C/C++ kernel-parity suite keeps the two engines in agreement on them. -
scripts/kernel-parity.mjs --lang c,cppon jq, leveldb, redis, fmt, protobuf and rocksdb: 0 files with diffs, and deferral counts are unchanged. UnderCODEGRAPH_KERNEL_CCPP_ERROR_EXTRACT=1, the set of files where the engines disagree is identical for main and the fix in all six repos (5 / 4 / 19 / 9 / 56 / 11). -
npx tsc --noEmit,npm run build,bash scripts/build-kernel.sh. Full suite: passes. The parallel run on this shared box (100% CPU from other sessions) had 109 failures in 39 files, all timing: sync, git, daemon and MCP tests hitting 5 s timeouts orEBUSYon teardown, plus the timed-out cases ofextraction,resolutionandcpp-macro-and-constructor-calls. Rerun serially with 120 s timeouts, those 39 files passed 1634 of 1635. The last one,function-ref's #1820 case, has its own 60 s limit and passed alone in 27 s. Every C/C++ suite passed in the parallel run too, this PR's 29 tests included.
Validation: before and after on real repos (main = ed199e60)
Each repo was indexed with main and with this branch, both engines as shipped (kernel for clean C/C++ files, wasm for the rest). The graphs were diffed by node, and by edge keyed by call site.
| repo | nodes | edges | edge sites that changed |
|---|---|---|---|
| jq | 1846 → 1846 | 7449 → 7449 | 1 contains (rename) |
| leveldb | 3094 → 3094 | 10116 → 10116 | 1 contains (rename) |
| redis | 21940 → 21959 | 76391 → 76410 | 36 contains retargeted; 1 removed + 1 added (a renamed enum's members) |
| fmt | 8321 → 8326 | 19057 → 19082 | +20 instantiates; contains +3 / −2 / 2 retargeted; 1 confidence change |
| rocksdb | 72562 → 72564 | 308586 → 308590 | +2 calls; contains +1 / 4 retargeted |
| protobuf | 118001 → 118005 | 376623 → 376629 | +2 calls; contains +4 / −3 / 7 retargeted |
No calls, extends, instantiates or references edge was removed or retargeted in any repo. Every removed site is a contains edge whose source was renamed from <anonymous>; the same edge reappears under the new name.
Types indexed for the first time:
- redis (19):
sentinelState(sentinel.c),luaCtxandldbState(eval.c),SPT,rdbstate,redis-cli'sconfig,prefandclusterManager,redis-benchmark'sconfig,RedisModuleForkInfo,redisTest, the ACL tablesACLCategoryItem,ACLUserFlagandACLSelectorFlags,ExprOptable, and indeps/: lua'spriority,native, and tre'stre_macro_structandtre_ctype_map. - fmt: the enum
fstat_simwith its three enumerators, andtest_mock(posix-mock-test.cc). - rocksdb:
StaticDestructionTesterand its destructor (env_test.cc). Its calls toEnv::Default()andGetHostNameStringnow resolve correctly. - protobuf:
ForceShutdownand its destructor (googletest.cc), whose two calls toShutdownProtobufLibraryresolve correctly, and the two utf8_rangeftabstructs.
Renamed from <anonymous>, all function-local:
- jq 1 (
res) and leveldb 1 (cases); - redis 25: 8 structs, 16 unions, and 1 enum whose 37 enumerators are re-qualified;
- fmt 2 functor structs (
enter_state,parse_presentation_type, with theiroperator()); - rocksdb 3;
- protobuf 4: 3 enums with 11 enumerators, and 1 union.
Triage of the rest:
- fmt's 20
instantiates: the functor calls described above. Each now reaches the struct that holds theoperator()that runs; before, nothing. - fmt's confidence change:
max_size()inmax_size_allocator::allocatestill links tomax_size_allocator::max_size, but at 0.4 instead of 0.9. The newly indexed enum valuefstat_sim::max_size(posix-mock-test.cc) shares its name, and exact-match still counts an enum value as a candidate for a call (pre-existing). The target is unchanged.
These site diffs are identical, as sets, to the same A/B run on this branch's original base (d8a7f868), before #2426, #2413 and #2415 landed.
Re-run on main cdb994b4 (after #2449 changed the C/C++ preParse, and #2430 and #2442 landed): the changed sites are identical, as sets, in all six repos. Parity is still 0 diffs, with fewer files deferred now that more of them parse cleanly: leveldb 9, redis 176, fmt 31, protobuf 292 and rocksdb 214, against 20, 186, 32, 343 and 223 before. This PR's tests, kernel-ccpp-parity, cpp-brace-scopes and #2449's cpp-attribute-macros pass on the merged tree (100/100, with the #2430 and #2442 test files).
Erroring C++ files. Before merging #2426, I measured an unconditional walk with no brace scopes. In leveldb's util/env_posix.cc, error recovery runs class Limiter to the end of the file (an #if inside its constructor's initializer list). That file gained 8 classes and 57 methods, all nested under leveldb::Limiter::. Two protobuf types also landed in a truncated namespace (google::ConstMapIterator). That is why an erroring C++ file without brace scopes is left alone. With #2426, main already indexes those classes in their correct scope, and this branch leaves them unchanged.
Overlap
-
#2426 (merged) added the same walk for erroring C++ files in brace scopes. This branch merged main and converged on one walk:
- the brace-scope case still walks exactly where it did;
- #2426's
CPP_CLASS_LIKE_SPECIFIERScheck becameisClassLikeDefinition; - its function-value scan now skips the walked type too.
cpp-brace-scopes.test.tspasses unchanged. -
#2449 (merged) rewrote much of the C++ preParse in
languages/c-cpp.ts, which this PR also edits. The two merged without conflict, and the re-run above shows no interaction beyond more files parsing cleanly.
Follow-ups (not in this PR)
- Objective-C has the same gap:
objc.tsalso listsdeclarationinvariableTypes, and the gate here is C/C++ only. - Unnamed types of class members (
union { … } u;inside a struct) are still<anonymous>. The same naming rule could apply to them, but it renames many existing nodes (89 in redis alone), so it deserves its own change. - A call through a functor object (
enter_state(x)) could link to the type'soperator()as a call, instead of to the type as an instantiation. - C still skips a variable with a bare declarator:
static struct { … } SPT;has novariable SPT, by the existing macro-prototype rule. The struct now carries the name.
🤖 Generated with Claude Code