diff --git a/__tests__/c-fnptr-synthesizer.test.ts b/__tests__/c-fnptr-synthesizer.test.ts index 202c963..bba9fca 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 52dae1f..c46c612 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 6b64241..3bf260c 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 bcee115..2ba81d1 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 e297cab..ad4d63b 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 9169dcd..e2af593 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 6cb00ac..5e9b354 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 52b7ed0..0d18932 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 cc7ea1d..1ab8099 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 cf5620c..a32a979 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', ]); /**