Prechádzať zdrojové kódy

feat(c/c++): resolve function-pointer dispatch (#932) (#954)

C/C++ polymorphism is the function pointer: a struct fn-pointer field, concrete
functions registered into it through a table (`{"add", cmd_add}`), a designated
initializer (`.handler = on_open`), or an assignment, then dispatched indirectly
(`p->fn(argv)`). Static extraction captures neither the registration→field
binding nor the indirect call, so the dispatcher→handler edge was missing — git's
run_builtin looked like it called nothing, a vtable's implementations had no
callers, and the hook_demo.c in the issue was unreachable.

Add a resolution-layer synthesizer keyed by (struct type, fn-pointer field). It
reads source (the established Celery/Sidekiq/Spring pattern — C extraction has no
struct fields or indirect-call edges to build on) in passes: collect fn-pointer
typedefs, parse struct field layouts, collect registrations (positional matched
by field index, designated, and assignment), propagate field←field assignments
(so a generic hook slot reassigned from a registry — the hook_demo.c
`h->func = found->fn` shape — inherits the registry field's handlers), then link
each indirect dispatch site to the registered handlers. Receiver type resolves
from the enclosing function's params/locals, falling back to a field name unique
to one struct. Covers both the command-table idiom (git, redis) and the
ops-struct/vtable idiom (curl content-encoders, protocol handlers).

Pure edge synthesis (no node growth); high precision via the (struct, field) key.

Validated: git 502 edges (run_builtin→cmd_* plus git_hash_algo/archiver/reftable
vtables), redis 357 (dictType.hashFunction, connection + reply-object vtables),
curl 478 (Curl_cwtype.do_init → deflate/gzip/brotli/zstd); 0 non-function targets
on all three; node-stable; 0 on the lua control (its {name,fn} tables register
into the Lua VM, with no C indirect call to bridge). Full suite 1665 pass.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby Mchenry 17 hodín pred
rodič
commit
ba209d9489

+ 1 - 0
CHANGELOG.md

@@ -22,6 +22,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - `codegraph_explore` now follows **Sidekiq** background-job dispatch in Ruby. A `DestroyUserWorker.perform_async(id)` (or `.perform_in` / `.perform_at`) call now links to that worker's `perform` method — usually in `app/workers/` away from the controller or model that enqueues it — so "what runs in the background here?" traces from the enqueue straight into the job body. Both the modern `include Sidekiq::Job` and the older `Sidekiq::Worker` are recognized, namespaced workers resolve to the right class even when several share a name (e.g. `Comments::NotifyWorker` vs `Articles::NotifyWorker`), and Rails ActiveJob's `perform_later` — a different mechanism — is intentionally left alone.
 - `codegraph_explore` now follows **Laravel events** in PHP. An `event(new OrderShipped($order))` call now links to every listener that handles it — each listener's `handle()` method, usually a separate `app/Listeners/` class — so "what reacts to this event?" traces from the dispatch straight into the listener bodies. Listeners are found both ways Laravel registers them: by a typed `handle(OrderShipped $event)` (auto-discovery, including a `handle(A|B $event)` union that listens for two events) and by the `protected $listen` map in your `EventServiceProvider` (which also catches a listener whose `handle()` has no type-hint). One event fans out to all its listeners, and queued jobs — dispatched via `::dispatch()` rather than `event()` — are correctly left out.
 - CodeGraph now understands **Lombok**-generated methods in Java. `@Getter`, `@Setter`, `@Data`, `@Value`, and `@Builder` generate getters, setters, `builder()`, `equals`/`hashCode`/`toString`, and the `@Slf4j` `log` field at compile time, so those methods never appear in the source — and a `user.getName()`, `User.builder()`, or `log.info(...)` call used to resolve to nothing, silently breaking call-chain analysis (the agent would conclude the method didn't exist and reconstruct it by hand). Those members are now indexed from the annotations and fields, so they appear in `codegraph search` and `codegraph_explore`/`codegraph_node`, and callers trace through them like any hand-written method. They're marked as Lombok-generated so they read as generated, not hand-written; a method you write yourself is never overridden, static fields get no accessor, and a class without Lombok is unaffected. Thanks @git87663849. (#912)
+- `codegraph_explore` now follows **C and C++ function-pointer dispatch**. C does polymorphism with function pointers: a struct carries a function-pointer field, concrete functions are registered into it through a table (`static struct cmd commands[] = {{"add", cmd_add}, …}`), a designated initializer (`.handler = on_open`), or an assignment, and the code dispatches indirectly (`p->fn(argv)`). None of that was visible to analysis — the indirect call resolved to nothing, so `git`'s command runner looked like it called nothing and a vtable's implementations had no callers. CodeGraph now links the dispatch site to the registered handlers, keyed by the struct field, so "what runs when this dispatches?" traces from `p->fn(...)` into every function registered for that field. This covers the command-table idiom (git, redis) and the ops-struct/vtable idiom (curl's content-encoders, protocol handlers), including the case where a generic hook slot is reassigned from a registry (`h->func = found->fn`). It stays precise — distinct function-pointer fields don't cross-link, a plain data field is never treated as a dispatch, and a project without function-pointer dispatch is unaffected. (#932)
 
 - `codegraph_explore` now surfaces the right code in large multi-layer projects. When you ask a backend-flow question in a repo that pairs an API server with a big frontend that mirrors the same domain words — say an `app/` admin UI sitting over an `api/` server — the server-side file that genuinely matches several of your query's terms is no longer pushed out of the results by the larger, more interconnected frontend layer. A file corroborated by two or more distinct query terms is now kept in the answer even when a denser unrelated layer would otherwise crowd it out, so "how does X read items / handle the request" returns the service or handler that does the work instead of a wall of frontend views. Single-layer projects are unaffected; set `CODEGRAPH_RANK_NO_MULTITERM=1` to revert to the previous ranking.
 - Impact and blast-radius analysis for TypeScript, JavaScript, Go, Python, Rust, Ruby, C, Java, C#, PHP, Scala, Kotlin, Swift, Dart, and Pascal/Delphi now understands the readers of a constant. When you change a file-scope, package-level, module-level, or class-level constant — a config object, a lookup table, a shared constant — the other symbols in that file that read it now show up as affected, where before they were invisible (impact only followed calls, imports, and inheritance, so a constant's consumers looked like "nothing depends on this"). This makes `codegraph impact`, and the impact trail in `codegraph_explore`/`codegraph_node`, catch the "change this table, break its readers" class of change. It's on by default and adds no nodes to your graph; bundled/minified files and ambiguously-shadowed names are skipped to keep results precise. Set `CODEGRAPH_VALUE_REFS=0` to turn it off.

+ 147 - 0
__tests__/c-fnptr-synthesizer.test.ts

@@ -0,0 +1,147 @@
+/**
+ * C/C++ function-pointer dispatch synthesis (#932).
+ *
+ * C polymorphism is the function pointer: a struct fn-pointer field, registered
+ * to concrete functions in a table (positional `{"add", cmd_add}` or designated
+ * `.fn = cmd_add`) or by assignment, then dispatched indirectly (`p->fn(argv)`).
+ * Static extraction sees neither the registration→field binding nor the
+ * indirect call, so the dispatcher→handler edge is missing. These tests prove
+ * the bridge keyed by (struct type, fn-pointer field): the command-table shape,
+ * designated init, the typedef'd-field + field←field double-hop (the issue's
+ * own hook_demo.c shape), by-value dispatch, and the precision boundaries
+ * (a data field is never bridged, distinct fn-pointer fields don't cross-bleed,
+ * and a non-C project is a no-op).
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as os from 'node:os';
+import { CodeGraph } from '../src';
+
+describe('c-fnptr dispatch synthesizer', () => {
+  let dir: string;
+  beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfp-')); });
+  afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
+
+  const write = (rel: string, body: string) => {
+    const p = path.join(dir, rel);
+    fs.mkdirSync(path.dirname(p), { recursive: true });
+    fs.writeFileSync(p, body);
+  };
+
+  const load = async () => {
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const db = (cg as any).db.db;
+    const edges: { src: string; tgt: string; via: string }[] = db
+      .prepare(
+        `SELECT s.name src, t.name tgt, json_extract(e.metadata,'$.via') via
+         FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
+         WHERE json_extract(e.metadata,'$.synthesizedBy') = 'fn-pointer-dispatch'`
+      )
+      .all();
+    cg.close?.();
+    return edges;
+  };
+  const has = (edges: any[], src: string, tgt: string) => edges.some((e) => e.src === src && e.tgt === tgt);
+
+  it('bridges a {name, fn} command table dispatched through p->fn() (the git shape)', async () => {
+    write('cmd.c', `
+struct cmd { const char *name; int (*fn)(int argc); };
+static int cmd_add(int argc) { return argc + 1; }
+static int cmd_rm(int argc) { return argc - 1; }
+static int cmd_noop(int argc) { return argc; }   /* defined, NOT in the table */
+
+static struct cmd commands[] = {
+    { "add", cmd_add },
+    { "rm",  cmd_rm  },
+};
+
+int run_builtin(struct cmd *p, int argc) {
+    return p->fn(argc);
+}
+`);
+    const edges = await load();
+    expect(has(edges, 'run_builtin', 'cmd_add')).toBe(true);
+    expect(has(edges, 'run_builtin', 'cmd_rm')).toBe(true);
+    expect(edges.every((e) => e.via === 'cmd.fn')).toBe(true);
+    // PRECISION: a function not registered in the table is never a target.
+    expect(has(edges, 'run_builtin', 'cmd_noop')).toBe(false);
+  });
+
+  it('bridges designated-init (.handler = fn) and by-value c.fn() dispatch', async () => {
+    write('ops.c', `
+struct ops { int (*handler)(void); int size; };
+static int on_open(void) { return 1; }
+static struct ops the_ops = { .handler = on_open, .size = 4 };
+
+int dispatch(struct 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 the typedef-field + field←field double-hop (the hook_demo.c shape)', async () => {
+    write('hook.c', `
+typedef void (*hook_func)(void);
+struct hooks { hook_func func; };
+struct entry { const char *name; hook_func fn; };
+
+static void hk_set(void) {}
+static void hk_get(void) {}
+
+static const struct entry registry[] = {
+    { "set", hk_set },
+    { "get", hk_get },
+};
+
+void call(struct hooks *h, const struct entry *found) {
+    h->func = found->fn;   /* generic slot reassigned from the registry */
+    h->func();             /* dispatch through hooks.func */
+}
+`);
+    const edges = await load();
+    // hooks.func has no direct registration; it inherits entry.fn's via h->func = found->fn.
+    expect(has(edges, 'call', 'hk_set')).toBe(true);
+    expect(has(edges, 'call', 'hk_get')).toBe(true);
+  });
+
+  it('keys by (struct, field): distinct fn-pointer fields do not cross-bleed', async () => {
+    write('vtable.c', `
+struct io { int (*read)(void); int (*write)(int); };
+static int do_read(void) { return 0; }
+static int do_write(int x) { return x; }
+static struct io io = { .read = do_read, .write = do_write };
+
+int only_reads(struct io *p) { return p->read(); }
+`);
+    const edges = await load();
+    // only_reads dispatches ->read → do_read, and must NOT reach do_write (a different field).
+    expect(has(edges, 'only_reads', 'do_read')).toBe(true);
+    expect(has(edges, 'only_reads', 'do_write')).toBe(false);
+  });
+
+  it('does not bridge a plain data field, and no-ops on a struct with no dispatch', async () => {
+    write('data.c', `
+struct box { int count; int (*fn)(void); };
+static int helper(void) { return 0; }
+static struct box b = { .count = 3, .fn = helper };
+
+/* reads a data field and never dispatches the fn pointer */
+int total(struct box *x) { return x->count + 1; }
+`);
+    const edges = await load();
+    // No indirect dispatch happens, so there are no synthesized edges at all.
+    expect(edges.length).toBe(0);
+  });
+
+  it('is a no-op on a project with no C/C++ (clean control)', async () => {
+    write('app.js', `
+const handlers = { add: (x) => x + 1, rm: (x) => x - 1 };
+function run(name, x) { return handlers[name](x); }
+`);
+    const edges = await load();
+    expect(edges.length).toBe(0);
+  });
+});

+ 1 - 0
docs/design/dispatch-synthesizer-backlog.md

@@ -91,6 +91,7 @@ Status legend (matches the playbook): ✅ done+validated · 🟡 shipped but und
 | MediatR | `mediatr-dispatch` | ✅ **shipped (2026-06-20)** — `_mediator.Send(x)`/`.Publish(x)` → the `Handle` of `IRequestHandler<X>`/`INotificationHandler<X>` by request type; 100% precision jasontaylor (9) / eShop (9, variable-passed), 0 on Newtonsoft control. Type from class base-list (C# has no signature) + arg resolved inline/local/param; receiver + handler-map gates. |
 | Sidekiq | `sidekiq-dispatch` | ✅ **shipped (2026-06-20)** — `W.perform_async/_in/_at(…)` → `W#perform`, gated on `include Sidekiq::Job`/`Worker`; 100% precision loomio (47) / forem (142, both aliases), 0 on jekyll control. Name-keyed; namespaced collisions disambiguated by qualified name; ActiveJob `perform_later` excluded. |
 | Laravel events | `laravel-event` | ✅ **shipped (2026-06-21)** — `event(new XEvent)` → each listener's `handle`, via typed `handle(XEvent $e)` (auto-discovery, union-split) AND the `$listen` map (covers untyped handles); 100% precision koel (9, `$listen`) / firefly (141, auto-discovery), 0 on guzzle control. Jobs excluded (they use `::dispatch`). |
+| C/C++ fn-pointer dispatch | `fn-pointer-dispatch` | ✅ **shipped (2026-06-22)** — FIRST C / systems-language member (#932). Keyed by **(struct type, fn-pointer field)**: a fn registered to `S.field` (positional init matched by field index, designated `.field=fn`, or `x->field=fn`) ← linked → an indirect dispatch `recv->field(…)` whose receiver resolves to `S` (param/local type, else unique-field fallback). Source-read synth (`c-fnptr-synthesizer.ts`, regex over `ctx.readFile`), NOT extraction — handles the typedef'd field (`hook_func func`) + the **field←field double-hop** (`h->func = found->fn`, the issue's `hook_demo.c` shape). Covers BOTH the command-table idiom (Shape 1) and the ops-struct/vtable idiom (Shape 2) with the same key. Validated: **git 502** (`run_builtin→cmd_*` + 7 real vtables), **redis 357** (`dictType.hashFunction`, conn vtable), **curl 478** (`Curl_cwtype.do_init→{deflate,gzip,brotli,zstd}_do_init`); **0 non-function targets** everywhere, node-stable (pure edge synth), **0 on lua** (its `{name,fn}` tables register into the VM — no C indirect call → correctly nothing to bridge). **Deferred:** direct fn-pointer *variables* (`fp=f; fp()` — not field-keyed), array-of-fn-pointers without a struct, C++ *class* fn-pointer fields (virtual dispatch already covered by `interface-impl`/`cpp-override`), and macro-built tables (redis `MAKE_CMD(…)` proc arg lives inside a macro call, not a struct initializer, so `redisCommand.proc` registrations are unbridged). |
 | (see playbook §6 / `callback-synthesizer.ts` for the other ~20 channels) | | |
 
 ### redux-thunk follow-ups (found by the n>1 validation — this is exactly what it's for)

+ 8 - 0
src/mcp/tools.ts

@@ -1643,6 +1643,14 @@ export class ToolHandler {
         registeredAt,
       };
     }
+    if (m?.synthesizedBy === 'fn-pointer-dispatch') {
+      const via = m.via ? `\`${String(m.via)}\`` : 'a function pointer';
+      return {
+        label: `function-pointer dispatch via ${via} (dynamic dispatch)`,
+        compact: `dynamic: fn-pointer ${m.via ? String(m.via) : ''}${at}`,
+        registeredAt,
+      };
+    }
     // Generic fallback for any other synthesizer (redux-thunk, gin-middleware-chain,
     // flutter-build, …): a synthesized hop must never read as a bare static `calls`.
     // It's a dynamic-dispatch bridge — label it as one and keep its wiring site.

+ 359 - 0
src/resolution/c-fnptr-synthesizer.ts

@@ -0,0 +1,359 @@
+/**
+ * C/C++ function-pointer dispatch synthesis (#932).
+ *
+ * C/C++ polymorphism is the function pointer: a struct carries a fn-pointer
+ * field (`int (*fn)(int)`, or a fn-pointer-typedef field `hook_func func`),
+ * concrete functions are *registered* into it through a table
+ * (`static struct cmd cmds[] = {{"add", cmd_add}, …}`, a designated
+ * `.fn = cmd_add`, or `x->fn = cmd_add`), and the dispatcher calls through it
+ * indirectly (`p->fn(argv)`). Static extraction captures neither the
+ * registration→field binding nor the indirect call, so the dispatcher→handler
+ * edge is missing and `git`'s `run_builtin` looks like it calls nothing, the
+ * hooks in `hook_demo.c` are unreachable, etc.
+ *
+ * This bridges it, keyed by **(struct type, fn-pointer field)**:
+ *   • registrations — a function bound to `S.field` via a positional
+ *     initializer (matched by field index), a designated `.field = fn`, or a
+ *     direct `x.field = fn` / `x->field = fn` assignment;
+ *   • dispatch — `recv->field(…)` / `recv.field(…)` where `recv` resolves to a
+ *     value of struct type `S` (from the enclosing function's params / locals),
+ *     falling back to the field name when it is unique to one struct;
+ *   • field←field propagation — `a->f = b->g` merges `B.g`'s handlers into
+ *     `A.f`, so a generic single-slot hook that is reassigned from a registry
+ *     (the `hook_demo.c` shape: `h->func = found->fn`) still resolves.
+ *
+ * Whole-graph pass after base resolution; all edges are `provenance:'heuristic'`
+ * (`synthesizedBy:'fn-pointer-dispatch'`). High precision via the (type, field)
+ * key + a real-function gate; a project with no fn-pointer dispatch is a no-op.
+ */
+import type { Edge, Node } from '../types';
+import type { QueryBuilder } from '../db/queries';
+import type { ResolutionContext } from './types';
+import { stripCommentsForRegex } from './strip-comments';
+
+const C_CPP_EXT = /\.(c|h|cc|cpp|cxx|hpp|hh|hxx|cppm|ipp|inl|tcc)$/i;
+const FN_KINDS = new Set(['function', 'method']);
+const FANOUT_CAP = 300; // a real command table (git ~150) is legitimate fan-out; this only stops pathological cases.
+
+/** A struct field, in declaration order, flagged when it is a function pointer. */
+interface FieldInfo {
+  name: string;
+  index: number;
+  isFnPtr: boolean;
+}
+
+function sliceLines(content: string, startLine?: number, endLine?: number): string {
+  if (!startLine) return '';
+  return content.split('\n').slice(startLine - 1, endLine ?? startLine).join('\n');
+}
+
+/** Index of the `}` matching the `{` at `open` (which must point at a `{`). -1 if unbalanced. */
+function matchBrace(src: string, open: number): number {
+  let depth = 0;
+  for (let i = open; i < src.length; i++) {
+    const c = src[i];
+    if (c === '{') depth++;
+    else if (c === '}') {
+      depth--;
+      if (depth === 0) return i;
+    }
+  }
+  return -1;
+}
+
+/** Split `body` on `sep` at brace/paren/bracket depth 0 (commas inside `{…}` / `(…)` stay together). */
+function splitTopLevel(body: string, sep: string): string[] {
+  const out: string[] = [];
+  let depth = 0;
+  let start = 0;
+  for (let i = 0; i < body.length; i++) {
+    const c = body[i]!;
+    if (c === '{' || c === '(' || c === '[') depth++;
+    else if (c === '}' || c === ')' || c === ']') depth--;
+    else if (c === sep && depth === 0) {
+      out.push(body.slice(start, i));
+      start = i + 1;
+    }
+  }
+  out.push(body.slice(start));
+  return out;
+}
+
+/** A fn-pointer field looks like `… (*name)(…)` — capture `name`. */
+const FNPTR_DECL_RE = /\(\s*\*\s*(\w+)\s*\)\s*\(/;
+/** `typedef RET (*NAME)(…)` — a function-pointer typedef. */
+const FNPTR_TYPEDEF_RE = /\btypedef\b[^;{}]*?\(\s*\*\s*(\w+)\s*\)\s*\(/g;
+
+export function cFnPointerDispatchEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] {
+  const files = ctx.getAllFiles().filter((f) => C_CPP_EXT.test(f));
+  if (files.length === 0) return [];
+
+  // Cache stripped source per file (read once, reused across passes).
+  const srcCache = new Map<string, string>();
+  const src = (file: string): string | null => {
+    if (srcCache.has(file)) return srcCache.get(file)!;
+    const raw = ctx.readFile(file);
+    const s = raw == null ? '' : stripCommentsForRegex(raw, 'c');
+    srcCache.set(file, s);
+    return raw == null ? null : s;
+  };
+
+  // ---- Pass A: function-pointer typedefs (cross-file) ----
+  const fnPtrTypedefs = new Set<string>();
+  for (const file of files) {
+    const s = src(file);
+    if (!s || !s.includes('typedef')) continue;
+    FNPTR_TYPEDEF_RE.lastIndex = 0;
+    let m: RegExpExecArray | null;
+    while ((m = FNPTR_TYPEDEF_RE.exec(s))) fnPtrTypedefs.add(m[1]!);
+  }
+
+  // ---- Pass B: struct field layouts ----
+  // structLayout: struct name → ordered fields (with fn-pointer flag).
+  // fieldToStructs: fn-pointer field name → set of struct names that declare it.
+  const structLayout = new Map<string, FieldInfo[]>();
+  const fieldToStructs = new Map<string, Set<string>>();
+  for (const st of ctx.getNodesByKind('struct')) {
+    if (!C_CPP_EXT.test(st.filePath)) continue;
+    const s = srcCache.get(st.filePath) ?? src(st.filePath);
+    if (!s) continue;
+    const body = sliceLines(s, st.startLine, st.endLine);
+    const open = body.indexOf('{');
+    const close = open >= 0 ? matchBrace(body, open) : -1;
+    if (open < 0 || close < 0) continue;
+    const inner = body.slice(open + 1, close);
+    const fields: FieldInfo[] = [];
+    let idx = 0;
+    for (const rawDecl of splitTopLevel(inner, ';')) {
+      const decl = rawDecl.trim();
+      if (!decl) continue;
+      let name: string | null = null;
+      let isFnPtr = false;
+      const ptr = decl.match(FNPTR_DECL_RE);
+      if (ptr) {
+        name = ptr[1]!;
+        isFnPtr = true;
+      } else {
+        // `TYPE [*]name` — fn-pointer when TYPE is a fn-pointer typedef.
+        const fm = decl.match(/(\w+)\s+\*?\s*(\w+)\s*$/);
+        if (fm) {
+          name = fm[2]!;
+          isFnPtr = fnPtrTypedefs.has(fm[1]!);
+        }
+      }
+      if (!name) continue;
+      fields.push({ name, index: idx, isFnPtr });
+      if (isFnPtr) {
+        if (!fieldToStructs.has(name)) fieldToStructs.set(name, new Set());
+        fieldToStructs.get(name)!.add(st.name);
+      }
+      idx++;
+    }
+    if (fields.some((f) => f.isFnPtr)) structLayout.set(st.name, fields);
+  }
+  if (structLayout.size === 0) return [];
+
+  const fnPtrFieldOf = (struct: string, field: string): boolean =>
+    !!structLayout.get(struct)?.some((f) => f.name === field && f.isFnPtr);
+
+  // C/C++ function + method nodes, materialized once (bounded by C/C++ files).
+  const cFns: Node[] = [];
+  for (const fn of iterateFns(queries)) {
+    if (C_CPP_EXT.test(fn.filePath)) cFns.push(fn);
+  }
+
+  // ---- function-name → node resolution (prefer a function in the same file) ----
+  const resolveFn = (name: string, preferFile?: string): Node | null => {
+    const cands = ctx.getNodesByName(name).filter((n) => FN_KINDS.has(n.kind));
+    if (cands.length === 0) return null;
+    if (cands.length === 1) return cands[0]!;
+    if (preferFile) {
+      const same = cands.find((n) => n.filePath === preferFile);
+      if (same) return same;
+    }
+    return cands[0]!;
+  };
+
+  // ---- Pass C: registrations — Map<"struct.field", Set<funcNodeId>> ----
+  const reg = new Map<string, Set<string>>();
+  const idToNode = new Map<string, Node>();
+  const addReg = (struct: string, field: string, fn: Node): void => {
+    const key = `${struct}.${field}`;
+    if (!reg.has(key)) reg.set(key, new Set());
+    reg.get(key)!.add(fn.id);
+    idToNode.set(fn.id, fn);
+  };
+
+  // A struct value `{ … }` (one element) — register its function entries to the
+  // struct's fields, by `.field = fn` designators or by positional slot.
+  const registerStructValue = (struct: string, valueBody: string, file: string): void => {
+    const layout = structLayout.get(struct);
+    if (!layout) return;
+    const items = splitTopLevel(valueBody, ',');
+    let pos = 0;
+    for (const rawItem of items) {
+      const item = rawItem.trim();
+      if (!item) continue;
+      const des = item.match(/^\.\s*(\w+)\s*=\s*(?:&\s*)?(\w+)\s*$/);
+      if (des) {
+        const field = des[1]!;
+        if (fnPtrFieldOf(struct, field)) {
+          const fn = resolveFn(des[2]!, file);
+          if (fn) addReg(struct, field, fn);
+        }
+        // a designated item does not advance positional counting
+        continue;
+      }
+      const field = layout.find((f) => f.index === pos);
+      if (field?.isFnPtr) {
+        const id = item.match(/^&?\s*(\w+)\s*$/);
+        if (id) {
+          const fn = resolveFn(id[1]!, file);
+          if (fn) addReg(struct, field.name, fn);
+        }
+      }
+      pos++;
+    }
+  };
+
+  // `(?:struct )?TYPE name[opt] = {` initializers, where TYPE is a struct that
+  // has ≥1 fn-pointer field. Handles both single (`= {…}`) and array
+  // (`[] = { {…}, {…} }`) forms.
+  const INIT_RE =
+    /(?:^|[;{}])\s*(?:(?:static|const|extern|register|volatile)\s+)*(?:struct\s+)?(\w+)\s+(\w+)\s*(\[[^\]]*\])?\s*=\s*\{/g;
+  for (const file of files) {
+    const s = srcCache.get(file);
+    if (!s || !s.includes('=')) continue;
+    INIT_RE.lastIndex = 0;
+    let m: RegExpExecArray | null;
+    while ((m = INIT_RE.exec(s))) {
+      const struct = m[1]!;
+      if (!structLayout.has(struct)) continue;
+      const isArray = !!m[3];
+      const open = m.index + m[0].length - 1; // points at the `{`
+      const close = matchBrace(s, open);
+      if (close < 0) continue;
+      const body = s.slice(open + 1, close);
+      if (isArray) {
+        // top-level `{ … }` element groups
+        for (const el of splitTopLevel(body, ',')) {
+          const t = el.trim();
+          if (t.startsWith('{')) {
+            const e = matchBrace(t, 0);
+            if (e > 0) registerStructValue(struct, t.slice(1, e), file);
+          } else if (t) {
+            // array of bare values (rare for structs) — treat as one positional slot
+            registerStructValue(struct, t, file);
+          }
+        }
+      } else {
+        registerStructValue(struct, body, file);
+      }
+      INIT_RE.lastIndex = close;
+    }
+  }
+
+  // ---- receiver-type resolution within a function's source ----
+  // `(?:struct )?TYPE [*]recv` declared in the params or body → TYPE (if a known struct).
+  const recvTypeIn = (fnSrc: string, recv: string): string | null => {
+    const re = new RegExp(`(?:struct\\s+)?(\\w+)\\s*\\*?\\s*\\b${recv}\\b\\s*(?:[,)=;]|\\[)`, 'g');
+    let m: RegExpExecArray | null;
+    while ((m = re.exec(fnSrc))) {
+      if (structLayout.has(m[1]!)) return m[1]!;
+    }
+    return null;
+  };
+
+  // ---- Pass D: field←field propagation (`a->f = b->g`) ----
+  // Collected as (targetStruct.field ← sourceStruct.field) pairs, then merged to
+  // a fixpoint so a hook slot inherits a registry field's handlers.
+  const FIELD_ASSIGN_RE = /(\w+)\s*(?:->|\.)\s*(\w+)\s*=\s*(\w+)\s*(?:->|\.)\s*(\w+)/g;
+  const propagations: { to: string; from: string }[] = [];
+  for (const fn of cFns) {
+    const s = srcCache.get(fn.filePath);
+    if (!s) continue;
+    const body = sliceLines(s, fn.startLine, fn.endLine);
+    if (!body.includes('=')) continue;
+    FIELD_ASSIGN_RE.lastIndex = 0;
+    let m: RegExpExecArray | null;
+    while ((m = FIELD_ASSIGN_RE.exec(body))) {
+      const [, lrecv, lfield, rrecv, rfield] = m;
+      const lt = recvTypeIn(body, lrecv!);
+      const rt = recvTypeIn(body, rrecv!);
+      if (lt && rt && fnPtrFieldOf(lt, lfield!) && fnPtrFieldOf(rt, rfield!)) {
+        propagations.push({ to: `${lt}.${lfield}`, from: `${rt}.${rfield}` });
+      }
+    }
+  }
+  for (let pass = 0; pass < 3 && propagations.length; pass++) {
+    let changed = false;
+    for (const { to, from } of propagations) {
+      const fromSet = reg.get(from);
+      if (!fromSet) continue;
+      if (!reg.has(to)) reg.set(to, new Set());
+      const toSet = reg.get(to)!;
+      for (const id of fromSet) {
+        if (!toSet.has(id)) {
+          toSet.add(id);
+          changed = true;
+        }
+      }
+    }
+    if (!changed) break;
+  }
+  if (reg.size === 0) return [];
+
+  // ---- Pass E: dispatch sites → edges ----
+  // recv->field( or recv.field( where field is a known fn-pointer field.
+  const DISPATCH_RE = /(\w+)\s*(?:->|\.)\s*(\w+)\s*\(/g;
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  for (const fn of cFns) {
+    const s = srcCache.get(fn.filePath);
+    if (!s) continue;
+    const body = sliceLines(s, fn.startLine, fn.endLine);
+    DISPATCH_RE.lastIndex = 0;
+    let m: RegExpExecArray | null;
+    let added = 0;
+    while ((m = DISPATCH_RE.exec(body)) && added < FANOUT_CAP) {
+      const recv = m[1]!;
+      const field = m[2]!;
+      const owners = fieldToStructs.get(field);
+      if (!owners || owners.size === 0) continue;
+      // Resolve the receiver's struct type; else fall back to a field name that
+      // belongs to exactly one struct.
+      let struct = recvTypeIn(body, recv);
+      if (!struct || !owners.has(struct)) struct = owners.size === 1 ? [...owners][0]! : null;
+      if (!struct) continue;
+      const targets = reg.get(`${struct}.${field}`);
+      if (!targets) continue;
+      const line = fn.startLine + body.slice(0, m.index).split('\n').length - 1;
+      for (const tid of targets) {
+        if (tid === fn.id) continue;
+        const key = `${fn.id}>${tid}`;
+        if (seen.has(key)) continue;
+        seen.add(key);
+        edges.push({
+          source: fn.id,
+          target: tid,
+          kind: 'calls',
+          line,
+          provenance: 'heuristic',
+          metadata: {
+            synthesizedBy: 'fn-pointer-dispatch',
+            via: `${struct}.${field}`,
+            registeredAt: `${fn.filePath}:${line}`,
+          },
+        });
+        if (++added >= FANOUT_CAP) break;
+      }
+    }
+  }
+  return edges;
+}
+
+/** C/C++ function + method nodes, streamed (memory-safe on symbol-dense repos). */
+function* iterateFns(queries: QueryBuilder): IterableIterator<Node> {
+  yield* queries.iterateNodesByKind('function');
+  yield* queries.iterateNodesByKind('method');
+}

+ 3 - 0
src/resolution/callback-synthesizer.ts

@@ -26,6 +26,7 @@ import type { QueryBuilder } from '../db/queries';
 import type { ResolutionContext } from './types';
 import { isGeneratedFile } from '../extraction/generated-detection';
 import { stripCommentsForRegex } from './strip-comments';
+import { cFnPointerDispatchEdges } from './c-fnptr-synthesizer';
 
 const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/;
 const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i;
@@ -2701,6 +2702,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
   const mediatrEdges = mediatrDispatchEdges(ctx);
   const sidekiqEdges = sidekiqDispatchEdges(ctx);
   const laravelEdges = laravelEventEdges(ctx);
+  const cFnPtrEdges = cFnPointerDispatchEdges(queries, ctx);
 
   const merged: Edge[] = [];
   const seen = new Set<string>();
@@ -2734,6 +2736,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
     ...mediatrEdges,
     ...sidekiqEdges,
     ...laravelEdges,
+    ...cFnPtrEdges,
   ]) {
     const key = `${e.source}>${e.target}`;
     if (seen.has(key)) continue;

+ 5 - 1
src/resolution/strip-comments.ts

@@ -33,7 +33,9 @@ export type CommentLang =
   | 'csharp'
   | 'swift'
   | 'go'
-  | 'rust';
+  | 'rust'
+  | 'c'
+  | 'cpp';
 
 export function stripCommentsForRegex(content: string, lang: CommentLang): string {
   switch (lang) {
@@ -52,6 +54,8 @@ export function stripCommentsForRegex(content: string, lang: CommentLang): strin
     case 'java':
     case 'csharp':
     case 'swift':
+    case 'c':
+    case 'cpp':
       return stripCStyle(content, /* allowSingleQuoteStrings */ lang === 'javascript' || lang === 'typescript');
     default:
       return content;