فهرست منبع

Merge pull request #1516 from ctype-lab/fix/union-declarations-not-indexed

fix(c,cpp,objc,rust): index union declarations as a first-class `union` node kind (#1515)
Colby Mchenry 4 هفته پیش
والد
کامیت
c6aaa20358

+ 2 - 0
CHANGELOG.md

@@ -21,6 +21,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### Fixes
 
+- 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, the methods from that impl were left pointing at a type the graph did not contain, and asking which types implement a trait quietly skipped the union ones. A union-shaped dispatch table in C now resolves its function pointers like a struct-shaped one. A `typedef union { … } Name;` in C keeps the typedef's name and is no longer mistaken for a plain type alias. Thanks @ctype-lab. Re-index after upgrading to pick up unions in existing projects. (#1515)
+
 - A long-lived index no longer drifts away from what a fresh `codegraph index` would produce. When a file gained or lost a symbol, references to that name in files the sync never touched kept pointing at the definition that was correct before the change, and — because nothing distinguished two same-named definitions — the winner could come down to the order files happened to be written, which differs between a full index and a sync. On this project's own repository, replaying 80 commits through `sync` left 5.7% of connections wrong; it is now 1.3%, and the wrong-answers-still-being-asserted half drops by 99.7%. Since call edges are what flow questions follow and what `codegraph_explore` ranks files by, this quietly degraded answers as an index aged, with nothing to indicate it. Syncing is unchanged in speed, and an edit that only changes a function's body does no extra work at all. Set `CODEGRAPH_NO_REBIND=1` to opt out.
 - `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)

+ 1 - 1
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

+ 39 - 0
__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);

+ 16 - 1
__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,

+ 126 - 0
__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 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('union');
+
+    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('union');
+
+    // 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 <anonymous> 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('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 === '<anonymous>')).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('union');
+
+    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;
@@ -8336,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');

+ 16 - 0
__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; }

+ 7 - 0
__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 {}

+ 121 - 0
__tests__/resolution.test.ts

@@ -1020,6 +1020,105 @@ 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('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('bridges a Rust trait method to a union implementor (interface-impl)', async () => {
+      // A Rust union can `impl Trait` exactly as a struct can. Trait-dispatch
+      // synthesis enumerates concrete kinds explicitly, so a union implementor
+      // only becomes a candidate if `union` is in that list. Making unions
+      // first-class nodes is not enough on its own: without this, `Reg` has an
+      // `implements` edge and is still silently dropped from the fan-out, so
+      // "who implements this trait" answers wrongly rather than incompletely
+      // — the struct beside it resolves and the union does not (#1515).
+      fs.writeFileSync(
+        path.join(tempDir, 'lib.rs'),
+        `pub union Reg { pub raw: u32 }
+pub struct Ctl { pub n: u32 }
+
+pub trait Describe { fn describe(&self) -> String; }
+
+impl Describe for Reg { fn describe(&self) -> String { "reg".into() } }
+impl Describe for Ctl { fn describe(&self) -> String { "ctl".into() } }
+`
+      );
+
+      cg = await CodeGraph.init(tempDir, { index: true });
+
+      const methods = cg.getNodesByKind('method');
+      const traitMethod = methods.find((n) => n.qualifiedName === 'Describe::describe');
+      const unionImpl = methods.find((n) => n.qualifiedName === 'Reg::describe');
+      const structImpl = methods.find((n) => n.qualifiedName === 'Ctl::describe');
+      expect(traitMethod, 'trait method should be in the graph').toBeDefined();
+      expect(unionImpl, 'union impl method should be in the graph').toBeDefined();
+      expect(structImpl, 'struct impl method should be in the graph').toBeDefined();
+
+      const synth = cg
+        .getOutgoingEdges(traitMethod!.id)
+        .filter((e) => e.kind === 'calls' && e.provenance === 'heuristic');
+      const targets = new Set(synth.map((e) => e.target));
+
+      // The struct implementor bridged before unions were nodes at all; it is
+      // the control that proves the synthesizer ran for this trait.
+      expect(targets.has(structImpl!.id), 'struct implementor should bridge').toBe(true);
+      expect(targets.has(unionImpl!.id), 'union implementor should bridge').toBe(true);
+
+      const unionEdge = synth.find((e) => e.target === unionImpl!.id);
+      expect(
+        (unionEdge!.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy
+      ).toBe('interface-impl');
+    });
+
     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 +2268,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',

+ 2 - 1
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.

+ 26 - 13
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);
@@ -813,7 +813,10 @@ impl<'t> Walker<'t> {
             self.extract_class(node);
             skip_children = true;
         } else if kind == "struct_specifier" {
-            self.extract_struct(node);
+            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);
@@ -925,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 {
@@ -971,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 {
@@ -980,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);
@@ -1045,21 +1048,27 @@ impl<'t> Walker<'t> {
                 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"));
+                .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);
@@ -1557,7 +1566,11 @@ impl<'t> Walker<'t> {
             return;
         }
         if kind == "struct_specifier" {
-            self.extract_struct(node);
+            self.extract_aggregate(node, "struct");
+            return;
+        }
+        if kind == "union_specifier" {
+            self.extract_aggregate(node, "union");
             return;
         }
         if kind == "enum_specifier" {

+ 27 - 9
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);

+ 17 - 11
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);
@@ -447,7 +447,10 @@ impl<'t> Walker<'t> {
             self.extract_interface(node);
             skip_children = true;
         } else if kind == "struct_item" {
-            self.extract_struct(node);
+            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);
@@ -529,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 {
@@ -579,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 {
@@ -589,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);
@@ -1057,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);
@@ -1131,7 +1133,11 @@ impl<'t> Walker<'t> {
 
         // Structural nodes inside bodies.
         if kind == "struct_item" {
-            self.extract_struct(node);
+            self.extract_aggregate(node, "struct");
+            return;
+        }
+        if kind == "union_item" {
+            self.extract_aggregate(node, "union");
             return;
         }
         if kind == "enum_item" {

+ 2 - 0
docs/design/ccpp-kernel-port-checklist.md

@@ -140,6 +140,8 @@ walker mirrors, with file:line anchors (as of `705e501`). Read WITH
 
 **cExtractor (line 180):** functionTypes=[function_definition]; NO
 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];

+ 3 - 1
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`];
+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`].

+ 5 - 5
src/context/index.ts

@@ -157,7 +157,7 @@ const DEFAULT_BUILD_OPTIONS: Required<BuildContextOptions> = {
  * 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);
@@ -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[];
@@ -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<string>(['class', 'interface', 'struct', 'trait', 'protocol']);
+    const typeHierarchyKinds = new Set<string>(['class', 'interface', 'struct', 'union', 'trait', 'protocol']);
     const maxHierarchyNodes = Math.ceil(opts.maxNodes / 4);
     let hierarchyNodesAdded = 0;
     for (const result of filteredResults) {

+ 23 - 4
src/extraction/languages/c-cpp.ts

@@ -187,6 +187,9 @@ export const cExtractor: LanguageExtractor = {
   methodTypes: [],
   interfaceTypes: [],
   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
@@ -207,12 +210,19 @@ 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, `<anonymous>` 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' &&
+        getChildByField(child, 'body')
+      )
+        return 'struct';
+      if (child.type === 'union_specifier' && getChildByField(child, 'body')) return 'union';
     }
     return undefined;
   },
@@ -1552,6 +1562,9 @@ export const cppExtractor: LanguageExtractor = {
   methodTypes: ['function_definition'],
   interfaceTypes: [],
   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
@@ -1581,12 +1594,18 @@ 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' &&
+        getChildByField(child, 'body')
+      )
+        return 'struct';
+      if (child.type === 'union_specifier' && getChildByField(child, 'body')) return 'union';
     }
     return undefined;
   },

+ 5 - 1
src/extraction/languages/objc.ts

@@ -103,6 +103,8 @@ export const objcExtractor: LanguageExtractor = {
   interfaceTypes: ['protocol_declaration'],
   interfaceKind: 'protocol',
   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'],
@@ -128,7 +130,9 @@ 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';
+      if (child.type === 'struct_specifier' && getChildByField(child, 'body'))
+        return 'struct';
+      if (child.type === 'union_specifier' && getChildByField(child, 'body')) return 'union';
     }
     return undefined;
   },

+ 3 - 0
src/extraction/languages/rust.ts

@@ -42,6 +42,9 @@ export const rustExtractor: LanguageExtractor = {
   methodTypes: ['function_item', 'function_signature_item'],
   interfaceTypes: ['trait_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

+ 2 - 0
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') */

+ 38 - 15
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;
       }
     }

+ 2 - 0
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',

+ 1 - 1
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) {

+ 6 - 6
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<NodeKind>([
-  'class', 'struct', 'interface', 'trait', 'protocol', 'enum', 'namespace', 'module',
+  'class', 'struct', 'union', 'interface', 'trait', 'protocol', 'enum', 'namespace', 'module',
 ]);
 
 /** Last `::` / `.` / `/`-separated segment of a qualified symbol. */
@@ -343,7 +343,7 @@ export function getExploreOutputBudget(fileCount: number): ExploreOutputBudget {
  */
 export const RELEVANCE_KIND_WEIGHT: Readonly<Record<string, number>> = {
   // 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.
@@ -3025,7 +3025,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<string>([
-      '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, '/');
@@ -3568,7 +3568,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<string>();
@@ -4103,7 +4103,7 @@ export class ToolHandler {
     const superMany = new Map<string, boolean>();
     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) {
@@ -4854,7 +4854,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)

+ 16 - 14
src/resolution/c-fnptr-synthesizer.ts

@@ -296,7 +296,7 @@ function resolveTypeName(name: string, objEnv: Map<string, string> | 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
@@ -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('{');
@@ -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;

+ 1 - 1
src/resolution/callback-synthesizer.ts

@@ -1061,7 +1061,7 @@ async function interfaceOverrideEdges(queries: QueryBuilder, onYield: MaybeYield
   // Concrete-side kinds vary by language: `class` covers Java / Kotlin /
   // C# / TS / Swift-classes / Scala-classes; `struct` covers Swift value
   // types that conform to protocols. Iterate both.
-  const concreteKinds = ['class', 'struct'] as const;
+  const concreteKinds = ['class', 'struct', 'union'] as const;
   for (const kind of concreteKinds) {
   for (const cls of queries.iterateNodesByKind(kind)) {
     if ((++scanned255 & 63) === 0) await onYield();

+ 2 - 1
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' ||
@@ -2191,7 +2192,7 @@ function findExportedSymbolWalk(
 
 /** Node kinds that own static members reachable as `Container.member`. */
 const STATIC_MEMBER_CONTAINERS = new Set<Node['kind']>([
-  'class', 'struct', 'interface', 'enum', 'trait', 'protocol',
+  'class', 'struct', 'union', 'interface', 'enum', 'trait', 'protocol',
 ]);
 
 /**

+ 5 - 2
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';
         }
       }

+ 5 - 4
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;
 
@@ -2107,6 +2107,7 @@ function findBestMatch(
       if (
         candidate.kind === 'class' ||
         candidate.kind === 'struct' ||
+        candidate.kind === 'union' ||
         candidate.kind === 'interface'
       ) {
         score += 25;

+ 1 - 0
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,

+ 1 - 0
src/types.ts

@@ -42,6 +42,7 @@ export const NODE_KINDS = [
   'export',
   'route',
   'component',
+  'union',
 ] as const;
 
 export type NodeKind = (typeof NODE_KINDS)[number];