From 85e9ac6762fc1e4290eb2ddf1241d406119d48b4 Mon Sep 17 00:00:00 2001 From: ctype_lab Date: Thu, 6 Aug 2026 15:46:53 +0900 Subject: [PATCH 1/8] fix(c,cpp,objc,rust): index union declarations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `union` declaration produced no symbol at all in any of the four languages that have one. The type never entered the graph, and neither did anything attached to it — in Rust every `impl Trait for MyUnion` lost its edge, and the impl's methods were left with a qualifiedName pointing at a type the graph did not contain. `union_specifier` / `union_item` were absent from the extraction layer entirely: no `Types` list on the TS side, no dispatch branch in either kernel walker. They join `structTypes` (kind `struct` — NodeKind has no `union`), which is the extension point the table-driven extractors already provide. The body guard in extractStruct is untouched, so a bodiless `union U;` stays a forward declaration and is still skipped, exactly like `struct U;`. `resolveTypeAliasKind` accepts `union_specifier` too, so `typedef union { … } N;` takes the typedef's name the way `typedef struct { … } N;` already did. Without it the anonymous union body would mint a second `` node beside the alias. Both walkers change together so kernel<->wasm parity holds. Co-Authored-By: Claude Opus 5 --- codegraph-kernel/src/ccpp/mod.rs | 14 ++++++++++---- codegraph-kernel/src/rustlang.rs | 6 ++++-- src/extraction/languages/c-cpp.ts | 30 ++++++++++++++++++++++++------ src/extraction/languages/objc.ts | 11 +++++++++-- src/extraction/languages/rust.ts | 5 ++++- 5 files changed, 51 insertions(+), 15 deletions(-) diff --git a/codegraph-kernel/src/ccpp/mod.rs b/codegraph-kernel/src/ccpp/mod.rs index c95d3126e..ee6884570 100644 --- a/codegraph-kernel/src/ccpp/mod.rs +++ b/codegraph-kernel/src/ccpp/mod.rs @@ -812,7 +812,10 @@ impl<'t> Walker<'t> { } else if self.variant == Variant::Cpp && kind == "class_specifier" { self.extract_class(node); skip_children = true; - } else if kind == "struct_specifier" { + } else if matches!(kind, "struct_specifier" | "union_specifier") { + // `union_specifier` mirrors structTypes on the TS side: a named + // `union U { … };` is a definition, extracted with kind "struct" + // (NodeKind has no "union"). Bodiless stays a forward declaration. self.extract_struct(node); skip_children = true; } else if kind == "enum_specifier" { @@ -1041,7 +1044,9 @@ impl<'t> Walker<'t> { resolved = Some("enum"); break; } - if child.kind() == "struct_specifier" && child.child_by_field_name("body").is_some() { + if matches!(child.kind(), "struct_specifier" | "union_specifier") + && child.child_by_field_name("body").is_some() + { resolved = Some("struct"); break; } @@ -1059,7 +1064,8 @@ impl<'t> Walker<'t> { self.stack.push(Scope { row, kind: "struct", name }); let type_child = node .child_by_field_name("type") - .or_else(|| self.find_child_by_kind(node, "struct_specifier")); + .or_else(|| self.find_child_by_kind(node, "struct_specifier")) + .or_else(|| self.find_child_by_kind(node, "union_specifier")); if let Some(tc) = type_child { self.extract_inheritance(tc, row); let body = tc.child_by_field_name("body").unwrap_or(tc); @@ -1556,7 +1562,7 @@ impl<'t> Walker<'t> { self.extract_class(node); return; } - if kind == "struct_specifier" { + if matches!(kind, "struct_specifier" | "union_specifier") { self.extract_struct(node); return; } diff --git a/codegraph-kernel/src/rustlang.rs b/codegraph-kernel/src/rustlang.rs index b5b9da3a9..a7d606718 100644 --- a/codegraph-kernel/src/rustlang.rs +++ b/codegraph-kernel/src/rustlang.rs @@ -446,7 +446,9 @@ impl<'t> Walker<'t> { } else if kind == "trait_item" { self.extract_interface(node); skip_children = true; - } else if kind == "struct_item" { + } else if matches!(kind, "struct_item" | "union_item") { + // `union_item` mirrors structTypes on the TS side: same `body:` + // field, same extractor, kind "struct" (NodeKind has no "union"). self.extract_struct(node); skip_children = true; } else if kind == "enum_item" { @@ -1130,7 +1132,7 @@ impl<'t> Walker<'t> { } // Structural nodes inside bodies. - if kind == "struct_item" { + if matches!(kind, "struct_item" | "union_item") { self.extract_struct(node); return; } diff --git a/src/extraction/languages/c-cpp.ts b/src/extraction/languages/c-cpp.ts index 6b5b45e9d..708985ef4 100644 --- a/src/extraction/languages/c-cpp.ts +++ b/src/extraction/languages/c-cpp.ts @@ -186,7 +186,11 @@ export const cExtractor: LanguageExtractor = { classTypes: [], methodTypes: [], interfaceTypes: [], - structTypes: ['struct_specifier'], + // `union U { … };` is a type DEFINITION, same as `struct U { … };` — it + // declares a named type whose members other code refers to. Extracted with + // kind `struct` because NodeKind has no `union`; a bodiless `union U;` is a + // forward declaration and still falls out via extractStruct's body guard. + structTypes: ['struct_specifier', 'union_specifier'], enumTypes: ['enum_specifier'], enumMemberTypes: ['enumerator'], typeAliasTypes: ['type_definition'], // typedef @@ -207,12 +211,18 @@ export const cExtractor: LanguageExtractor = { resolveTypeAliasKind: (node, _source) => { // C typedef: `typedef enum { ... } name;` or `typedef struct { ... } name;` // The inner enum_specifier/struct_specifier is anonymous, but we want the typedef name - // to become the enum/struct node name. + // to become the enum/struct node name. `typedef union { ... } name;` takes the + // same route — otherwise the union body would mint a second, `` node + // beside the alias. for (let i = 0; i < node.namedChildCount; i++) { const child = node.namedChild(i); if (!child) continue; if (child.type === 'enum_specifier' && getChildByField(child, 'body')) return 'enum'; - if (child.type === 'struct_specifier' && getChildByField(child, 'body')) return 'struct'; + if ( + (child.type === 'struct_specifier' || child.type === 'union_specifier') && + getChildByField(child, 'body') + ) + return 'struct'; } return undefined; }, @@ -1551,7 +1561,10 @@ export const cppExtractor: LanguageExtractor = { skipBodilessClass: true, methodTypes: ['function_definition'], interfaceTypes: [], - structTypes: ['struct_specifier'], + // See the C extractor: a named `union U { … };` is a definition, not an + // alias. C++ unions additionally carry member functions, which extract + // through the same body walk as a struct's. + structTypes: ['struct_specifier', 'union_specifier'], enumTypes: ['enum_specifier'], enumMemberTypes: ['enumerator'], typeAliasTypes: ['type_definition', 'alias_declaration'], // typedef and using @@ -1581,12 +1594,17 @@ export const cppExtractor: LanguageExtractor = { return undefined; }, resolveTypeAliasKind: (node, _source) => { - // C++ typedef: `typedef enum { ... } name;` or `typedef struct { ... } name;` + // C++ typedef: `typedef enum { ... } name;`, `typedef struct { ... } name;`, + // or `typedef union { ... } name;` — see the C extractor. for (let i = 0; i < node.namedChildCount; i++) { const child = node.namedChild(i); if (!child) continue; if (child.type === 'enum_specifier' && getChildByField(child, 'body')) return 'enum'; - if (child.type === 'struct_specifier' && getChildByField(child, 'body')) return 'struct'; + if ( + (child.type === 'struct_specifier' || child.type === 'union_specifier') && + getChildByField(child, 'body') + ) + return 'struct'; } return undefined; }, diff --git a/src/extraction/languages/objc.ts b/src/extraction/languages/objc.ts index cf5ecc4d7..668e226ec 100644 --- a/src/extraction/languages/objc.ts +++ b/src/extraction/languages/objc.ts @@ -102,7 +102,9 @@ export const objcExtractor: LanguageExtractor = { methodTypes: ['method_definition'], interfaceTypes: ['protocol_declaration'], interfaceKind: 'protocol', - structTypes: ['struct_specifier'], + // Objective-C is a C superset: `union U { … };` is a definition, same as in + // the C extractor. + structTypes: ['struct_specifier', 'union_specifier'], enumTypes: ['enum_specifier'], enumMemberTypes: ['enumerator'], typeAliasTypes: ['type_definition'], @@ -128,7 +130,12 @@ export const objcExtractor: LanguageExtractor = { const child = node.namedChild(i); if (!child) continue; if (child.type === 'enum_specifier' && getChildByField(child, 'body')) return 'enum'; - if (child.type === 'struct_specifier' && getChildByField(child, 'body')) return 'struct'; + // `typedef union { … } name;` resolves like `typedef struct` — see the C extractor. + if ( + (child.type === 'struct_specifier' || child.type === 'union_specifier') && + getChildByField(child, 'body') + ) + return 'struct'; } return undefined; }, diff --git a/src/extraction/languages/rust.ts b/src/extraction/languages/rust.ts index bdc4477ba..2015e62d8 100644 --- a/src/extraction/languages/rust.ts +++ b/src/extraction/languages/rust.ts @@ -41,7 +41,10 @@ export const rustExtractor: LanguageExtractor = { classTypes: [], // Rust has impl blocks methodTypes: ['function_item', 'function_signature_item'], interfaceTypes: ['trait_item'], - structTypes: ['struct_item'], + // `union U { … }` is a definition like `struct U { … }` — same `body:` + // (`field_declaration_list`) and the same `impl Trait for U` attachment + // point. Extracted with kind `struct` because NodeKind has no `union`. + structTypes: ['struct_item', 'union_item'], enumTypes: ['enum_item'], enumMemberTypes: ['enum_variant'], typeAliasTypes: ['type_item'], // Rust type aliases From 11acc504b53ff7a12e6a31eaeef2fce0981918a9 Mon Sep 17 00:00:00 2001 From: ctype_lab Date: Thu, 6 Aug 2026 15:47:09 +0900 Subject: [PATCH 2/8] test(union): regression cases + kernel-parity torture coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four cases in extraction.test.ts: a Rust union carrying an `impl Trait for` edge and owning the impl's method; a named C union alongside a forward declaration that must NOT mint a node; a `typedef union` taking the typedef name with no `` twin; a C++ union with a member function. Verified they fail without the fix on BOTH extraction paths — the wasm walker via CODEGRAPH_KERNEL=0 and the kernel with a staged build. torture.c / torture.rs gain the same shapes. The parity gate compares the two walkers rather than a snapshot, so the fixtures do not detect the bug on their own — they pin that the fix stays SYMMETRIC. The regression tests above are what pin that it is present. Co-Authored-By: Claude Opus 5 --- __tests__/extraction.test.ts | 110 ++++++++++++++++++++ __tests__/fixtures/kernel-parity/torture.c | 16 +++ __tests__/fixtures/kernel-parity/torture.rs | 7 ++ 3 files changed, 133 insertions(+) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 784023952..d8a431531 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -1174,6 +1174,51 @@ impl Counter { ); expect(implRefs).toHaveLength(0); }); + + it('should extract union declarations and their impl edges', () => { + const code = ` +pub union Reg { + pub raw: u32, + pub halves: [u16; 2], +} + +pub trait Describe { + fn describe(&self) -> u32; +} + +impl Describe for Reg { + fn describe(&self) -> u32 { + unsafe { self.raw } + } +} +`; + const result = extractFromSource('reg.rs', code); + + // A union is a type definition, not an alias — it must be a node, or the + // impl below has no source endpoint to hang off. + const reg = result.nodes.find((n) => n.name === 'Reg'); + expect(reg).toBeDefined(); + expect(reg?.kind).toBe('struct'); + + const implRef = result.unresolvedReferences.find( + (r) => r.referenceKind === 'implements' && r.referenceName === 'Describe' + ); + expect(implRef).toBeDefined(); + expect(implRef?.fromNodeId).toBe(reg?.id); + + // The impl's method attaches to the union, not to the file — without a Reg + // node it was an orphan whose qualifiedName pointed at a type that did not + // exist in the graph. + const implMethod = result.nodes.find( + (n) => n.kind === 'method' && n.qualifiedName?.includes('Reg') + ); + expect(implMethod).toBeDefined(); + expect( + result.edges.some( + (e) => e.kind === 'contains' && e.source === reg?.id && e.target === implMethod?.id + ) + ).toBe(true); + }); }); describe('Java Extraction', () => { @@ -5642,6 +5687,71 @@ std::string use() { }); }); +describe('C/C++ union declarations', () => { + it('extracts a named union as a type node, but not a forward declaration', () => { + const code = ` +union packet_hdr { + unsigned int raw; + unsigned short port; +}; + +/* forward declaration — not a definition */ +union opaque_hdr; + +static unsigned int hdr_raw(union packet_hdr *h) { return h->raw; } +`; + const result = extractFromSource('packet.c', code); + + const hdr = result.nodes.find((n) => n.name === 'packet_hdr'); + expect(hdr).toBeDefined(); + expect(hdr?.kind).toBe('struct'); + + // Same rule as `struct Foo;`: bodiless is a forward declaration, so it must + // not mint a phantom node beside the real definition. + expect(result.nodes.some((n) => n.name === 'opaque_hdr')).toBe(false); + + // Exactly one node for the type — the definition — so a call site or a + // `union packet_hdr *` parameter has a single resolution target. + expect(result.nodes.filter((n) => n.name === 'packet_hdr')).toHaveLength(1); + }); + + it('gives a typedef union the typedef name, not a second node', () => { + const code = ` +typedef union { + unsigned int u; + float f; +} word_t; +`; + const result = extractFromSource('word.c', code); + + const word = result.nodes.find((n) => n.name === 'word_t'); + expect(word?.kind).toBe('struct'); + // Resolved through the typedef the same way `typedef struct { … } X;` is, + // so the anonymous union body does not become its own node. + expect(result.nodes.some((n) => n.name === '')).toBe(false); + }); + + it('extracts a C++ union with member functions', () => { + const code = ` +union Value { + int i; + double d; + int as_int() const { return i; } +}; +`; + const result = extractFromSource('value.cpp', code); + + const value = result.nodes.find((n) => n.name === 'Value'); + expect(value?.kind).toBe('struct'); + + const asInt = result.nodes.find((n) => n.name === 'as_int'); + expect(asInt).toBeDefined(); + expect( + result.edges.some((e) => e.kind === 'contains' && e.source === value?.id && e.target === asInt?.id) + ).toBe(true); + }); +}); + describe('Dart mixins and type references', () => { let tempDir: string; let cg: CodeGraph; diff --git a/__tests__/fixtures/kernel-parity/torture.c b/__tests__/fixtures/kernel-parity/torture.c index 407263748..57e04a912 100644 --- a/__tests__/fixtures/kernel-parity/torture.c +++ b/__tests__/fixtures/kernel-parity/torture.c @@ -152,3 +152,19 @@ static void ratelimited_warn(void) { static DEFINE_RATELIMIT_STATE(ratelimit, 5 * HZ, 5); use_ptr(&ratelimit, 0); } + +/* named union definition, forward declaration, and anonymous typedef union — + the definition is a node, the forward decl is not (#UNION) */ +union packet_hdr { + unsigned int raw; + struct { unsigned char ver, flags; } parts; +}; + +union opaque_hdr; + +typedef union { + unsigned int u; + float f; +} word_t; + +static unsigned int hdr_raw(union packet_hdr *h) { return h->raw; } diff --git a/__tests__/fixtures/kernel-parity/torture.rs b/__tests__/fixtures/kernel-parity/torture.rs index 0f7e80c4e..1e14b7b7a 100644 --- a/__tests__/fixtures/kernel-parity/torture.rs +++ b/__tests__/fixtures/kernel-parity/torture.rs @@ -209,3 +209,10 @@ fn mount() { } routes![top_level_h]; + +pub union Reg { + pub raw: u32, + pub halves: [u16; 2], +} + +impl Base for Reg {} From 86854cd0d72aba7e49ff73649f56dde5d3aea494 Mon Sep 17 00:00:00 2001 From: ctype_lab Date: Thu, 6 Aug 2026 15:47:09 +0900 Subject: [PATCH 3/8] docs(union): changelog entry + port-checklist annotations The two kernel port checklists record the extractor configs as surveyed at porting time; their structTypes lines are marked superseded rather than rewritten, so the surveys stay readable as history. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 ++ docs/design/ccpp-kernel-port-checklist.md | 5 ++++- docs/design/rust-lang-kernel-port-checklist.md | 4 +++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dfc0e9e8..4d1235662 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Unions are now indexed in C, C++, Objective-C and Rust. A `union` declaration previously produced no symbol at all, so it never appeared in search or `codegraph_explore`, and anything attached to it disappeared with it — in Rust, every `impl SomeTrait for MyUnion` lost its edge, and the methods from that impl were left pointing at a type the graph did not contain. A `typedef union { … } Name;` in C now carries the typedef's name like `typedef struct` already did. Re-index after upgrading to pick up the new symbols. + - `codegraph_explore` now concentrates its answer on the code that actually answers your question instead of spreading it across files that merely share a word with it, so more of the answer arrives in a single call. Thanks @LeDuyViet for the detailed measurements and reproduction. (#1500) - Files only weakly related to your question now come back as a name, symbol and line number instead of spending the answer on their source — name one of them in a follow-up `codegraph_explore` to get it back in full. (#1500) - A generated CRUD or protobuf layer no longer crowds out the hand-written code sitting beside it: generated files are now recognized by the `// Code generated by … DO NOT EDIT.` style banner written at the top of the file, not just by a filename that looks generated. Re-index after upgrading to pick up the new detection. (#1500) diff --git a/docs/design/ccpp-kernel-port-checklist.md b/docs/design/ccpp-kernel-port-checklist.md index 08811f32f..520b2fd24 100644 --- a/docs/design/ccpp-kernel-port-checklist.md +++ b/docs/design/ccpp-kernel-port-checklist.md @@ -139,7 +139,10 @@ walker mirrors, with file:line anchors (as of `705e501`). Read WITH ## Extractor configs (languages/c-cpp.ts — read the whole file when porting) **cExtractor (line 180):** functionTypes=[function_definition]; NO -class/method/interface types; structTypes=[struct_specifier]; +class/method/interface types; structTypes=[struct_specifier] +(superseded: `union_specifier` joined structTypes — a named `union U { … };` +is a definition, extracted with kind `struct`, and `typedef union { … } N;` +resolves through resolveTypeAliasKind like `typedef struct`); enumTypes=[enum_specifier]; enumMemberTypes=[enumerator]; typeAliasTypes=[type_definition]; importTypes=[preproc_include]; callTypes=[call_expression]; variableTypes=[declaration]; diff --git a/docs/design/rust-lang-kernel-port-checklist.md b/docs/design/rust-lang-kernel-port-checklist.md index d931e7c7a..d6f01b323 100644 --- a/docs/design/rust-lang-kernel-port-checklist.md +++ b/docs/design/rust-lang-kernel-port-checklist.md @@ -54,7 +54,9 @@ Types: functionTypes=[`function_item`, **`function_signature_item`**] (the latter = a trait method DECLARATION `fn render(&self);` — extracted so a trait's method set is first-class); classTypes=[] (impl blocks instead); methodTypes = same two; interfaceTypes=[`trait_item`] with -**interfaceKind:'trait'**; structTypes=[`struct_item`]; enumTypes=[`enum_item`]; +**interfaceKind:'trait'**; structTypes=[`struct_item`] (superseded: +`union_item` joined structTypes — same `body` field, same extractor, kind +`struct`); enumTypes=[`enum_item`]; enumMemberTypes=[`enum_variant`]; typeAliasTypes=[`type_item`]; importTypes=[`use_declaration`]; callTypes=[`call_expression`]; variableTypes=[`let_declaration`, `const_item`, `static_item`]. From 6978acc92e9901bb6402c88a2bf0530615497e1c Mon Sep 17 00:00:00 2001 From: ctype_lab Date: Thu, 6 Aug 2026 16:49:14 +0900 Subject: [PATCH 4/8] feat(extraction): model union declarations distinctly --- __tests__/extraction.test.ts | 28 +++++++++++---- src/extraction/languages/c-cpp.ts | 23 +++++++------ src/extraction/languages/objc.ts | 13 +++---- src/extraction/languages/rust.ts | 8 ++--- src/extraction/tree-sitter-types.ts | 2 ++ src/extraction/tree-sitter.ts | 53 +++++++++++++++++++++-------- src/types.ts | 1 + 7 files changed, 84 insertions(+), 44 deletions(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index d8a431531..292658822 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -1194,11 +1194,11 @@ impl Describe for Reg { `; const result = extractFromSource('reg.rs', code); - // A union is a type definition, not an alias — it must be a node, or the - // impl below has no source endpoint to hang off. + // A union is a first-class type definition, not an alias — it must be a + // node, or the impl below has no source endpoint to hang off. const reg = result.nodes.find((n) => n.name === 'Reg'); expect(reg).toBeDefined(); - expect(reg?.kind).toBe('struct'); + expect(reg?.kind).toBe('union'); const implRef = result.unresolvedReferences.find( (r) => r.referenceKind === 'implements' && r.referenceName === 'Describe' @@ -5704,7 +5704,7 @@ static unsigned int hdr_raw(union packet_hdr *h) { return h->raw; } const hdr = result.nodes.find((n) => n.name === 'packet_hdr'); expect(hdr).toBeDefined(); - expect(hdr?.kind).toBe('struct'); + expect(hdr?.kind).toBe('union'); // Same rule as `struct Foo;`: bodiless is a forward declaration, so it must // not mint a phantom node beside the real definition. @@ -5725,7 +5725,7 @@ typedef union { const result = extractFromSource('word.c', code); const word = result.nodes.find((n) => n.name === 'word_t'); - expect(word?.kind).toBe('struct'); + expect(word?.kind).toBe('union'); // Resolved through the typedef the same way `typedef struct { … } X;` is, // so the anonymous union body does not become its own node. expect(result.nodes.some((n) => n.name === '')).toBe(false); @@ -5742,7 +5742,7 @@ union Value { const result = extractFromSource('value.cpp', code); const value = result.nodes.find((n) => n.name === 'Value'); - expect(value?.kind).toBe('struct'); + expect(value?.kind).toBe('union'); const asInt = result.nodes.find((n) => n.name === 'as_int'); expect(asInt).toBeDefined(); @@ -8446,6 +8446,22 @@ void helperFunction(int count) { expect(imports).toContain('MyClass.h'); }); + it('extracts union declarations as first-class union nodes', () => { + const code = ` +typedef union { + unsigned int raw; + float value; +} NumberBits; + +union opaque_bits; +`; + const result = extractFromSource('NumberBits.m', code); + + const numberBits = result.nodes.find((n) => n.name === 'NumberBits'); + expect(numberBits?.kind).toBe('union'); + expect(result.nodes.some((n) => n.name === 'opaque_bits')).toBe(false); + }); + it('should record inheritance and protocol conformance', () => { const result = extractFromSource('App.m', sample); const extendsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'extends'); diff --git a/src/extraction/languages/c-cpp.ts b/src/extraction/languages/c-cpp.ts index 708985ef4..cdd573b5a 100644 --- a/src/extraction/languages/c-cpp.ts +++ b/src/extraction/languages/c-cpp.ts @@ -186,11 +186,10 @@ export const cExtractor: LanguageExtractor = { classTypes: [], methodTypes: [], interfaceTypes: [], - // `union U { … };` is a type DEFINITION, same as `struct U { … };` — it - // declares a named type whose members other code refers to. Extracted with - // kind `struct` because NodeKind has no `union`; a bodiless `union U;` is a - // forward declaration and still falls out via extractStruct's body guard. - structTypes: ['struct_specifier', 'union_specifier'], + structTypes: ['struct_specifier'], + // A bodiless `union U;` is a forward declaration; the aggregate extractor + // applies the same body requirement it uses for C structs. + unionTypes: ['union_specifier'], enumTypes: ['enum_specifier'], enumMemberTypes: ['enumerator'], typeAliasTypes: ['type_definition'], // typedef @@ -219,10 +218,11 @@ export const cExtractor: LanguageExtractor = { if (!child) continue; if (child.type === 'enum_specifier' && getChildByField(child, 'body')) return 'enum'; if ( - (child.type === 'struct_specifier' || child.type === 'union_specifier') && + child.type === 'struct_specifier' && getChildByField(child, 'body') ) return 'struct'; + if (child.type === 'union_specifier' && getChildByField(child, 'body')) return 'union'; } return undefined; }, @@ -1561,10 +1561,10 @@ export const cppExtractor: LanguageExtractor = { skipBodilessClass: true, methodTypes: ['function_definition'], interfaceTypes: [], - // See the C extractor: a named `union U { … };` is a definition, not an - // alias. C++ unions additionally carry member functions, which extract - // through the same body walk as a struct's. - structTypes: ['struct_specifier', 'union_specifier'], + structTypes: ['struct_specifier'], + // C++ unions additionally carry member functions, which extract through the + // same aggregate-body walk as structs while preserving their distinct kind. + unionTypes: ['union_specifier'], enumTypes: ['enum_specifier'], enumMemberTypes: ['enumerator'], typeAliasTypes: ['type_definition', 'alias_declaration'], // typedef and using @@ -1601,10 +1601,11 @@ export const cppExtractor: LanguageExtractor = { if (!child) continue; if (child.type === 'enum_specifier' && getChildByField(child, 'body')) return 'enum'; if ( - (child.type === 'struct_specifier' || child.type === 'union_specifier') && + child.type === 'struct_specifier' && getChildByField(child, 'body') ) return 'struct'; + if (child.type === 'union_specifier' && getChildByField(child, 'body')) return 'union'; } return undefined; }, diff --git a/src/extraction/languages/objc.ts b/src/extraction/languages/objc.ts index 668e226ec..9bada343c 100644 --- a/src/extraction/languages/objc.ts +++ b/src/extraction/languages/objc.ts @@ -102,9 +102,9 @@ export const objcExtractor: LanguageExtractor = { methodTypes: ['method_definition'], interfaceTypes: ['protocol_declaration'], interfaceKind: 'protocol', - // Objective-C is a C superset: `union U { … };` is a definition, same as in - // the C extractor. - structTypes: ['struct_specifier', 'union_specifier'], + structTypes: ['struct_specifier'], + // Objective-C is a C superset: union declarations preserve their own kind. + unionTypes: ['union_specifier'], enumTypes: ['enum_specifier'], enumMemberTypes: ['enumerator'], typeAliasTypes: ['type_definition'], @@ -130,12 +130,9 @@ export const objcExtractor: LanguageExtractor = { const child = node.namedChild(i); if (!child) continue; if (child.type === 'enum_specifier' && getChildByField(child, 'body')) return 'enum'; - // `typedef union { … } name;` resolves like `typedef struct` — see the C extractor. - if ( - (child.type === 'struct_specifier' || child.type === 'union_specifier') && - getChildByField(child, 'body') - ) + if (child.type === 'struct_specifier' && getChildByField(child, 'body')) return 'struct'; + if (child.type === 'union_specifier' && getChildByField(child, 'body')) return 'union'; } return undefined; }, diff --git a/src/extraction/languages/rust.ts b/src/extraction/languages/rust.ts index 2015e62d8..6d91bf6b8 100644 --- a/src/extraction/languages/rust.ts +++ b/src/extraction/languages/rust.ts @@ -41,10 +41,10 @@ export const rustExtractor: LanguageExtractor = { classTypes: [], // Rust has impl blocks methodTypes: ['function_item', 'function_signature_item'], interfaceTypes: ['trait_item'], - // `union U { … }` is a definition like `struct U { … }` — same `body:` - // (`field_declaration_list`) and the same `impl Trait for U` attachment - // point. Extracted with kind `struct` because NodeKind has no `union`. - structTypes: ['struct_item', 'union_item'], + structTypes: ['struct_item'], + // Unions share struct member syntax and impl attachment, but retain their + // distinct semantic kind in the graph. + unionTypes: ['union_item'], enumTypes: ['enum_item'], enumMemberTypes: ['enum_variant'], typeAliasTypes: ['type_item'], // Rust type aliases diff --git a/src/extraction/tree-sitter-types.ts b/src/extraction/tree-sitter-types.ts index 2a02b47b7..6808895e1 100644 --- a/src/extraction/tree-sitter-types.ts +++ b/src/extraction/tree-sitter-types.ts @@ -102,6 +102,8 @@ export interface LanguageExtractor { interfaceTypes: string[]; /** Node types that represent structs */ structTypes: string[]; + /** Node types that represent unions */ + unionTypes?: string[]; /** Node types that represent enums */ enumTypes: string[]; /** Node types that represent enum members/cases (e.g. Swift: 'enum_entry', Rust: 'enum_variant') */ diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 9e53e62da..8d71d7f18 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1066,6 +1066,11 @@ export class TreeSitterExtractor { this.extractStruct(node); skipChildren = true; // extractStruct visits body children } + // Check for union declarations + else if (this.extractor.unionTypes?.includes(nodeType)) { + this.extractUnion(node); + skipChildren = true; // extractUnion visits body children + } // Check for enum declarations else if (this.extractor.enumTypes.includes(nodeType)) { this.extractEnum(node); @@ -1487,7 +1492,7 @@ export class TreeSitterExtractor { /** * Check if the current node stack indicates we are inside a class-like node - * (class, struct, interface, trait). File nodes do not count as class-like. + * (class, struct, union, interface, trait). File nodes do not count as class-like. */ private isInsideClassLikeNode(): boolean { if (this.nodeStack.length === 0) return false; @@ -1498,6 +1503,7 @@ export class TreeSitterExtractor { return ( parentNode.kind === 'class' || parentNode.kind === 'struct' || + parentNode.kind === 'union' || parentNode.kind === 'interface' || parentNode.kind === 'trait' || parentNode.kind === 'enum' || @@ -1807,7 +1813,7 @@ export class TreeSitterExtractor { (n) => n.name === receiverType && n.filePath === this.filePath && - (n.kind === 'struct' || n.kind === 'class' || n.kind === 'enum' || n.kind === 'trait') + (n.kind === 'struct' || n.kind === 'union' || n.kind === 'class' || n.kind === 'enum' || n.kind === 'trait') ); if (ownerNode) { this.edges.push({ @@ -1873,6 +1879,16 @@ export class TreeSitterExtractor { * Extract a struct */ private extractStruct(node: SyntaxNode): void { + this.extractAggregate(node, 'struct'); + } + + /** Extract a union while sharing the member-walk behavior of aggregate types. */ + private extractUnion(node: SyntaxNode): void { + this.extractAggregate(node, 'union'); + } + + /** Extract a struct-like declaration without conflating its semantic kind. */ + private extractAggregate(node: SyntaxNode, kind: 'struct' | 'union'): void { if (!this.extractor) return; // Skip forward declarations and type references (no body = not a definition) @@ -1886,24 +1902,24 @@ export class TreeSitterExtractor { const visibility = this.extractor.getVisibility?.(node); const isExported = this.extractor.isExported?.(node, this.source); - const structNode = this.createNode('struct', name, node, { + const aggregateNode = this.createNode(kind, name, node, { docstring, visibility, isExported, }); - if (!structNode) return; + if (!aggregateNode) return; // Extract inheritance (e.g. Swift: struct HTTPMethod: RawRepresentable) - this.extractInheritance(node, structNode.id); + this.extractInheritance(node, aggregateNode.id); // C# primary-constructor parameter dependencies (`struct P(int x)`, and // `record struct M(decimal Amount)` which the grammar nests here). - this.extractCsharpPrimaryCtorParamRefs(node, structNode.id); + this.extractCsharpPrimaryCtorParamRefs(node, aggregateNode.id); // Push to stack for field extraction (bodiless positional records have // no members to visit) if (body) { - this.nodeStack.push(structNode.id); + this.nodeStack.push(aggregateNode.id); for (let i = 0; i < body.namedChildCount; i++) { const child = body.namedChild(i); if (child) { @@ -2905,17 +2921,20 @@ export class TreeSitterExtractor { // (e.g. Go: `type Foo struct { ... }` is a type_spec wrapping struct_type) const resolvedKind = this.extractor.resolveTypeAliasKind?.(node, this.source); - if (resolvedKind === 'struct') { - const structNode = this.createNode('struct', name, node, { docstring, isExported }); - if (!structNode) return true; + if (resolvedKind === 'struct' || resolvedKind === 'union') { + const aggregateNode = this.createNode(resolvedKind, name, node, { docstring, isExported }); + if (!aggregateNode) return true; // Visit body children for field extraction - this.nodeStack.push(structNode.id); - // Try Go-style 'type' field first, then find inner struct child (C typedef struct) + this.nodeStack.push(aggregateNode.id); + // Try Go-style 'type' field first, then find the matching inner aggregate child. const typeChild = getChildByField(node, 'type') - || this.findChildByTypes(node, this.extractor.structTypes); + || this.findChildByTypes( + node, + resolvedKind === 'union' ? (this.extractor.unionTypes ?? []) : this.extractor.structTypes + ); if (typeChild) { // Extract struct embedding (e.g. Go: `type DB struct { *Head; Queryable }`) - this.extractInheritance(typeChild, structNode.id); + this.extractInheritance(typeChild, aggregateNode.id); const body = getChildByField(typeChild, this.extractor.bodyField) || typeChild; for (let i = 0; i < body.namedChildCount; i++) { const child = body.namedChild(i); @@ -5271,6 +5290,10 @@ export class TreeSitterExtractor { this.extractStruct(node); return; } + if (this.extractor!.unionTypes?.includes(nodeType)) { + this.extractUnion(node); + return; + } if (this.extractor!.enumTypes.includes(nodeType)) { this.extractEnum(node); return; @@ -5745,7 +5768,7 @@ export class TreeSitterExtractor { */ private findNodeByName(name: string): string | undefined { for (const node of this.nodes) { - if (node.name === name && (node.kind === 'struct' || node.kind === 'enum' || node.kind === 'class')) { + if (node.name === name && (node.kind === 'struct' || node.kind === 'union' || node.kind === 'enum' || node.kind === 'class')) { return node.id; } } diff --git a/src/types.ts b/src/types.ts index a1861bba4..b0ebfe433 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,6 +42,7 @@ export const NODE_KINDS = [ 'export', 'route', 'component', + 'union', ] as const; export type NodeKind = (typeof NODE_KINDS)[number]; From 8e3cde6994af896d496b5039313f0d69118ad84c Mon Sep 17 00:00:00 2001 From: ctype_lab Date: Thu, 6 Aug 2026 16:52:37 +0900 Subject: [PATCH 5/8] feat(kernel): preserve union nodes across extraction --- codegraph-kernel/src/buffers.rs | 3 +- codegraph-kernel/src/ccpp/mod.rs | 47 +++++++++++++++------------ codegraph-kernel/src/rustlang.rs | 34 ++++++++++--------- src/context/index.ts | 2 +- src/mcp/tools.ts | 4 +-- src/resolution/c-fnptr-synthesizer.ts | 4 +-- src/resolution/import-resolver.ts | 1 + src/resolution/name-matcher.ts | 8 ++--- src/search/query-utils.ts | 1 + 9 files changed, 59 insertions(+), 45 deletions(-) diff --git a/codegraph-kernel/src/buffers.rs b/codegraph-kernel/src/buffers.rs index 66ae1401c..8934ed73e 100644 --- a/codegraph-kernel/src/buffers.rs +++ b/codegraph-kernel/src/buffers.rs @@ -77,7 +77,7 @@ pub const EDGE_ROW_SIZE: usize = 44; pub const REF_ROW_SIZE: usize = 40; /// Mirror of NODE_KINDS in src/types.ts — order is the wire contract. -pub const NODE_KINDS: [&str; 22] = [ +pub const NODE_KINDS: [&str; 23] = [ "file", "module", "class", @@ -100,6 +100,7 @@ pub const NODE_KINDS: [&str; 22] = [ "export", "route", "component", + "union", ]; /// Mirror of EDGE_KINDS in src/types.ts — order is the wire contract. diff --git a/codegraph-kernel/src/ccpp/mod.rs b/codegraph-kernel/src/ccpp/mod.rs index ee6884570..9f058f495 100644 --- a/codegraph-kernel/src/ccpp/mod.rs +++ b/codegraph-kernel/src/ccpp/mod.rs @@ -455,7 +455,7 @@ impl<'t> Walker<'t> { fn inside_class_like(&self) -> bool { self.stack .last() - .map(|s| matches!(s.kind, "class" | "struct" | "interface" | "trait" | "enum" | "module")) + .map(|s| matches!(s.kind, "class" | "struct" | "union" | "interface" | "trait" | "enum" | "module")) .unwrap_or(false) } @@ -561,7 +561,7 @@ impl<'t> Walker<'t> { let parent_ok = self .stack .last() - .map(|s| matches!(s.kind, "file" | "class" | "module" | "struct" | "enum")) + .map(|s| matches!(s.kind, "file" | "class" | "module" | "struct" | "union" | "enum")) .unwrap_or(false); if parent_ok { self.fs_values.insert(name.to_string(), row); @@ -812,11 +812,11 @@ impl<'t> Walker<'t> { } else if self.variant == Variant::Cpp && kind == "class_specifier" { self.extract_class(node); skip_children = true; - } else if matches!(kind, "struct_specifier" | "union_specifier") { - // `union_specifier` mirrors structTypes on the TS side: a named - // `union U { … };` is a definition, extracted with kind "struct" - // (NodeKind has no "union"). Bodiless stays a forward declaration. - self.extract_struct(node); + } else if kind == "struct_specifier" { + self.extract_aggregate(node, "struct"); + skip_children = true; + } else if kind == "union_specifier" { + self.extract_aggregate(node, "union"); skip_children = true; } else if kind == "enum_specifier" { self.extract_enum(node); @@ -928,7 +928,7 @@ impl<'t> Walker<'t> { .iter() .position(|m| { m.name == *receiver_type - && matches!(m.kind, "struct" | "class" | "enum" | "trait") + && matches!(m.kind, "struct" | "union" | "class" | "enum" | "trait") }) .map(|i| i as u32); if let Some(owner_row) = owner_row { @@ -974,8 +974,8 @@ impl<'t> Walker<'t> { self.stack.pop(); } - /// extractStruct: bodiless specifiers (fwd decls / elaborated refs) skip. - fn extract_struct(&mut self, node: Node<'t>) { + /// Extract a struct-like declaration while preserving its semantic kind. + fn extract_aggregate(&mut self, node: Node<'t>, kind: &'static str) { let Some(body) = node.child_by_field_name("body") else { return }; let name = self.extract_name(node); let extra = Extra { @@ -983,9 +983,9 @@ impl<'t> Walker<'t> { visibility: if self.variant == Variant::Cpp { self.visibility_of(node) } else { None }, ..Extra::default() }; - let Some(row) = self.create_node("struct", &name, node, extra) else { return }; + let Some(row) = self.create_node(kind, &name, node, extra) else { return }; self.extract_inheritance(node, row); - self.stack.push(Scope { row, kind: "struct", name }); + self.stack.push(Scope { row, kind, name }); for i in 0..body.named_child_count() { if let Some(c) = body.named_child(i) { self.visit_node(c); @@ -1044,24 +1044,27 @@ impl<'t> Walker<'t> { resolved = Some("enum"); break; } - if matches!(child.kind(), "struct_specifier" | "union_specifier") - && child.child_by_field_name("body").is_some() - { + if child.kind() == "struct_specifier" && child.child_by_field_name("body").is_some() { resolved = Some("struct"); break; } + if child.kind() == "union_specifier" && child.child_by_field_name("body").is_some() { + resolved = Some("union"); + break; + } } - if resolved == Some("struct") { + if matches!(resolved, Some("struct") | Some("union")) { + let kind = resolved.unwrap(); let Some(row) = self.create_node( - "struct", + kind, &name, node, Extra { docstring, ..Extra::default() }, ) else { return true; }; - self.stack.push(Scope { row, kind: "struct", name }); + self.stack.push(Scope { row, kind, name }); let type_child = node .child_by_field_name("type") .or_else(|| self.find_child_by_kind(node, "struct_specifier")) @@ -1562,8 +1565,12 @@ impl<'t> Walker<'t> { self.extract_class(node); return; } - if matches!(kind, "struct_specifier" | "union_specifier") { - self.extract_struct(node); + if kind == "struct_specifier" { + self.extract_aggregate(node, "struct"); + return; + } + if kind == "union_specifier" { + self.extract_aggregate(node, "union"); return; } if kind == "enum_specifier" { diff --git a/codegraph-kernel/src/rustlang.rs b/codegraph-kernel/src/rustlang.rs index a7d606718..16447c243 100644 --- a/codegraph-kernel/src/rustlang.rs +++ b/codegraph-kernel/src/rustlang.rs @@ -217,7 +217,7 @@ impl<'t> Walker<'t> { fn inside_class_like(&self) -> bool { self.stack .last() - .map(|s| matches!(s.kind, "class" | "struct" | "interface" | "trait" | "enum" | "module")) + .map(|s| matches!(s.kind, "class" | "struct" | "union" | "interface" | "trait" | "enum" | "module")) .unwrap_or(false) } @@ -326,7 +326,7 @@ impl<'t> Walker<'t> { let parent_ok = self .stack .last() - .map(|s| matches!(s.kind, "file" | "class" | "module" | "struct" | "enum")) + .map(|s| matches!(s.kind, "file" | "class" | "module" | "struct" | "union" | "enum")) .unwrap_or(false); if parent_ok { self.fs_values.insert(name.to_string(), row); @@ -446,10 +446,11 @@ impl<'t> Walker<'t> { } else if kind == "trait_item" { self.extract_interface(node); skip_children = true; - } else if matches!(kind, "struct_item" | "union_item") { - // `union_item` mirrors structTypes on the TS side: same `body:` - // field, same extractor, kind "struct" (NodeKind has no "union"). - self.extract_struct(node); + } else if kind == "struct_item" { + self.extract_aggregate(node, "struct"); + skip_children = true; + } else if kind == "union_item" { + self.extract_aggregate(node, "union"); skip_children = true; } else if kind == "enum_item" { self.extract_enum(node); @@ -531,7 +532,7 @@ impl<'t> Walker<'t> { .iter() .position(|m| { m.name == *receiver - && matches!(m.kind, "struct" | "class" | "enum" | "trait") + && matches!(m.kind, "struct" | "union" | "class" | "enum" | "trait") }) .map(|i| i as u32); if let Some(owner_row) = owner_row { @@ -581,9 +582,8 @@ impl<'t> Walker<'t> { self.stack.pop(); } - /// extractStruct — body field REQUIRED (unit structs mint no node; tuple - /// structs' ordered_field_declaration_list is a body). - fn extract_struct(&mut self, node: Node<'t>) { + /// Extract a Rust struct or union with a body; unit structs remain skipped. + fn extract_aggregate(&mut self, node: Node<'t>, kind: &'static str) { let Some(body) = node.child_by_field_name("body") else { return }; let name = self.extract_name(node); let extra = Extra { @@ -591,10 +591,10 @@ impl<'t> Walker<'t> { visibility: Some(self.visibility_of(node)), ..Extra::default() }; - let Some(row) = self.create_node("struct", &name, node, extra) else { return }; + let Some(row) = self.create_node(kind, &name, node, extra) else { return }; self.extract_inheritance(node, row); - self.stack.push(Scope { row, kind: "struct", name }); + self.stack.push(Scope { row, kind, name }); for i in 0..body.named_child_count() { if let Some(c) = body.named_child(i) { self.visit_node(c); @@ -1059,7 +1059,7 @@ impl<'t> Walker<'t> { let target_row = self .nodes_meta .iter() - .position(|m| m.name == type_name && matches!(m.kind, "struct" | "enum" | "class")) + .position(|m| m.name == type_name && matches!(m.kind, "struct" | "union" | "enum" | "class")) .map(|i| i as u32); if let Some(target_row) = target_row { self.push_ref_at(target_row, &trait_name, edge_kind_index("implements").unwrap(), trait_node); @@ -1132,8 +1132,12 @@ impl<'t> Walker<'t> { } // Structural nodes inside bodies. - if matches!(kind, "struct_item" | "union_item") { - self.extract_struct(node); + if kind == "struct_item" { + self.extract_aggregate(node, "struct"); + return; + } + if kind == "union_item" { + self.extract_aggregate(node, "union"); return; } if kind == "enum_item" { diff --git a/src/context/index.ts b/src/context/index.ts index 3e60723db..e297caba4 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -559,7 +559,7 @@ export class ContextBuilder { // but are almost never what exploration queries want. const searchKinds = opts.nodeKinds && opts.nodeKinds.length > 0 ? opts.nodeKinds - : ['file', 'module', 'class', 'struct', 'interface', 'trait', 'protocol', + : ['file', 'module', 'class', 'struct', 'union', 'interface', 'trait', 'protocol', 'function', 'method', 'property', 'field', 'variable', 'constant', 'enum', 'enum_member', 'type_alias', 'namespace', 'export', 'route', 'component'] as NodeKind[]; diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 4a6e1a5fe..52b7ed03f 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -343,7 +343,7 @@ export function getExploreOutputBudget(fileCount: number): ExploreOutputBudget { */ export const RELEVANCE_KIND_WEIGHT: Readonly> = { // Callables and types: the answer lives in one of these. - function: 1, method: 1, class: 1, struct: 1, interface: 1, trait: 1, + function: 1, method: 1, class: 1, struct: 1, union: 1, interface: 1, trait: 1, protocol: 1, component: 1, route: 1, enum: 1, type_alias: 1, constructor: 1, // Containers: real structure, but a whole namespace/module matching a term is // a coarser signal than a callable matching it. @@ -3968,7 +3968,7 @@ export class ToolHandler { const superMany = new Map(); const definesPolymorphicSupertype = (nodes: Node[]): boolean => { for (const n of nodes) { - if (n.kind !== 'class' && n.kind !== 'interface' && n.kind !== 'struct' + if (n.kind !== 'class' && n.kind !== 'interface' && n.kind !== 'struct' && n.kind !== 'union' && n.kind !== 'trait' && n.kind !== 'protocol' && n.kind !== 'type_alias') continue; let many = superMany.get(n.id); if (many === undefined) { diff --git a/src/resolution/c-fnptr-synthesizer.ts b/src/resolution/c-fnptr-synthesizer.ts index 13055e2c4..cc7ea1df9 100644 --- a/src/resolution/c-fnptr-synthesizer.ts +++ b/src/resolution/c-fnptr-synthesizer.ts @@ -703,7 +703,7 @@ export async function cFnPointerDispatchEdges( if (prof) { prof.nodesMs += Date.now() - tN; prof.nodesN++; } const structs: CfnptrFileIn['structs'] = []; for (const st of fileNodes) { - if (st.kind !== 'struct') continue; + if (st.kind !== 'struct' && st.kind !== 'union') continue; // sliceLinesPre semantics ride along: falsy startLine never parses, // and `endLine ?? startLine` is applied here so the kernel sees the // exact slice bounds the JS sweep would use. @@ -740,7 +740,7 @@ export async function cFnPointerDispatchEdges( if (prof) { prof.nodesMs += Date.now() - tN; prof.nodesN++; } let lines: string[] | null = null; for (const st of fileNodes) { - if (st.kind !== 'struct') continue; + if (st.kind !== 'struct' && st.kind !== 'union') continue; lines ??= s.split('\n'); const body = sliceLinesPre(lines, st.startLine, st.endLine); const open = body.indexOf('{'); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 07f18cbb1..cf5620cd8 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -1827,6 +1827,7 @@ function resolveRustPathReference( n.name === leaf && (n.kind === 'function' || n.kind === 'struct' || + n.kind === 'union' || n.kind === 'enum' || n.kind === 'trait' || n.kind === 'type_alias' || diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 967f0b19b..208213fe8 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -798,12 +798,12 @@ function lookupCalleeReturnType( return candidates.find((n) => n.kind === 'function')?.returnType ?? null; } -/** Does the graph contain a class/struct named `name`'s last segment? */ +/** Does the graph contain an aggregate type named `name`'s last segment? */ function cppClassExists(name: string, ref: UnresolvedRef, context: ResolutionContext): boolean { const last = cppLastSegment(name); return context .getNodesByName(last) - .some((n) => (n.kind === 'class' || n.kind === 'struct') && n.language === ref.language); + .some((n) => (n.kind === 'class' || n.kind === 'struct' || n.kind === 'union') && n.language === ref.language); } /** @@ -1771,7 +1771,7 @@ export function matchMethodCall( ); for (const classNode of classCandidates) { - if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'interface') { + if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'union' || classNode.kind === 'interface') { // Skip cross-language class matches if (classNode.language !== ref.language) continue; @@ -1807,7 +1807,7 @@ export function matchMethodCall( ref.filePath, ); for (const classNode of fuzzyClassCandidates) { - if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'interface') { + if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'union' || classNode.kind === 'interface') { // Skip cross-language class matches if (classNode.language !== ref.language) continue; diff --git a/src/search/query-utils.ts b/src/search/query-utils.ts index 1a7b121fc..e3db25b1a 100644 --- a/src/search/query-utils.ts +++ b/src/search/query-utils.ts @@ -393,6 +393,7 @@ export function kindBonus(kind: Node['kind']): number { interface: 9, type_alias: 6, struct: 6, + union: 6, trait: 9, enum: 5, component: 8, From ba58365c6f054fb82c40dca2c4fed8a8241c5a38 Mon Sep 17 00:00:00 2001 From: ctype_lab Date: Thu, 6 Aug 2026 16:53:13 +0900 Subject: [PATCH 6/8] docs(union): describe first-class union nodes --- CHANGELOG.md | 2 +- CLAUDE.md | 2 +- docs/design/ccpp-kernel-port-checklist.md | 7 +++---- docs/design/rust-lang-kernel-port-checklist.md | 6 +++--- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d1235662..536232873 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes -- Unions are now indexed in C, C++, Objective-C and Rust. A `union` declaration previously produced no symbol at all, so it never appeared in search or `codegraph_explore`, and anything attached to it disappeared with it — in Rust, every `impl SomeTrait for MyUnion` lost its edge, and the methods from that impl were left pointing at a type the graph did not contain. A `typedef union { … } Name;` in C now carries the typedef's name like `typedef struct` already did. Re-index after upgrading to pick up the new symbols. +- C, C++, Objective-C and Rust unions are now indexed as first-class `union` nodes. A `union` declaration previously produced no symbol at all, so it never appeared in search or `codegraph_explore`, and anything attached to it disappeared with it — in Rust, every `impl SomeTrait for MyUnion` lost its edge, and the methods from that impl were left pointing at a type the graph did not contain. A `typedef union { … } Name;` in C now carries the typedef's name and remains distinguishable from a struct. Re-index after upgrading to replace the earlier struct-shaped union nodes. - `codegraph_explore` now concentrates its answer on the code that actually answers your question instead of spreading it across files that merely share a word with it, so more of the answer arrives in a single call. Thanks @LeDuyViet for the detailed measurements and reproduction. (#1500) - Files only weakly related to your question now come back as a name, symbol and line number instead of spending the answer on their source — name one of them in a follow-up `codegraph_explore` to get it back in full. (#1500) diff --git a/CLAUDE.md b/CLAUDE.md index 1ae8ae969..4f24f1c0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,7 @@ The public API surface is `src/index.ts` — the `CodeGraph` class wires all the Defined in `src/types.ts`. Both extractors and resolvers must use these exact strings. -- **NodeKind**: `file`, `module`, `class`, `struct`, `interface`, `trait`, `protocol`, `function`, `method`, `property`, `field`, `variable`, `constant`, `enum`, `enum_member`, `type_alias`, `namespace`, `parameter`, `import`, `export`, `route`, `component`. +- **NodeKind**: `file`, `module`, `class`, `struct`, `interface`, `trait`, `protocol`, `function`, `method`, `property`, `field`, `variable`, `constant`, `enum`, `enum_member`, `type_alias`, `namespace`, `parameter`, `import`, `export`, `route`, `component`, `union`. - **EdgeKind**: `contains`, `calls`, `imports`, `exports`, `extends`, `implements`, `references`, `type_of`, `returns`, `instantiates`, `overrides`, `decorates`. ### Multi-agent installer diff --git a/docs/design/ccpp-kernel-port-checklist.md b/docs/design/ccpp-kernel-port-checklist.md index 520b2fd24..3fe0562b3 100644 --- a/docs/design/ccpp-kernel-port-checklist.md +++ b/docs/design/ccpp-kernel-port-checklist.md @@ -139,10 +139,9 @@ walker mirrors, with file:line anchors (as of `705e501`). Read WITH ## Extractor configs (languages/c-cpp.ts — read the whole file when porting) **cExtractor (line 180):** functionTypes=[function_definition]; NO -class/method/interface types; structTypes=[struct_specifier] -(superseded: `union_specifier` joined structTypes — a named `union U { … };` -is a definition, extracted with kind `struct`, and `typedef union { … } N;` -resolves through resolveTypeAliasKind like `typedef struct`); +class/method/interface types; structTypes=[struct_specifier]; +unionTypes=[union_specifier] (a named `union U { … };` is a definition, and +`typedef union { … } N;` resolves to a first-class `union` node); enumTypes=[enum_specifier]; enumMemberTypes=[enumerator]; typeAliasTypes=[type_definition]; importTypes=[preproc_include]; callTypes=[call_expression]; variableTypes=[declaration]; diff --git a/docs/design/rust-lang-kernel-port-checklist.md b/docs/design/rust-lang-kernel-port-checklist.md index d6f01b323..8727d8598 100644 --- a/docs/design/rust-lang-kernel-port-checklist.md +++ b/docs/design/rust-lang-kernel-port-checklist.md @@ -54,9 +54,9 @@ Types: functionTypes=[`function_item`, **`function_signature_item`**] (the latter = a trait method DECLARATION `fn render(&self);` — extracted so a trait's method set is first-class); classTypes=[] (impl blocks instead); methodTypes = same two; interfaceTypes=[`trait_item`] with -**interfaceKind:'trait'**; structTypes=[`struct_item`] (superseded: -`union_item` joined structTypes — same `body` field, same extractor, kind -`struct`); enumTypes=[`enum_item`]; +**interfaceKind:'trait'**; structTypes=[`struct_item`]; +unionTypes=[`union_item`] (same body walk, distinct `union` node kind); +enumTypes=[`enum_item`]; enumMemberTypes=[`enum_variant`]; typeAliasTypes=[`type_item`]; importTypes=[`use_declaration`]; callTypes=[`call_expression`]; variableTypes=[`let_declaration`, `const_item`, `static_item`]. From e922563e0509976152736fa9ad6064f85668620b Mon Sep 17 00:00:00 2001 From: ctype_lab Date: Thu, 6 Aug 2026 17:05:33 +0900 Subject: [PATCH 7/8] fix(resolution): recognize union instantiation --- __tests__/resolution.test.ts | 47 ++++++++++++++++++++++++++++++++++ src/resolution/index.ts | 7 +++-- src/resolution/name-matcher.ts | 1 + 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index eca1778ff..6b64241e3 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -1020,6 +1020,31 @@ def bootstrap(): expect(callsToUserService).toHaveLength(0); }); + it('promotes calls→instantiates when target resolves to a C++ union', async () => { + // `Packet()` value-initializes the union. The extractor emits a calls + // reference for that expression, so resolution must preserve the + // class-like promotion that unions received when they were structs. + fs.writeFileSync( + path.join(tempDir, 'packet.cpp'), + `union Packet { unsigned int raw; }; + +void initialize() { Packet(); } +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const packet = cg.getNodesByKind('union').find((n) => n.name === 'Packet'); + const initialize = cg.getNodesByKind('function').find((n) => n.name === 'initialize'); + expect(packet).toBeDefined(); + expect(initialize).toBeDefined(); + + const outgoing = cg.getOutgoingEdges(initialize!.id); + expect(outgoing.some((e) => e.kind === 'instantiates' && e.target === packet!.id)).toBe(true); + expect(outgoing.some((e) => e.kind === 'calls' && e.target === packet!.id)).toBe(false); + }); + it('records instantiates for C++ stack/brace construction, targeting the class (#1035)', async () => { // `Calculator calc(0)` (direct-init) and `Widget w{1, 2}` (brace-init) // carry the constructor args directly on the declarator — there's no @@ -2169,6 +2194,28 @@ func main() { expect(result?.targetNodeId).toBe('class:logger.ts:Logger:10'); }); + it('prefers a union candidate over a function for `instantiates` refs', () => { + const fn: Node = { + id: 'func:packet.cpp:Packet:5', kind: 'function', name: 'Packet', + qualifiedName: 'packet.cpp::Packet', filePath: 'packet.cpp', language: 'cpp', + startLine: 5, endLine: 7, startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + const union: Node = { + id: 'union:packet.hpp:Packet:10', kind: 'union', name: 'Packet', + qualifiedName: 'packet.hpp::Packet', filePath: 'packet.hpp', language: 'cpp', + startLine: 10, endLine: 14, startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + const ref = { + fromNodeId: 'func:main.cpp:initialize:1', + referenceName: 'Packet', + referenceKind: 'instantiates' as const, + line: 5, column: 0, filePath: 'main.cpp', language: 'cpp' as const, + }; + + const result = matchReference(ref, baseContext([fn, union])); + expect(result?.targetNodeId).toBe('union:packet.hpp:Packet:10'); + }); + it('prefers a function candidate over a non-function for `decorates` refs', () => { const variable: Node = { id: 'var:config.ts:Inject:5', kind: 'variable', name: 'Inject', diff --git a/src/resolution/index.ts b/src/resolution/index.ts index ef3a5fc23..01f615b28 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -1079,13 +1079,16 @@ export class ReferenceResolver { } // Promote "calls" to "instantiates" when the resolved target is a - // class/struct. Languages without a `new` keyword (Python, Ruby) + // class/struct/union. Languages without a `new` keyword (Python, Ruby) // express instantiation as `Foo()` — extraction can't tell that // apart from a function call without symbol info, but resolution // can: if `Foo` resolves to a class, the call IS an instantiation. if (kind === 'calls') { const targetNode = this.queries.getNodeById(ref.targetNodeId); - if (targetNode && (targetNode.kind === 'class' || targetNode.kind === 'struct')) { + if ( + targetNode && + (targetNode.kind === 'class' || targetNode.kind === 'struct' || targetNode.kind === 'union') + ) { kind = 'instantiates'; } } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 208213fe8..651051466 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -2107,6 +2107,7 @@ function findBestMatch( if ( candidate.kind === 'class' || candidate.kind === 'struct' || + candidate.kind === 'union' || candidate.kind === 'interface' ) { score += 25; From e2195940fb55c29b735c8102935931deea729554 Mon Sep 17 00:00:00 2001 From: ctype_lab Date: Thu, 6 Aug 2026 17:50:25 +0900 Subject: [PATCH 8/8] fix(union): complete downstream container handling --- __tests__/c-fnptr-synthesizer.test.ts | 39 +++++++++++++++++++++++++++ __tests__/context.test.ts | 17 +++++++++++- __tests__/resolution.test.ts | 28 +++++++++++++++++++ codegraph-kernel/src/cfnptr.rs | 36 ++++++++++++++++++------- src/context/index.ts | 8 +++--- src/graph/queries.ts | 2 ++ src/graph/traversal.ts | 2 +- src/mcp/tools.ts | 8 +++--- src/resolution/c-fnptr-synthesizer.ts | 26 +++++++++--------- src/resolution/import-resolver.ts | 2 +- 10 files changed, 136 insertions(+), 32 deletions(-) diff --git a/__tests__/c-fnptr-synthesizer.test.ts b/__tests__/c-fnptr-synthesizer.test.ts index 202c9638f..bba9fcadf 100644 --- a/__tests__/c-fnptr-synthesizer.test.ts +++ b/__tests__/c-fnptr-synthesizer.test.ts @@ -86,6 +86,45 @@ int dispatch(struct ops o) { return o.handler(); } expect(edges.every((e) => e.via === 'ops.handler')).toBe(true); }); + it('bridges function-pointer fields declared in a union', async () => { + write('union-ops.c', ` +union ops { int (*handler)(void); }; +static int on_open(void) { return 1; } +static union ops the_ops = { .handler = on_open }; + +int dispatch(union ops o) { return o.handler(); } +`); + const edges = await load(); + expect(has(edges, 'dispatch', 'on_open')).toBe(true); + expect(edges.every((e) => e.via === 'ops.handler')).toBe(true); + }); + + it('bridges an inline union table whose entries are macro-built', async () => { + write('inline-union.c', ` +#define SLOT(fn) { fn } +static int on_open(void) { return 1; } +static union inline_ops { int (*handler)(void); } ops[] = { SLOT(on_open) }; + +int dispatch(union inline_ops o) { return o.handler(); } +`); + const edges = await load(); + expect(has(edges, 'dispatch', 'on_open')).toBe(true); + }); + + it('bridges a union table declared through an object-macro type alias', async () => { + write('alias-union.c', ` +#define OPS_TYPE union ops +#define SLOT(fn) { fn } +union ops { int (*handler)(void); }; +static int on_open(void) { return 1; } +static OPS_TYPE ops[] = { SLOT(on_open) }; + +int dispatch(union ops o) { return o.handler(); } +`); + const edges = await load(); + expect(has(edges, 'dispatch', 'on_open')).toBe(true); + }); + it('bridges the typedef-field + field←field double-hop (the hook_demo.c shape)', async () => { write('hook.c', ` typedef void (*hook_func)(void); diff --git a/__tests__/context.test.ts b/__tests__/context.test.ts index 52dae1fe8..c46c612d7 100644 --- a/__tests__/context.test.ts +++ b/__tests__/context.test.ts @@ -135,10 +135,16 @@ export function validateEmail(email: string): boolean { ` ); + fs.writeFileSync( + path.join(srcDir, 'callback_ops.c'), + `union CallbackOps { int (*run)(int); }; +` + ); + // Initialize CodeGraph cg = CodeGraph.initSync(testDir, { config: { - include: ['**/*.ts'], + include: ['**/*.ts', '**/*.c'], exclude: [], }, }); @@ -194,6 +200,15 @@ export function validateEmail(email: string): boolean { ).toBe(true); }); + it('includes union definitions in the default context search', async () => { + const result = await cg.findRelevantContext('CallbackOps'); + const union = [...result.nodes.values()].find( + (node) => node.kind === 'union' && node.name === 'CallbackOps' + ); + + expect(union).toBeDefined(); + }); + it('should include edges in the result', async () => { const result = await cg.findRelevantContext('checkout', { traversalDepth: 2, diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 6b64241e3..3bf260c8d 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -1045,6 +1045,34 @@ void initialize() { Packet(); } expect(outgoing.some((e) => e.kind === 'calls' && e.target === packet!.id)).toBe(false); }); + it('resolves a static call through an imported C++ union to its member', async () => { + fs.writeFileSync( + path.join(tempDir, 'ops.hpp'), + `union Ops { + static int run() { return 1; } +}; +` + ); + fs.writeFileSync( + path.join(tempDir, 'main.cpp'), + `#include "ops.hpp" + +int invoke() { return Ops::run(); } +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const invoke = cg.getNodesByKind('function').find((n) => n.name === 'invoke'); + const run = cg.getNodesByKind('method').find((n) => n.name === 'run'); + expect(invoke).toBeDefined(); + expect(run).toBeDefined(); + + const outgoing = cg.getOutgoingEdges(invoke!.id); + expect(outgoing.some((e) => e.kind === 'calls' && e.target === run!.id)).toBe(true); + }); + it('records instantiates for C++ stack/brace construction, targeting the class (#1035)', async () => { // `Calculator calc(0)` (direct-init) and `Widget w{1, 2}` (brace-init) // carry the constructor args directly on the declarator — there's no diff --git a/codegraph-kernel/src/cfnptr.rs b/codegraph-kernel/src/cfnptr.rs index bcee115f6..2ba81d130 100644 --- a/codegraph-kernel/src/cfnptr.rs +++ b/codegraph-kernel/src/cfnptr.rs @@ -432,8 +432,19 @@ struct InlineScan { fn scan_inline_structs(s: &[u8]) -> InlineScan { let mut out = InlineScan { ptr: false, types: Vec::new(), tags: Vec::new() }; let mut last = 0; - while let Some(t) = find_word(s, b"struct", last) { - let after_kw = t + 6; + loop { + let next_struct = find_word(s, b"struct", last); + let next_union = find_word(s, b"union", last); + let Some((t, keyword_len)) = (match (next_struct, next_union) { + (Some(st), Some(un)) if st < un => Some((st, 6)), + (Some(_), Some(un)) => Some((un, 5)), + (Some(st), None) => Some((st, 6)), + (None, Some(un)) => Some((un, 5)), + (None, None) => None, + }) else { + break; + }; + let after_kw = t + keyword_len; let ws = skip_jsws(s, after_kw); if ws == after_kw || !is_word_at(s, ws) { last = t + 1; @@ -569,10 +580,10 @@ fn init_body(s: &[u8], p: usize) -> Option<(String, usize)> { let i = skip_jsws(s, p); let mods = modifier_positions(s, i); for &pos in mods.iter().rev() { - for with_struct in [true, false] { - let q = if with_struct { - if s.len() >= pos + 6 && &s[pos..pos + 6] == b"struct" { - let e = pos + 6; + for keyword in [Some(b"struct".as_slice()), Some(b"union".as_slice()), None] { + let q = if let Some(keyword) = keyword { + if s.len() >= pos + keyword.len() && &s[pos..pos + keyword.len()] == keyword { + let e = pos + keyword.len(); let w = skip_jsws(s, e); if w == e { continue; @@ -729,12 +740,19 @@ fn alias_line(line: &[u8]) -> Option<&[u8]> { if v0 == name_end { return None; // [ \t]+ before the value } - // (?:struct[ \t]+)* greedy, k-descending on value failure. + // (?:(?:struct|union)[ \t]+)* greedy, k-descending on value failure. let mut stack = vec![v0]; loop { let cur = *stack.last().unwrap(); - if line.len() >= cur + 6 && &line[cur..cur + 6] == b"struct" { - let e = cur + 6; + let keyword_len = if line.len() >= cur + 6 && &line[cur..cur + 6] == b"struct" { + Some(6) + } else if line.len() >= cur + 5 && &line[cur..cur + 5] == b"union" { + Some(5) + } else { + None + }; + if let Some(keyword_len) = keyword_len { + let e = cur + keyword_len; let w2 = skip_sp_tab(line, e); if w2 > e { stack.push(w2); diff --git a/src/context/index.ts b/src/context/index.ts index e297caba4..ad4d63bc0 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -157,7 +157,7 @@ const DEFAULT_BUILD_OPTIONS: Required = { * they tell you something exists, not how it works. */ const HIGH_VALUE_NODE_KINDS: NodeKind[] = [ - 'function', 'method', 'class', 'interface', 'type_alias', 'struct', 'trait', + 'function', 'method', 'class', 'interface', 'type_alias', 'struct', 'union', 'trait', 'component', 'route', 'variable', 'constant', 'enum', 'module', 'namespace', ]; @@ -503,7 +503,7 @@ export class ContextBuilder { // like RestController, BulkRequest, AllocationService — not nodes named exactly that. // Also tries stem variants: "caching" → "cache" finds Cache, CacheBuilder. if (symbolsFromQuery.length > 0) { - const definitionKinds: NodeKind[] = ['class', 'interface', 'struct', 'trait', + const definitionKinds: NodeKind[] = ['class', 'interface', 'struct', 'union', 'trait', 'protocol', 'enum', 'type_alias']; // Expand symbols with stem variants for broader definition matching const expandedSymbols = new Set(symbolsFromQuery); @@ -754,7 +754,7 @@ export class ContextBuilder { // LIKE reliably finds these substring matches. Results are appended with // guaranteed slots so they don't compete with higher-scoring prefix matches. if (symbolsFromQuery.length > 0) { - const camelDefinitionKinds: NodeKind[] = ['class', 'interface', 'struct', 'trait', + const camelDefinitionKinds: NodeKind[] = ['class', 'interface', 'struct', 'union', 'trait', 'protocol', 'enum', 'type_alias']; // Callable kinds participate too: in service-layer codebases the // camel-infix definers of a queried FIELD are methods/functions @@ -977,7 +977,7 @@ export class ContextBuilder { // before reaching extends/implements neighbors. This dedicated step // ensures subclasses and superclasses always appear in results. // Budget: up to maxNodes/4 hierarchy nodes to avoid flooding. - const typeHierarchyKinds = new Set(['class', 'interface', 'struct', 'trait', 'protocol']); + const typeHierarchyKinds = new Set(['class', 'interface', 'struct', 'union', 'trait', 'protocol']); const maxHierarchyNodes = Math.ceil(opts.maxNodes / 4); let hierarchyNodesAdded = 0; for (const result of filteredResults) { diff --git a/src/graph/queries.ts b/src/graph/queries.ts index 9169dcdf5..e2af59335 100644 --- a/src/graph/queries.ts +++ b/src/graph/queries.ts @@ -173,6 +173,7 @@ export class GraphQueryManager { const allNodes: Node[] = []; const kinds: Node['kind'][] = [ 'class', + 'union', 'function', 'method', 'interface', @@ -347,6 +348,7 @@ export class GraphQueryManager { 'module', 'class', 'struct', + 'union', 'interface', 'trait', 'function', diff --git a/src/graph/traversal.ts b/src/graph/traversal.ts index 6cb00ac6b..5e9b354e9 100644 --- a/src/graph/traversal.ts +++ b/src/graph/traversal.ts @@ -564,7 +564,7 @@ export class GraphTraverser { // into their children so that callers of contained methods appear in impact const focalNode = this.queries.getNodeById(nodeId); if (focalNode) { - const containerKinds = new Set(['class', 'interface', 'struct', 'trait', 'protocol', 'module', 'enum']); + const containerKinds = new Set(['class', 'interface', 'struct', 'union', 'trait', 'protocol', 'module', 'enum']); if (containerKinds.has(focalNode.kind)) { const containsEdges = this.queries.getOutgoingEdges(nodeId, ['contains']); if (containsEdges.length > 0) { diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 52b7ed03f..0d18932d3 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -118,7 +118,7 @@ const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']); * multi-thousand-character wall of source that bloats the agent's context. */ const CONTAINER_NODE_KINDS = new Set([ - 'class', 'struct', 'interface', 'trait', 'protocol', 'enum', 'namespace', 'module', + 'class', 'struct', 'union', 'interface', 'trait', 'protocol', 'enum', 'namespace', 'module', ]); /** Last `::` / `.` / `/`-separated segment of a qualified symbol. */ @@ -2927,7 +2927,7 @@ export class ToolHandler { const ROOT_CAP = 5; // only the symbols the query actually targeted const FILE_CAP = 4; // caller files listed per symbol before "+N more" const MEANINGFUL = new Set([ - 'function', 'method', 'class', 'interface', 'struct', 'trait', 'protocol', + 'function', 'method', 'class', 'interface', 'struct', 'union', 'trait', 'protocol', 'enum', 'type_alias', 'component', 'constant', 'variable', 'property', 'field', ]); const rel = (p: string) => p.replace(/\\/g, '/'); @@ -3452,7 +3452,7 @@ export class ToolHandler { // displaces a flow-central file. Bounded: only the few named seeds, only the // types in their signatures. const CALLABLE_KINDS = new Set(['method', 'function', 'component', 'constructor']); - const TYPE_KINDS = new Set(['class', 'struct', 'interface', 'trait', 'protocol', 'enum', 'type_alias']); + const TYPE_KINDS = new Set(['class', 'struct', 'union', 'interface', 'trait', 'protocol', 'enum', 'type_alias']); const SIG_EDGE = new Set(['references', 'type_of', 'returns']); const changeSurfaceCandidates: Node[] = []; const seenChangeSurface = new Set(); @@ -4538,7 +4538,7 @@ export class ToolHandler { // query actually asked about (#185 follow-up — Session.swift in // Alamofire is the canonical case: the `Session` class spans ~1,400 // lines). We want the granular symbols inside, not the envelope. - const ENVELOPE_KINDS = new Set(['file', 'module', 'class', 'struct', 'interface', 'enum', 'namespace', 'protocol', 'trait', 'component']); + const ENVELOPE_KINDS = new Set(['file', 'module', 'class', 'struct', 'union', 'interface', 'enum', 'namespace', 'protocol', 'trait', 'component']); // Cluster from this file's gathered nodes PLUS any callable the agent NAMED that // lives here. Explore's relevance gather can miss a named method def in a huge // non-sibling file — Django's query.py is 3,040 lines and `_fetch_all` (L2237) diff --git a/src/resolution/c-fnptr-synthesizer.ts b/src/resolution/c-fnptr-synthesizer.ts index cc7ea1df9..1ab809918 100644 --- a/src/resolution/c-fnptr-synthesizer.ts +++ b/src/resolution/c-fnptr-synthesizer.ts @@ -296,7 +296,7 @@ function resolveTypeName(name: string, objEnv: Map | undefined): let n = name; for (let i = 0; objEnv && i < 5; i++) { const v = objEnv.get(n); - const t = v?.trim().match(/^(?:struct\s+)?(\w+)$/); + const t = v?.trim().match(/^(?:(?:struct|union)\s+)?(\w+)$/); if (!t) break; n = t[1]!; } @@ -370,20 +370,20 @@ const INCLUDABLE_EXT = /\.(def|inc|h|hh|hpp|hxx|c|cc|cpp|cxx|ipp|tcc|tbl)$/i; * are excluded: `resolveTypeName` would rewrite to a dead-end token that can * never name a struct, so skipping them is exact, and it drops the register * flood. */ -const OBJ_ALIAS_RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)[ \t]+(?:struct[ \t]+)*[A-Za-z_]\w*[ \t\r]*$/gm; +const OBJ_ALIAS_RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)[ \t]+(?:(?:struct|union)[ \t]+)*[A-Za-z_]\w*[ \t\r]*$/gm; /** `(?:struct )?TYPE name[opt] = {` initializers, where TYPE is a struct that * has ≥1 fn-pointer field. Handles both single (`= {…}`) and array * (`[] = { {…}, {…} }`) forms. Macro calls inside an element are expanded first. */ const INIT_RE = - /(?:^|[;{}])\s*(?:(?:static|const|extern|register|volatile)\s+)*(?:struct\s+)?(\w+)\s+(\w+)\s*(\[[^\]]*\])?\s*=\s*\{/g; + /(?:^|[;{}])\s*(?:(?:static|const|extern|register|volatile)\s+)*(?:(?:struct|union)\s+)?(\w+)\s+(\w+)\s*(\[[^\]]*\])?\s*=\s*\{/g; /** `struct TAG { … } var[opt] [= {…}]` — the struct is defined INLINE with the * table (vim's `cmdname`/`nv_cmd`); its layout never became a node, so parse it * here and register it before reading the entries. No leading anchor: a * `struct TAG {` with a brace body is always a definition (it may be preceded * by a `#define …` line ending in a digit, as in vim), and the trailing * `var … = {` check below is what distinguishes a TABLE from a plain type. */ -const INLINE_STRUCT_RE = /\bstruct\s+(\w+)\s*\{/g; +const INLINE_STRUCT_RE = /\b(?:struct|union)\s+(\w+)\s*\{/g; /** `(?:static …)* ELEMTYPE [*] name[…] = { … }` — a bare array of function * pointers (no struct wrapper). The optional `*` covers a function-TYPE * typedef element (`opcode_t *opcodes[]`); a function-pointer typedef element @@ -854,12 +854,14 @@ export async function cFnPointerDispatchEdges( if (fields.some((f) => f.isFnPtr)) structLayout.set(name, fields); }; - for (const st of (ctx.iterateNodesByKind?.('struct') ?? ctx.getNodesByKind('struct'))) { - if ((++scannedFiles & 255) === 0) await onYield(); - if (!C_CPP_EXT.test(st.filePath)) continue; - const rawFields = rawFieldsByNode.get(st.id); - if (!rawFields) continue; // file unreadable or body unparsable at sweep time — the old pass skipped it too - registerStructLayout(st.name, classifyFields(rawFields)); + for (const kind of ['struct', 'union'] as const) { + for (const st of (ctx.iterateNodesByKind?.(kind) ?? ctx.getNodesByKind(kind))) { + if ((++scannedFiles & 255) === 0) await onYield(); + if (!C_CPP_EXT.test(st.filePath)) continue; + const rawFields = rawFieldsByNode.get(st.id); + if (!rawFields) continue; // file unreadable or body unparsable at sweep time — the old pass skipped it too + registerStructLayout(st.name, classifyFields(rawFields)); + } } rawFieldsByNode.clear(); if (prof) { prof.B = Date.now() - tPass; tPass = Date.now(); } @@ -1211,7 +1213,7 @@ export async function cFnPointerDispatchEdges( const recvTypeIn = (fnSrc: string, recv: string): string | null => { let re = recvReCache.get(recv); if (!re) { - re = new RegExp(`(?:struct\\s+)?(\\w+)\\s*\\*?\\s*\\b${recv}\\b\\s*(?:[,)=;]|\\[)`, 'g'); + re = new RegExp(`(?:(?:struct|union)\\s+)?(\\w+)\\s*\\*?\\s*\\b${recv}\\b\\s*(?:[,)=;]|\\[)`, 'g'); recvReCache.set(recv, re); } re.lastIndex = 0; @@ -1230,7 +1232,7 @@ export async function cFnPointerDispatchEdges( const varTypeIn = (fnSrc: string, v: string): string | null => { let re = varReCache.get(v); if (!re) { - re = new RegExp(`(?:struct\\s+)?(\\w+)\\s*\\*?\\s*\\b${escapeRe(v)}\\b\\s*(?:[,)=;]|\\[)`, 'g'); + re = new RegExp(`(?:(?:struct|union)\\s+)?(\\w+)\\s*\\*?\\s*\\b${escapeRe(v)}\\b\\s*(?:[,)=;]|\\[)`, 'g'); varReCache.set(v, re); } re.lastIndex = 0; diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index cf5620cd8..a32a97916 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -2192,7 +2192,7 @@ function findExportedSymbolWalk( /** Node kinds that own static members reachable as `Container.member`. */ const STATIC_MEMBER_CONTAINERS = new Set([ - 'class', 'struct', 'interface', 'enum', 'trait', 'protocol', + 'class', 'struct', 'union', 'interface', 'enum', 'trait', 'protocol', ]); /**