fix(c,cpp): a struct, class, union or enum defined in a variable declaration is indexed

Merged
CO
8d ago
+495-68

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::foo and n::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; gives struct SPT. This also applies inside function bodies, where such types were indexed as <anonymous> until now. It goes through the c/cpp resolveName hook, mirrored in the kernel's extract_name_raw.
  • The comment above the declaration documents the type, as it already documents the variable (a new c/cpp getDeclarationWrapper, mirrored by class_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.
  • cDeclaratorIdentifier moves from tree-sitter.ts to languages/c-cpp.ts, where the naming rule shares it, and #2426's CPP_CLASS_LIKE_SPECIFIERS check becomes isClassLikeDefinition there.

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::size gets its callers, Worker extends Base resolves, and searchNodes('SPT') finds the struct.

    All 29 fail on main's walker and main's kernel (ed199e60).

  • torture.c and torture.cpp gain the same shapes, so the C/C++ kernel-parity suite keeps the two engines in agreement on them.

  • scripts/kernel-parity.mjs --lang c,cpp on jq, leveldb, redis, fmt, protobuf and rocksdb: 0 files with diffs, and deferral counts are unchanged. Under CODEGRAPH_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 or EBUSY on teardown, plus the timed-out cases of extraction, resolution and cpp-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.

reponodesedgesedge sites that changed
jq1846 → 18467449 → 74491 contains (rename)
leveldb3094 → 309410116 → 101161 contains (rename)
redis21940 → 2195976391 → 7641036 contains retargeted; 1 removed + 1 added (a renamed enum's members)
fmt8321 → 832619057 → 19082+20 instantiates; contains +3 / −2 / 2 retargeted; 1 confidence change
rocksdb72562 → 72564308586 → 308590+2 calls; contains +1 / 4 retargeted
protobuf118001 → 118005376623 → 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), luaCtx and ldbState (eval.c), SPT, rdbstate, redis-cli's config, pref and clusterManager, redis-benchmark's config, RedisModuleForkInfo, redisTest, the ACL tables ACLCategoryItem, ACLUserFlag and ACLSelectorFlags, ExprOptable, and in deps/: lua's priority, native, and tre's tre_macro_struct and tre_ctype_map.
  • fmt: the enum fstat_sim with its three enumerators, and test_mock (posix-mock-test.cc).
  • rocksdb: StaticDestructionTester and its destructor (env_test.cc). Its calls to Env::Default() and GetHostNameString now resolve correctly.
  • protobuf: ForceShutdown and its destructor (googletest.cc), whose two calls to ShutdownProtobufLibrary resolve correctly, and the two utf8_range ftab structs.

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 their operator());
  • 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 the operator() that runs; before, nothing.
  • fmt's confidence change: max_size() in max_size_allocator::allocate still links to max_size_allocator::max_size, but at 0.4 instead of 0.9. The newly indexed enum value fstat_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_SPECIFIERS check became isClassLikeDefinition;
    • its function-value scan now skips the walked type too.

    cpp-brace-scopes.test.ts passes 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.ts also lists declaration in variableTypes, 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's operator() as a call, instead of to the type as an instantiation.
  • C still skips a variable with a bare declarator: static struct { … } SPT; has no variable SPT, by the existing macro-prototype rule. The struct now carries the name.

🤖 Generated with Claude Code