7 Коммиты e5897d0334 ... 6e5c3a9336

Автор SHA1 Сообщение Дата
  Colby McHenry 6e5c3a9336 feat(resolution): bridge Celery .delay()/.apply_async() dispatch to the task body 2 дней назад
  Colby McHenry 80a1044d3d feat(resolution): bridge Vuex string dispatch/commit to actions and mutations 2 дней назад
  Colby McHenry 8ea32059b6 feat(resolution): bridge Pinia useStore().action() calls to the action 2 дней назад
  Colby McHenry cc9c2f7420 feat(extraction): index Vuex/Pinia store actions, mutations, and getters 2 дней назад
  Colby McHenry e9f7422223 feat(resolution): synthesize RTK Query hook→endpoint dispatch edges 2 дней назад
  Colby McHenry 7f970296cf feat(resolution): synthesize object-literal registry dispatch edges 2 дней назад
  Colby McHenry 270e50655a fix(explore): surface synth constant-endpoint edges + precise redux-thunk dispatch resolution 2 дней назад

+ 6 - 0
CHANGELOG.md

@@ -11,6 +11,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
+- Vue store actions, mutations, and getters are now indexed as symbols you can find and read. Whether your store is **Vuex** (`mutations` / `actions` objects in a module) or **Pinia** — both the options form (`defineStore({ actions: { … } })`) and the setup form (`defineStore('id', () => { … })`, where actions are local functions) — each action, mutation, and getter is now a real node. So `codegraph search` finds `login` or `getSessionList`, and `codegraph_explore` / `codegraph_node` show its body and what it calls, instead of "not found" because the function only existed as an object-literal property.
+- `codegraph_explore` now connects a Vue component to the **Pinia** store action it calls. When code does `const store = useUserStore()` and then `store.fetchUser()`, that call now links through to the `fetchUser` action in the store module — so "what happens when this view loads its data?" traces from the component into the action's body instead of stopping at the `store.fetchUser()` line. Works for both Pinia store styles (options and setup), and stays precise (a built-in like `store.$patch()` or an unrelated same-named method isn't mislinked).
+- `codegraph_explore` now follows **Vuex** string dispatch. A `dispatch('user/login')` or `commit('SET_TOKEN')` call — namespaced `'module/action'` keys included — now links to the action or mutation it names, resolved to the correct store module even when several modules share an action name (and without being fooled by a same-named `api/` helper). So "what runs when this dispatches?" traces from the call into the store handler and on to the mutations it commits. Vuex's canonical `export default { namespaced, actions, mutations }` module shape is now indexed too, so those handlers are findable symbols.
+- `codegraph_explore` now connects React data-fetching flows built on **RTK Query** (Redux Toolkit's `createApi`). An endpoint defined inside `createApi({ endpoints })` and the `useGetXQuery` / `useUpdateYMutation` hook it generates were both invisible to analysis — so "what does this component fetch?" or "where does `useGetThingQuery` get its data?" dead-ended, because the hook, the endpoint, and the component had nothing linking them. CodeGraph now indexes each endpoint and each generated hook as real symbols and wires the path `component → useGetXQuery → getX → queryFn`, so the flow resolves in one explore call instead of reading the API slice by hand. Both the arrow (`endpoints: build => ({ … })`) and method (`endpoints(builder) { return { … } }`) styles are recognized, along with the `useLazyGetXQuery` variant; hand-written hooks of a similar name are left untouched.
+- `codegraph_explore` now follows **Celery** task dispatch in Python. A `send_email.delay(...)` or `send_email.apply_async(...)` call now links to the `@shared_task` / `@app.task` function it runs — typically defined in a different module (`tasks.py`) from where it's triggered (a view or service) — so "what actually happens when this is dispatched?" traces from the call site straight into the task body instead of stopping at the `.delay()` line. Both decorator dialects are recognized (bare `@shared_task` and the arg'd `@app.task(bind=True, …)` form), including the module-qualified `tasks.invalidate_cache.apply_async()` call style. It stays precise: a `.delay()` on something that isn't a Celery task is never mislinked, so a project that doesn't use Celery is unaffected.
+
 - `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.
 - C file-scope constants and globals — `static const` scalars, pointer/array lookup tables, and shared mutable globals — are now recognized as symbols in their own right. They previously weren't extracted at all, so they never appeared in search or carried any dependents; now they show up in `codegraph search` and participate in impact analysis (see above), so changing a C lookup table surfaces the same-file functions that read it.

+ 129 - 0
__tests__/celery-dispatch-synthesizer.test.ts

@@ -0,0 +1,129 @@
+/**
+ * Celery task-dispatch bridge (Python).
+ *
+ * Celery decouples a task's call site from its body: a `@shared_task` / `@app.task`
+ * decorated `def` is invoked through `task.delay(...)` / `task.apply_async(...)`, a
+ * dynamic hop with no static edge. This bridges each `.delay`/`.apply_async` site to
+ * the task function, gated on the DECORATOR (read from the source above the `def`) so a
+ * `.delay()` on a non-task object resolves to nothing. Covers both decorator dialects
+ * (`@shared_task`, `@app.task(...)`), the module-qualified `mod.task.apply_async()` form,
+ * and proves the precision gates: a plain function called with `.delay()` and a canvas
+ * `group(...).delay()` (no single identifier before `.delay`) both contribute no edge.
+ */
+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('celery-dispatch synthesizer', () => {
+  let dir: string;
+  beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'celery-dispatch-')); });
+  afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
+
+  it('bridges .delay()/.apply_async() to decorated tasks, ignoring non-task and canvas dispatch', async () => {
+    // Two decorator dialects: bare @shared_task and arg'd @app.task(...).
+    fs.writeFileSync(
+      path.join(dir, 'tasks.py'),
+      `from celery import shared_task
+from myapp.celery import app
+
+
+@shared_task
+def send_email(to):
+    return to
+
+
+@app.task(bind=True, max_retries=3)
+def crunch(self, n):
+    return n * 2
+`
+    );
+    fs.mkdirSync(path.join(dir, 'services'), { recursive: true });
+    fs.writeFileSync(
+      path.join(dir, 'services', 'tickets.py'),
+      `from celery import shared_task
+
+
+@shared_task
+def invalidate_cache():
+    return None
+`
+    );
+    // A plain function — NOT a celery task — that nonetheless has .delay() called on it.
+    fs.writeFileSync(
+      path.join(dir, 'utils.py'),
+      `def process_data(x):
+    return x
+`
+    );
+    // Dispatch sites, all inside one enclosing function.
+    fs.writeFileSync(
+      path.join(dir, 'views.py'),
+      `from tasks import send_email, crunch
+from services import tickets
+from utils import process_data
+from celery import group
+
+
+def handle_request(req):
+    send_email.delay(req.addr)                 # → send_email task (cross-file)
+    crunch.apply_async(args=[5])               # → crunch task (@app.task dialect)
+    tickets.invalidate_cache.apply_async()     # module-qualified → invalidate_cache
+    process_data.delay(req.x)                  # NOT a task → no edge
+    group([send_email.s(a) for a in req.addrs]).delay()  # canvas → no edge
+`
+    );
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const db = (cg as any).db.db;
+
+    const edges = db
+      .prepare(
+        `SELECT s.name source, t.name target, t.file_path tf, 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') = 'celery-dispatch'`
+      )
+      .all();
+
+    const targets = (src: string) => edges.filter((r: any) => r.source === src).map((r: any) => r.target).sort();
+    // handle_request dispatches exactly the three real tasks (both dialects + module-qualified).
+    expect(targets('handle_request')).toEqual(['crunch', 'invalidate_cache', 'send_email']);
+    // The @app.task target resolved to the task def, not anything else.
+    const crunchEdge = edges.find((r: any) => r.target === 'crunch');
+    expect(crunchEdge.tf).toMatch(/tasks\.py$/);
+    // Module-qualified `tickets.invalidate_cache.apply_async()` resolved by the last identifier.
+    const cacheEdge = edges.find((r: any) => r.target === 'invalidate_cache');
+    expect(cacheEdge.tf).toMatch(/services[\\/]tickets\.py$/);
+    expect(cacheEdge.via).toBe('invalidate_cache');
+    // PRECISION: a plain function called with .delay() is never targeted (no decorator).
+    expect(edges.some((r: any) => r.target === 'process_data')).toBe(false);
+
+    cg.close?.();
+  });
+
+  it('produces no edges in a Celery-free project (clean control)', async () => {
+    fs.writeFileSync(
+      path.join(dir, 'app.py'),
+      `def schedule(job):
+    job.delay()          # a .delay() that has nothing to do with Celery
+    return job
+
+
+def run():
+    schedule(make_job())
+`
+    );
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const db = (cg as any).db.db;
+    const count = db
+      .prepare(
+        `SELECT count(*) c FROM edges WHERE json_extract(metadata,'$.synthesizedBy') = 'celery-dispatch'`
+      )
+      .get();
+    expect(count.c).toBe(0);
+    cg.close?.();
+  });
+});

+ 86 - 0
__tests__/explore-synth-constant-endpoints.test.ts

@@ -0,0 +1,86 @@
+/**
+ * Regression: codegraph_explore must SURFACE a synthesized edge whose endpoints are
+ * `constant` nodes (RTK thunk→thunk), on a SMALL repo.
+ *
+ * `buildFlowFromNamedSymbols` historically filtered its "named" set to CALLABLE kinds
+ * (method/function/component/constructor), excluding `constant`. RTK thunks are
+ * `export const X = createAsyncThunk(...)`, so a thunk→thunk hop is constant→constant —
+ * it never entered the flow scan and surfaced nowhere on the Flow path. The kind-agnostic
+ * "### Relationships" section would have caught it, but that is disabled below 500 files.
+ * Net: on a small RTK app the synthesized edge existed in the graph yet was invisible to
+ * the agent. The fix feeds a `dynNamed` set (named non-callable endpoints that participate
+ * in a heuristic edge) to the tier-independent "## Dynamic-dispatch links" scan. This test
+ * pins it on a deliberately tiny (<150-file) fixture so the Relationships gate is OFF and
+ * the dynamic-dispatch-links path is the ONLY thing that can surface the hop.
+ */
+import { describe, it, expect, beforeAll, afterAll, 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/index';
+import { ToolHandler } from '../src/mcp/tools';
+
+// Assertions read RAW codegraph_explore output; managed offload would replace it. Disable
+// it for this file so the suite is hermetic regardless of dev-machine config, then restore.
+let _prevOffloadDisable: string | undefined;
+beforeAll(() => { _prevOffloadDisable = process.env.CODEGRAPH_OFFLOAD_DISABLE; process.env.CODEGRAPH_OFFLOAD_DISABLE = '1'; });
+afterAll(() => {
+  if (_prevOffloadDisable === undefined) delete process.env.CODEGRAPH_OFFLOAD_DISABLE;
+  else process.env.CODEGRAPH_OFFLOAD_DISABLE = _prevOffloadDisable;
+});
+
+describe('codegraph_explore — synthesized constant→constant edges surface on small repos', () => {
+  let dir: string;
+  let cg: CodeGraph;
+  let handler: ToolHandler;
+
+  afterEach(() => {
+    if (cg) cg.destroy();
+    if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
+  });
+
+  it('surfaces an RTK thunk→thunk hop (both `constant`) in the Dynamic-dispatch links section', async () => {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'explore-thunk-surface-'));
+    fs.writeFileSync(
+      path.join(dir, 'package.json'),
+      JSON.stringify({ name: 'app', dependencies: { '@reduxjs/toolkit': '^2' } })
+    );
+    fs.writeFileSync(
+      path.join(dir, 'thunks.ts'),
+      `import { createAsyncThunk } from '@reduxjs/toolkit';
+
+export const deepThunk = createAsyncThunk('app/deep', async (n: number) => n * 2);
+
+export const innerThunk = createAsyncThunk('app/inner', async (n: number, { dispatch }) => {
+  return dispatch(deepThunk(n));
+});
+
+export const outerThunk = createAsyncThunk('app/outer', async (n: number, { dispatch }) => {
+  await dispatch(innerThunk(n));
+});
+`
+    );
+
+    cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } });
+    await cg.indexAll();
+
+    // Precondition: the endpoints really are `constant` nodes — the exact kind the old
+    // CALLABLE-only flow scan dropped (if extraction ever classed them as functions the
+    // test would pass vacuously, so assert the case we actually fixed).
+    const db = (cg as any).db.db;
+    const outerKind = db.prepare(`SELECT kind FROM nodes WHERE name = 'outerThunk' LIMIT 1`).get()?.kind;
+    expect(outerKind).toBe('constant');
+
+    handler = new ToolHandler(cg);
+    const res = await handler.execute('codegraph_explore', { query: 'outerThunk innerThunk' });
+    const text = res.content[0].text as string;
+
+    // The synthesized hop now surfaces (was invisible: both endpoints `constant` AND the
+    // small-repo Relationships section is off).
+    expect(text).toContain('## Dynamic-dispatch links among your symbols');
+    expect(text).toMatch(/outerThunk\s+→\s+innerThunk/);
+    // It reads as a dynamic-dispatch bridge with its wiring site, not a bare `calls`.
+    expect(text).toMatch(/dynamic: redux thunk @/);
+    expect(text).not.toMatch(/outerThunk\s+→\s+innerThunk\s+\[calls\]/);
+  });
+});

+ 83 - 0
__tests__/object-registry-synthesizer.test.ts

@@ -0,0 +1,83 @@
+/**
+ * Object-literal registry dispatch synthesizer.
+ *
+ * A command registry maps keys → handler classes/functions in an object literal, then
+ * dispatches by a RUNTIME key (`new registry[command]().execute()`) that static parsing
+ * can't follow. The synthesizer links each dispatching method → each registered handler's
+ * callable entry. Validates: a class registry resolves to the handler's `.execute` method;
+ * the field-initializer form (`commands = {…}` matched against a `this.commands[k]` dispatch);
+ * and the dispatch GATE — a look-alike object literal that is only ever accessed statically
+ * (never `registry[var]`) yields no edges.
+ */
+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('object-registry synthesizer', () => {
+  let dir: string;
+  beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'obj-registry-')); });
+  afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
+
+  it('links a dispatcher to each registered command class’s execute method, gated on dynamic dispatch', async () => {
+    fs.writeFileSync(
+      path.join(dir, 'commands.ts'),
+      `export class AddCommand { execute() { return 'add'; } }
+export class RemoveCommand { execute() { return 'remove'; } }
+export class MoveCommand { execute() { return 'move'; } }
+`
+    );
+    fs.writeFileSync(
+      path.join(dir, 'manager.ts'),
+      `import { AddCommand, RemoveCommand, MoveCommand } from './commands';
+
+const Cmd = { ADD: 'add', REMOVE: 'remove', MOVE: 'move' };
+
+class CommandManager {
+  commands = {
+    [Cmd.ADD]: AddCommand,
+    [Cmd.REMOVE]: RemoveCommand,
+    [Cmd.MOVE]: MoveCommand,
+  };
+
+  executeCommand(command: string) {
+    return new this.commands[command]().execute();
+  }
+}
+`
+    );
+    // A look-alike registry that is NEVER dynamically dispatched (only a static `.add`
+    // member access) — must yield NO edges. The dynamic `registry[var]` dispatch is the gate.
+    fs.writeFileSync(
+      path.join(dir, 'static.ts'),
+      `import { AddCommand, RemoveCommand } from './commands';
+const table = { add: AddCommand, remove: RemoveCommand };
+export function direct() { return new table.add().execute(); }
+`
+    );
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+
+    const db = (cg as any).db.db;
+    const rows = db
+      .prepare(
+        `SELECT s.name source_name, t.name target_name, t.kind target_kind, t.file_path target_file
+         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') = 'object-registry'`
+      )
+      .all();
+    cg.close?.();
+
+    // Exactly the 3 dispatcher→handler-entry edges: executeCommand → {Add,Remove,Move}Command.execute.
+    expect(rows.length).toBe(3);
+    expect(rows.every((r: any) => r.source_name === 'executeCommand')).toBe(true);
+    expect(rows.every((r: any) => r.target_kind === 'method' && r.target_name === 'execute')).toBe(true);
+    expect(rows.every((r: any) => /commands\.ts$/.test(r.target_file))).toBe(true);
+    // The statically-accessed look-alike registry contributed nothing.
+    expect(rows.some((r: any) => /static\.ts$/.test(r.target_file))).toBe(false);
+  });
+});

+ 108 - 0
__tests__/pinia-store-synthesizer.test.ts

@@ -0,0 +1,108 @@
+/**
+ * Pinia `useStore().action()` dispatch bridge.
+ *
+ * A Pinia store factory `export const useXStore = defineStore(...)` exposes its
+ * actions as methods on the store instance; a consumer does `const s = useXStore()`
+ * then `s.action()`. That method-on-instance call has no static edge to the action
+ * (which lives in the store module). This bridges consumer → action by binding the
+ * store var to its factory's file and resolving `s.method()` to a function node IN
+ * THAT FILE — so it covers both the options and setup store forms, stays precise
+ * (a Pinia built-in like `$patch`, or an unrelated same-named method, resolves to
+ * nothing), and fires only when a `defineStore` factory actually exists.
+ */
+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('pinia-store synthesizer', () => {
+  let dir: string;
+  beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pinia-store-')); });
+  afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
+
+  it('bridges `const s = useXStore(); s.action()` to the action, across options + setup forms', async () => {
+    // Options-form store.
+    fs.writeFileSync(
+      path.join(dir, 'authStore.ts'),
+      `import { defineStore } from 'pinia';
+export const useAuthStore = defineStore({
+  id: 'auth',
+  state: () => ({ token: '' }),
+  actions: {
+    async getMenu() { return loadMenu(); },
+    setToken(t: string) { this.token = t; },
+  },
+});
+`
+    );
+    // Setup-form store.
+    fs.writeFileSync(
+      path.join(dir, 'chatStore.ts'),
+      `import { defineStore } from 'pinia';
+export const useChatStore = defineStore('chat', () => {
+  const getList = async () => { return fetchList(); };
+  return { getList };
+});
+`
+    );
+    // Consumer binds both stores and calls their actions (plus a Pinia built-in).
+    fs.writeFileSync(
+      path.join(dir, 'init.ts'),
+      `import { useAuthStore } from './authStore';
+import { useChatStore } from './chatStore';
+export function init() {
+  const authStore = useAuthStore();
+  const chatStore = useChatStore();
+  authStore.getMenu();
+  authStore.setToken('x');
+  authStore.$patch({});        // Pinia built-in — must not bridge
+  chatStore.getList();
+}
+`
+    );
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const db = (cg as any).db.db;
+
+    const edges = db
+      .prepare(
+        `SELECT s.name source, t.name target, t.file_path tf
+         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') = 'pinia-store'`
+      )
+      .all();
+    const pairs = edges.map((r: any) => `${r.source}->${r.target}`).sort();
+    // Exactly the three real actions, all from `init`.
+    expect(pairs).toEqual(['init->getList', 'init->getMenu', 'init->setToken']);
+    // Each target is the action in its own store file (cross-file, store-scoped).
+    expect(edges.every((r: any) => /Store\.ts$/.test(r.tf))).toBe(true);
+    // The Pinia built-in `$patch` produced no edge.
+    expect(pairs.some((p: string) => p.includes('patch'))).toBe(false);
+
+    cg.close?.();
+  });
+
+  it('produces nothing when there is no defineStore factory (not a Pinia store)', async () => {
+    fs.writeFileSync(
+      path.join(dir, 'thing.ts'),
+      `function useThing() { return { run() { return 1; } }; }
+export function go() {
+  const thing = useThing();
+  thing.run();
+}
+`
+    );
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const db = (cg as any).db.db;
+    const c = db
+      .prepare(`SELECT count(*) c FROM edges WHERE json_extract(metadata,'$.synthesizedBy') = 'pinia-store'`)
+      .get().c;
+    expect(c).toBe(0);
+
+    cg.close?.();
+  });
+});

+ 47 - 0
__tests__/redux-thunk-synthesizer.test.ts

@@ -79,4 +79,51 @@ export const notAThunk = 'dispatch(innerThunk())';
     expect(outer.via).toBe('innerThunk');
     expect(outer.registeredAt).toMatch(/thunks\.ts:\d+/);
   });
+
+  it('on a name collision, a dispatch resolves to the THUNK, not a same-named service function', async () => {
+    // Regression for the octo-call case: `leaveCall` exists as BOTH a `createAsyncThunk`
+    // const and an unrelated service function. `dispatch(leaveCall())` targets the thunk,
+    // but the old first-match resolver could pick the function. The resolver now prefers a
+    // thunk-signature const > other const > same-file > first.
+    fs.writeFileSync(
+      path.join(dir, 'package.json'),
+      JSON.stringify({ name: 'app', dependencies: { '@reduxjs/toolkit': '^2' } })
+    );
+    // A plain service function that shares the name `leaveCall` with the thunk below.
+    fs.writeFileSync(path.join(dir, 'service.ts'), `export function leaveCall(id: string) { return id; }\n`);
+    fs.writeFileSync(
+      path.join(dir, 'thunks.ts'),
+      `import { createAsyncThunk } from '@reduxjs/toolkit';
+
+export const leaveCall = createAsyncThunk('call/leave', async () => {
+  return 1;
+});
+
+export const logout = createAsyncThunk('user/logout', async (_: void, { dispatch }) => {
+  dispatch(leaveCall());
+});
+`
+    );
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+
+    const db = (cg as any).db.db;
+    const row = db
+      .prepare(
+        `SELECT t.kind target_kind, t.file_path target_file
+         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') = 'redux-thunk'
+           AND s.name = 'logout' AND t.name = 'leaveCall'`
+      )
+      .get();
+    cg.close?.();
+
+    expect(row).toBeTruthy();
+    // Resolved to the createAsyncThunk constant in thunks.ts, NOT service.ts's function.
+    expect(row.target_kind).toBe('constant');
+    expect(row.target_file).toMatch(/thunks\.ts$/);
+  });
 });

+ 197 - 0
__tests__/rtk-query-synthesizer.test.ts

@@ -0,0 +1,197 @@
+/**
+ * RTK Query generated-hook → endpoint synthesizer.
+ *
+ * RTK Query's `createApi({ endpoints })` defines endpoints as object-literal
+ * properties (`getX: build.query(...)`) and generates one `useGetXQuery` /
+ * `useUpdateYMutation` hook per endpoint, exported via a `const {…} = api`
+ * destructuring. Neither the endpoint nor the generated hook is otherwise a node,
+ * so a `component → useGetXQuery → getX → queryFn` flow has nothing to connect to.
+ *
+ * This validates the two halves: extraction mints a function node for each
+ * endpoint (named by its key, both the `build => ({...})` arrow form and the
+ * `endpoints(build){ return {...} }` method-shorthand form) and for each generated
+ * hook binding; then the synthesizer bridges hook→endpoint by the naming
+ * convention (incl. the `useLazyGetXQuery` variant → the same endpoint). Precision
+ * is gated to genuinely-generated hooks: a hand-written `use*Query` arrow is never
+ * bridged, and no edge ever crosses files.
+ */
+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('rtk-query synthesizer', () => {
+  let dir: string;
+  beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rtk-query-')); });
+  afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
+
+  it('extracts endpoints + generated hooks and bridges hook→endpoint (arrow + method + lazy + factory forms)', async () => {
+    // Arrow form (shapeshift-style): `endpoints: build => ({...})`, `queryFn: () => {}`.
+    fs.writeFileSync(
+      path.join(dir, 'fiatRampApi.ts'),
+      `import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
+import { fetchRamps } from './ramps';
+
+export const fiatRampApi = createApi({
+  reducerPath: 'fiatRampApi',
+  baseQuery: fetchBaseQuery({ baseUrl: '/' }),
+  endpoints: build => ({
+    getFiatRamps: build.query({
+      queryFn: async () => {
+        const data = await fetchRamps();
+        return { data };
+      },
+    }),
+    placeOrder: build.mutation({
+      query: body => ({ url: 'order', method: 'POST', body }),
+    }),
+  }),
+});
+
+export const { useGetFiatRampsQuery, usePlaceOrderMutation, useLazyGetFiatRampsQuery } = fiatRampApi;
+`
+    );
+    // Method-shorthand form (basetool-style): `endpoints(builder){ return {...} }`,
+    // `query(){}` method handler, plus a factory-handler endpoint (no fn literal).
+    fs.writeFileSync(
+      path.join(dir, 'dashApi.ts'),
+      `import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
+import { makeCheckFn } from './factory';
+
+export const dashApi = createApi({
+  reducerPath: 'dash',
+  baseQuery: fetchBaseQuery({ baseUrl: '/' }),
+  endpoints(builder) {
+    return {
+      getDashboards: builder.query({
+        query() {
+          return '/dashboards';
+        },
+      }),
+      checkConnection: builder.mutation({
+        queryFn: makeCheckFn('/check'),
+      }),
+    };
+  },
+});
+
+export const { useGetDashboardsQuery, useCheckConnectionMutation } = dashApi;
+`
+    );
+    // Components consuming the generated hooks.
+    fs.writeFileSync(
+      path.join(dir, 'Views.tsx'),
+      `import { useGetFiatRampsQuery, useLazyGetFiatRampsQuery } from './fiatRampApi';
+import { useGetDashboardsQuery } from './dashApi';
+
+export function FiatForm() {
+  const { data } = useGetFiatRampsQuery();
+  return data;
+}
+export function DashList() {
+  const { data } = useGetDashboardsQuery();
+  return data;
+}
+export function LazyForm() {
+  const [load] = useLazyGetFiatRampsQuery();
+  return load;
+}
+`
+    );
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const db = (cg as any).db.db;
+
+    // Endpoints are extracted as function nodes named by their key.
+    const endpoints = db
+      .prepare(`SELECT name, kind FROM nodes WHERE name IN ('getFiatRamps','placeOrder','getDashboards','checkConnection')`)
+      .all();
+    expect(endpoints.length).toBe(4);
+    expect(endpoints.every((n: any) => n.kind === 'function')).toBe(true);
+
+    // Generated hooks are extracted as function nodes carrying the sentinel.
+    const hooks = db
+      .prepare(`SELECT name FROM nodes WHERE signature = '= RTK Query generated hook' ORDER BY name`)
+      .all()
+      .map((r: any) => r.name);
+    expect(hooks).toEqual([
+      'useCheckConnectionMutation',
+      'useGetDashboardsQuery',
+      'useGetFiatRampsQuery',
+      'useLazyGetFiatRampsQuery',
+      'usePlaceOrderMutation',
+    ]);
+
+    // hook → endpoint synth edges, including the Lazy variant mapping to the same endpoint.
+    const synth = db
+      .prepare(
+        `SELECT s.name source, t.name target, s.file_path sf, t.file_path tf
+         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') = 'rtk-query'`
+      )
+      .all();
+    const pairs = synth.map((r: any) => `${r.source}->${r.target}`).sort();
+    expect(pairs).toEqual([
+      'useCheckConnectionMutation->checkConnection',
+      'useGetDashboardsQuery->getDashboards',
+      'useGetFiatRampsQuery->getFiatRamps',
+      'useLazyGetFiatRampsQuery->getFiatRamps',
+      'usePlaceOrderMutation->placeOrder',
+    ]);
+    // Every synth edge stays within one file (RTK colocates api + hooks).
+    expect(synth.every((r: any) => r.sf === r.tf)).toBe(true);
+
+    // The component reaches the hook (normal import/call resolution), so the full
+    // `component → hook → endpoint` chain is connected.
+    const compToHook = db
+      .prepare(
+        `SELECT s.name source, t.name target FROM edges e
+         JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
+         WHERE s.name = 'FiatForm' AND t.name = 'useGetFiatRampsQuery' AND e.kind = 'calls'`
+      )
+      .all();
+    expect(compToHook.length).toBeGreaterThan(0);
+
+    cg.close?.();
+  });
+
+  it('does not bridge a hand-written use*Query hook (no createApi, no sentinel) — 0 synth edges', async () => {
+    // A real custom hook of the same name shape, plus a same-file `getThing`
+    // function it could spuriously map to. Without the generated-hook sentinel +
+    // createApi destructuring, the synthesizer must produce nothing.
+    fs.writeFileSync(
+      path.join(dir, 'useGetThingQuery.ts'),
+      `export function getThing() { return 42; }
+export const useGetThingQuery = () => {
+  return getThing();
+};
+`
+    );
+    fs.writeFileSync(
+      path.join(dir, 'Thing.tsx'),
+      `import { useGetThingQuery } from './useGetThingQuery';
+export function Thing() {
+  return useGetThingQuery();
+}
+`
+    );
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const db = (cg as any).db.db;
+
+    const synth = db
+      .prepare(`SELECT count(*) c FROM edges WHERE json_extract(metadata,'$.synthesizedBy') = 'rtk-query'`)
+      .get();
+    expect(synth.c).toBe(0);
+    // The hand-written hook keeps its real body (not a sentinel binding).
+    const sentinel = db
+      .prepare(`SELECT count(*) c FROM nodes WHERE signature = '= RTK Query generated hook'`)
+      .get();
+    expect(sentinel.c).toBe(0);
+
+    cg.close?.();
+  });
+});

+ 138 - 0
__tests__/vue-store-extraction.test.ts

@@ -0,0 +1,138 @@
+/**
+ * Vue store action/mutation/getter extraction (the foundation for finding and
+ * reading store logic — `codegraph_node login` / `getSessionList`).
+ *
+ * Vuex/Pinia define a store's callable surface as object-literal members nested
+ * under `actions`/`mutations`/`getters`, or as body-local consts in a Pinia setup
+ * store — none of which were extracted, so the symbols an agent looks for didn't
+ * exist as nodes. This covers the three dominant forms:
+ *   - Vuex module: non-exported `const actions = {…}` / `const mutations = {…}`.
+ *   - Pinia options: `defineStore({ actions: {…}, getters: {…} })`.
+ *   - Pinia setup: `defineStore('id', () => { const foo = …; return { foo } })`.
+ * And the precision gate: a non-exported `const actions = {…}` in a file that
+ * isn't a Vue store contributes nothing.
+ */
+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('vue store extraction', () => {
+  let dir: string;
+  beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vue-store-')); });
+  afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
+
+  it('extracts Vuex module + Pinia options + Pinia setup store members as function nodes', async () => {
+    // Vuex MODULE form: non-exported `const mutations`/`const actions` collections,
+    // wired via a default export (element-admin style). Method shorthand + arrow pairs.
+    fs.writeFileSync(
+      path.join(dir, 'userModule.js'),
+      `import { persistToken } from './auth-utils';
+const state = { token: '' };
+const mutations = {
+  SET_TOKEN: (state, token) => { state.token = token; },
+};
+const actions = {
+  login({ commit }, info) {
+    persistToken(info.token);
+  },
+  async logout({ commit }) {
+    commit('SET_TOKEN', '');
+  },
+};
+export default { namespaced: true, state, mutations, actions };
+`
+    );
+    fs.writeFileSync(
+      path.join(dir, 'auth-utils.js'),
+      `export function persistToken(token) { return token; }
+`
+    );
+    // Pinia OPTIONS form: actions + getters as object properties of a defineStore config.
+    fs.writeFileSync(
+      path.join(dir, 'authStore.ts'),
+      `import { defineStore } from 'pinia';
+export const useAuthStore = defineStore({
+  id: 'auth',
+  state: () => ({ name: '' }),
+  getters: {
+    upperName: state => state.name.toUpperCase(),
+  },
+  actions: {
+    async fetchMenu() { return loadMenu(); },
+    setName(n: string) { this.name = n; },
+  },
+});
+`
+    );
+    // Pinia SETUP form: actions are body-local consts exposed via the return block.
+    fs.writeFileSync(
+      path.join(dir, 'chatStore.ts'),
+      `import { defineStore } from 'pinia';
+export const useChatStore = defineStore('chat', () => {
+  const list = reactive([]);
+  const getList = async () => { return fetchList(); };
+  function pushItem(x) { list.push(x); }
+  return { list, getList, pushItem };
+});
+`
+    );
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const db = (cg as any).db.db;
+    const fn = (name: string) =>
+      db.prepare(`SELECT count(*) c FROM nodes WHERE name = ? AND kind = 'function'`).get(name).c;
+
+    // Vuex module: actions + mutations extracted.
+    expect(fn('login')).toBeGreaterThan(0);
+    expect(fn('logout')).toBeGreaterThan(0);
+    expect(fn('SET_TOKEN')).toBeGreaterThan(0);
+    // Pinia options: actions + getter extracted.
+    expect(fn('fetchMenu')).toBeGreaterThan(0);
+    expect(fn('setName')).toBeGreaterThan(0);
+    expect(fn('upperName')).toBeGreaterThan(0);
+    // Pinia setup: body-local actions extracted (and reachable via their bodies).
+    expect(fn('getList')).toBeGreaterThan(0);
+    expect(fn('pushItem')).toBeGreaterThan(0);
+
+    // The extracted action spans its real body — `login`'s `persistToken(...)`
+    // call attributes to it (extraction, not the deferred dispatch synthesis).
+    const loginCalls = db
+      .prepare(
+        `SELECT t.name FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
+         WHERE s.name = 'login' AND e.kind = 'calls'`
+      )
+      .all()
+      .map((r: any) => r.name);
+    expect(loginCalls).toContain('persistToken');
+
+    cg.close?.();
+  });
+
+  it('does not extract a non-exported `const actions = {…}` outside a Vue store file', async () => {
+    // A plain module that happens to hold a non-exported `const actions` object of
+    // functions, but lacks any second Vue-store signal — the gate must not fire.
+    fs.writeFileSync(
+      path.join(dir, 'commands.js'),
+      `const actions = {
+  doThing() { return 1; },
+  doOther() { return 2; },
+};
+export function run(key) { return actions[key](); }
+`
+    );
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const db = (cg as any).db.db;
+
+    expect(db.prepare(`SELECT count(*) c FROM nodes WHERE name = 'doThing'`).get().c).toBe(0);
+    expect(db.prepare(`SELECT count(*) c FROM nodes WHERE name = 'doOther'`).get().c).toBe(0);
+    // The real exported function is still extracted normally.
+    expect(db.prepare(`SELECT count(*) c FROM nodes WHERE name = 'run' AND kind='function'`).get().c).toBeGreaterThan(0);
+
+    cg.close?.();
+  });
+});

+ 100 - 0
__tests__/vuex-dispatch-synthesizer.test.ts

@@ -0,0 +1,100 @@
+/**
+ * Vuex string-keyed dispatch/commit bridge.
+ *
+ * Vuex dispatches actions/mutations by a runtime STRING key — `dispatch('user/login')`,
+ * `commit('SET_TOKEN')` — with no static edge to the handler (an object-literal
+ * method in a store module). This bridges the key to its function node: the last
+ * `/` segment is the action/mutation name, the preceding segment is the namespace
+ * (≈ the module file). It resolves to a node IN A STORE FILE (excluding a same-named
+ * `api/` helper — a real collision), disambiguated by the namespace appearing in the
+ * path, or the same file for a root `commit('M')` inside an action. Redux-style
+ * `dispatch(actionCreator())` (no string key) produces nothing.
+ *
+ * Also exercises the canonical Vuex MODULE shape `export default { namespaced,
+ * actions: {…}, mutations: {…} }` — whose methods only become nodes via the
+ * store-collection extraction this bridge depends on.
+ */
+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('vuex-dispatch synthesizer', () => {
+  let dir: string;
+  beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vuex-dispatch-')); });
+  afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
+
+  it('bridges namespaced dispatch + local commit to the right store handler, excluding an api collision', async () => {
+    fs.mkdirSync(path.join(dir, 'store', 'modules'), { recursive: true });
+    fs.mkdirSync(path.join(dir, 'api'), { recursive: true });
+    // Canonical Vuex module: `export default { namespaced, actions, mutations }`.
+    fs.writeFileSync(
+      path.join(dir, 'store', 'modules', 'user.js'),
+      `import { login as apiLogin } from '../../api/user';
+export default {
+  namespaced: true,
+  state: { token: '' },
+  mutations: {
+    SET_TOKEN(state, t) { state.token = t; },
+  },
+  actions: {
+    login({ commit }, info) {
+      apiLogin(info);
+      commit('SET_TOKEN', info.token);   // root/local key → SET_TOKEN in THIS module
+    },
+  },
+};
+`
+    );
+    // Collision: an api helper ALSO named `login` — must never be the dispatch target.
+    fs.writeFileSync(
+      path.join(dir, 'api', 'user.js'),
+      `export function login(info) { return info; }
+`
+    );
+    // Consumer dispatches by namespaced string key.
+    fs.writeFileSync(
+      path.join(dir, 'app.js'),
+      `import store from './store';
+export function bootstrap() {
+  store.dispatch('user/login', { token: 'x' });
+}
+`
+    );
+    // Redux-style control: a non-string dispatch must produce no vuex edge.
+    fs.writeFileSync(
+      path.join(dir, 'reduxy.js'),
+      `export function reduxy(dispatch) {
+  dispatch(someAction());
+}
+`
+    );
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const db = (cg as any).db.db;
+
+    const edges = db
+      .prepare(
+        `SELECT s.name source, t.name target, t.file_path tf, 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') = 'vuex-dispatch'`
+      )
+      .all();
+
+    // bootstrap → login, resolving to the STORE module (not api/user.js).
+    const loginEdge = edges.find((r: any) => r.source === 'bootstrap' && r.target === 'login');
+    expect(loginEdge).toBeTruthy();
+    expect(loginEdge.tf).toMatch(/store[\\/]modules[\\/]user\.js$/);
+    expect(loginEdge.via).toBe('user/login');
+    // The api helper of the same name was never targeted.
+    expect(edges.some((r: any) => /api[\\/]user\.js$/.test(r.tf))).toBe(false);
+    // Local commit('SET_TOKEN') inside the action → the same module's mutation.
+    expect(edges.some((r: any) => r.source === 'login' && r.target === 'SET_TOKEN')).toBe(true);
+    // Redux-style non-string dispatch contributed nothing.
+    expect(edges.some((r: any) => r.source === 'reduxy')).toBe(false);
+
+    cg.close?.();
+  });
+});

Разница между файлами не показана из-за своего большого размера
+ 58 - 0
docs/design/dispatch-synthesizer-backlog.md


+ 3 - 0
docs/design/dynamic-dispatch-coverage-playbook.md

@@ -7,6 +7,9 @@ each one the same way, so cross-symbol *flows* exist in the graph everywhere.
 
 > This is the top-level playbook. The deep design for one mechanism (the callback
 > synthesizer) is in [`callback-edge-synthesis.md`](./callback-edge-synthesis.md).
+> The cross-cutting **dispatch-shape** queue (Redux/RTK Query/NgRx/MediatR/registries —
+> organized by indirection shape, not language×framework) is in
+> [`dispatch-synthesizer-backlog.md`](./dispatch-synthesizer-backlog.md).
 > Full investigation context + findings: auto-memory `project_codegraph_read_displacement`.
 
 > **Update (2026-06-01):** the `codegraph_trace` and `codegraph_context` MCP tools were

+ 379 - 6
src/extraction/tree-sitter.ts

@@ -36,6 +36,24 @@ import {
 // Re-export for backward compatibility
 export { generateNodeId } from './tree-sitter-helpers';
 
+/**
+ * RTK Query generated-hook naming convention: `use` + PascalCase endpoint (with
+ * an optional `Lazy` variant prefix) + `Query`/`Mutation`. Matches the hook
+ * bindings to extract from an `export const {...} = api` destructuring. Kept in
+ * sync with the same convention in `callback-synthesizer.ts` (the synth side).
+ */
+const RTK_HOOK_NAME_RE = /^use[A-Z][A-Za-z0-9]*(?:Query|Mutation)$/;
+
+/** Vue store collections whose object-literal members are the symbols an agent
+ *  looks for. Extracted as function nodes so `actions`/`mutations`/`getters` are
+ *  findable + readable (the foundation under any later dispatch-bridge synth). */
+const VUE_STORE_COLLECTION_NAMES = new Set(['actions', 'mutations', 'getters']);
+/** Store-definition callees whose config object carries those collections. */
+const VUE_STORE_FACTORY_CALLEES = new Set(['defineStore', 'createStore']);
+/** Distinct signals that a file is a Vuex/Pinia store (≥2 ⇒ treat a bare
+ *  `const actions = {…}` as a store collection — see looksLikeVueStoreFile). */
+const VUE_STORE_FILE_SIGNAL = /\bdefineStore\b|\bcreateStore\b|\bVuex\b|\bmutations\b|\bactions\b|\bgetters\b|\bnamespaced\b/g;
+
 /**
  * Extract the name from a node based on language
  */
@@ -317,6 +335,8 @@ export class TreeSitterExtractor {
   // (see flushFnRefCandidates).
   private fnRefSpec: FnRefSpec | undefined;
   private fnRefCandidates: Array<FnRefCandidate & { fromNodeId: string }> = [];
+  // Memoized "is this a Vue store file" verdict (per-extractor = per-file).
+  private vueStoreFile: boolean | null = null;
 
   constructor(filePath: string, source: string, language?: Language) {
     this.filePath = filePath;
@@ -1046,6 +1066,24 @@ export class TreeSitterExtractor {
       const parentId = this.nodeStack[this.nodeStack.length - 1];
       if (parentId) this.emitReExportRefs(node, parentId);
     }
+    // Vuex MODULE default export — `export default { namespaced, actions: {…},
+    // mutations: {…} }` (the canonical Vuex module shape). Object-literal methods
+    // aren't otherwise extracted, so scan the config's actions/mutations/getters
+    // collections and extract their methods as nodes. Store-file gated (the
+    // ≥2-signal heuristic) so a plain default-exported object is untouched; skip
+    // the subtree afterward (the collection methods are now handled).
+    else if (
+      nodeType === 'export_statement' &&
+      (this.language === 'typescript' || this.language === 'tsx' ||
+       this.language === 'javascript' || this.language === 'jsx') &&
+      this.looksLikeVueStoreFile()
+    ) {
+      const exported = getChildByField(node, 'value');
+      if (exported && (exported.type === 'object' || exported.type === 'object_expression')) {
+        this.extractStoreCollectionMethods(exported);
+        skipChildren = true;
+      }
+    }
     // Check for function calls
     else if (this.extractor.callTypes.includes(nodeType)) {
       this.extractCall(node);
@@ -1945,6 +1983,285 @@ export class TreeSitterExtractor {
     return null;
   }
 
+  /**
+   * RTK Query: from a `createApi({ ..., endpoints: build => ({...}) })` or a
+   * `baseApi.injectEndpoints({ endpoints: build => ({...}) })` call initializer,
+   * return the object literal of endpoint definitions (the object the `endpoints`
+   * arrow returns). Returns null for any other call — the common case — so this
+   * stays cheap and silent. Keyed on the RTK entry-point names (`createApi` /
+   * `injectEndpoints`) like the framework extractors key on their library APIs.
+   */
+  private findRtkEndpointsObject(callNode: SyntaxNode): SyntaxNode | null {
+    const callee = getChildByField(callNode, 'function');
+    if (!callee) return null;
+    const calleeName =
+      callee.type === 'identifier'
+        ? getNodeText(callee, this.source)
+        : callee.type === 'member_expression'
+          ? getNodeText(getChildByField(callee, 'property') ?? callee, this.source)
+          : '';
+    if (calleeName !== 'createApi' && calleeName !== 'injectEndpoints') return null;
+    const args = getChildByField(callNode, 'arguments');
+    if (!args) return null;
+    for (let i = 0; i < args.namedChildCount; i++) {
+      const arg = args.namedChild(i);
+      if (arg?.type !== 'object' && arg?.type !== 'object_expression') continue;
+      for (let j = 0; j < arg.namedChildCount; j++) {
+        const member = arg.namedChild(j);
+        // Two equally-common spellings: `endpoints: build => ({...})` (pair with an
+        // arrow value) and `endpoints(build) { return {...} }` (method shorthand).
+        if (member?.type === 'pair') {
+          const key = getChildByField(member, 'key');
+          if (!key || getNodeText(key, this.source) !== 'endpoints') continue;
+          const value = getChildByField(member, 'value');
+          if (value && (value.type === 'arrow_function' || value.type === 'function_expression')) {
+            return this.functionReturnedObject(value);
+          }
+        } else if (member?.type === 'method_definition') {
+          const key = getChildByField(member, 'name');
+          if (!key || getNodeText(key, this.source) !== 'endpoints') continue;
+          return this.functionReturnedObject(member);
+        }
+      }
+    }
+    return null;
+  }
+
+  /**
+   * Extract each RTK Query endpoint (`getX: build.query({...})` / `build.mutation`)
+   * as a function node named by the endpoint key, spanning its primary handler
+   * (the `queryFn`/`query` arrow) so the fetch logic's calls attribute to the
+   * endpoint. Without this an endpoint exists only as an object-literal property —
+   * never a node — so the generated `useXQuery` hook can't be bridged to it.
+   */
+  private extractRtkEndpoints(obj: SyntaxNode): void {
+    for (let i = 0; i < obj.namedChildCount; i++) {
+      const member = obj.namedChild(i);
+      if (member?.type !== 'pair') continue;
+      const key = getChildByField(member, 'key');
+      const value = getChildByField(member, 'value');
+      if (!key || value?.type !== 'call_expression') continue;
+      // The value must be a builder dispatch `<builder>.query|mutation(...)`.
+      const callee = getChildByField(value, 'function');
+      if (callee?.type !== 'member_expression') continue;
+      const method = getNodeText(getChildByField(callee, 'property') ?? callee, this.source);
+      if (method !== 'query' && method !== 'mutation' && method !== 'infiniteQuery') continue;
+      const handler = this.rtkEndpointHandler(value);
+      if (handler) {
+        this.extractFunction(handler, this.objectKeyName(key));
+      } else {
+        // Factory / config-only handler (`queryFn: makeQueryFn(url)`): no function
+        // literal to name. Mint a bare endpoint node spanning the builder call so
+        // the generated hook still bridges to it, and walk the call so its handler
+        // factory (and any inline transform) is captured as an outgoing edge.
+        const epNode = this.createNode('function', this.objectKeyName(key), value, {
+          signature: getNodeText(value, this.source).slice(0, 80),
+        });
+        if (epNode) {
+          this.nodeStack.push(epNode.id);
+          this.visitFunctionBody(value, epNode.id);
+          this.nodeStack.pop();
+        }
+      }
+    }
+  }
+
+  /**
+   * The primary handler arrow of a `build.query({ queryFn|query: (…) => … })`
+   * endpoint — prefers `queryFn`, then `query`, else the first function-valued
+   * property. Returns null when the endpoint is config-only (no handler arrow).
+   */
+  private rtkEndpointHandler(callNode: SyntaxNode): SyntaxNode | null {
+    const args = getChildByField(callNode, 'arguments');
+    if (!args) return null;
+    for (let i = 0; i < args.namedChildCount; i++) {
+      const arg = args.namedChild(i);
+      if (arg?.type !== 'object' && arg?.type !== 'object_expression') continue;
+      let queryFn: SyntaxNode | null = null;
+      let query: SyntaxNode | null = null;
+      let firstFn: SyntaxNode | null = null;
+      for (let j = 0; j < arg.namedChildCount; j++) {
+        const member = arg.namedChild(j);
+        // The handler may be `queryFn: () => …` / `query: () => …` (pair) or the
+        // method-shorthand `query(arg) { … }` / `queryFn(arg) { … }`.
+        let fn: SyntaxNode | null = null;
+        let kn = '';
+        if (member?.type === 'pair') {
+          const v = getChildByField(member, 'value');
+          if (v?.type === 'arrow_function' || v?.type === 'function_expression') {
+            fn = v;
+            const k = getChildByField(member, 'key');
+            kn = k ? getNodeText(k, this.source) : '';
+          }
+        } else if (member?.type === 'method_definition') {
+          fn = member;
+          const k = getChildByField(member, 'name');
+          kn = k ? getNodeText(k, this.source) : '';
+        }
+        if (!fn) continue;
+        if (kn === 'queryFn') queryFn = fn;
+        else if (kn === 'query') query = fn;
+        if (!firstFn) firstFn = fn;
+      }
+      if (queryFn) return queryFn;
+      if (query) return query;
+      if (firstFn) return firstFn;
+    }
+    return null;
+  }
+
+  /**
+   * RTK Query generated-hook bindings. `export const { useGetXQuery,
+   * useUpdateYMutation } = someApi` destructures the hooks RTK generates per
+   * endpoint off a createApi result. They are real exported symbols that
+   * components import, but destructured bindings aren't otherwise extracted —
+   * mint a function node per binding matching the RTK hook convention so the hook
+   * resolves and the synthesizer can bridge it to its endpoint. Gated tight by the
+   * caller (object-pattern off a bare identifier) + the name convention here, so
+   * ordinary destructures stay unextracted.
+   */
+  private extractRtkHookBindings(pattern: SyntaxNode, isExported: boolean): void {
+    for (let i = 0; i < pattern.namedChildCount; i++) {
+      const binding = pattern.namedChild(i);
+      if (binding?.type !== 'shorthand_property_identifier_pattern') continue;
+      const name = getNodeText(binding, this.source);
+      if (!RTK_HOOK_NAME_RE.test(name)) continue;
+      this.createNode('function', name, binding, {
+        isExported,
+        signature: '= RTK Query generated hook',
+      });
+    }
+  }
+
+  /** Cheap per-file heuristic: the file carries ≥2 distinct Vue-store signals
+   *  (defineStore/createStore/Vuex, or the actions/mutations/getters/namespaced
+   *  vocabulary). Gates the non-exported `const actions = {…}` Vuex-module form so
+   *  a stray `const actions` in unrelated code is never mistaken for a store. */
+  private looksLikeVueStoreFile(): boolean {
+    if (this.vueStoreFile !== null) return this.vueStoreFile;
+    const seen = new Set<string>();
+    VUE_STORE_FILE_SIGNAL.lastIndex = 0;
+    let m: RegExpExecArray | null;
+    while ((m = VUE_STORE_FILE_SIGNAL.exec(this.source))) {
+      seen.add(m[0]);
+      if (seen.size >= 2) break;
+    }
+    this.vueStoreFile = seen.size >= 2;
+    return this.vueStoreFile;
+  }
+
+  /** True if an object literal has ≥1 inline function member (`key: () => …` /
+   *  `method(){}`) — distinguishes an inline action map (zustand/SvelteKit form
+   *  actions) from a Pinia SETUP store's all-shorthand `return { foo, bar }`
+   *  (whose functions are body-local consts, walked normally instead). */
+  private objectHasInlineFunctions(obj: SyntaxNode): boolean {
+    for (let i = 0; i < obj.namedChildCount; i++) {
+      const member = obj.namedChild(i);
+      if (member?.type === 'method_definition') return true;
+      if (member?.type === 'pair') {
+        const v = getChildByField(member, 'value');
+        if (v?.type === 'arrow_function' || v?.type === 'function_expression') return true;
+      }
+    }
+    return false;
+  }
+
+  /** Vue store action/mutation/getter collections defined INLINE in a store call:
+   *  `defineStore({ actions: {…}, getters: {…} })` (Pinia options form),
+   *  `defineStore('id', { actions: {…} })`, `createStore({ mutations: {…} })`,
+   *  `new Vuex.Store({ actions: {…} })`. Returns the object literals under those
+   *  keys so their methods become nodes. Gated on the store-factory callee. */
+  private findVueStoreCollectionObjects(callNode: SyntaxNode): SyntaxNode[] {
+    const callee = getChildByField(callNode, 'function') ?? getChildByField(callNode, 'constructor');
+    if (!callee) return [];
+    const calleeName =
+      callee.type === 'identifier'
+        ? getNodeText(callee, this.source)
+        : callee.type === 'member_expression'
+          ? getNodeText(getChildByField(callee, 'property') ?? callee, this.source)
+          : '';
+    if (!VUE_STORE_FACTORY_CALLEES.has(calleeName) && calleeName !== 'Store') return [];
+    const args = getChildByField(callNode, 'arguments');
+    if (!args) return [];
+    const objects: SyntaxNode[] = [];
+    for (let i = 0; i < args.namedChildCount; i++) {
+      const arg = args.namedChild(i);
+      if (arg?.type !== 'object' && arg?.type !== 'object_expression') continue;
+      for (let j = 0; j < arg.namedChildCount; j++) {
+        const member = arg.namedChild(j);
+        if (member?.type !== 'pair') continue;
+        const key = getChildByField(member, 'key');
+        if (!key || !VUE_STORE_COLLECTION_NAMES.has(getNodeText(key, this.source))) continue;
+        const value = getChildByField(member, 'value');
+        if (value && (value.type === 'object' || value.type === 'object_expression')) {
+          objects.push(value);
+        }
+      }
+    }
+    return objects;
+  }
+
+  /** Extract the methods of a store-config object's `actions`/`mutations`/`getters`
+   *  properties. Used for the canonical Vuex MODULE shape `export default {
+   *  namespaced, actions: {…}, mutations: {…} }` — object-literal methods aren't
+   *  otherwise extracted, so the actions/mutations would never be nodes. */
+  private extractStoreCollectionMethods(configObj: SyntaxNode): void {
+    for (let j = 0; j < configObj.namedChildCount; j++) {
+      const member = configObj.namedChild(j);
+      if (member?.type !== 'pair') continue;
+      const key = getChildByField(member, 'key');
+      if (!key || !VUE_STORE_COLLECTION_NAMES.has(getNodeText(key, this.source))) continue;
+      const value = getChildByField(member, 'value');
+      if (value && (value.type === 'object' || value.type === 'object_expression')) {
+        this.extractObjectLiteralFunctions(value);
+      }
+    }
+  }
+
+  /** The SETUP function of a Pinia setup store (`defineStore('id', () => {…})`)
+   *  — an arrow/function arg with a block body. Returns null for the options form
+   *  (`defineStore({…})`) and for any non-defineStore call. The setup body's local
+   *  function consts are the store's actions; the generic body walk doesn't reach
+   *  them (nested functions are separate scopes), so they're extracted explicitly. */
+  private findPiniaSetupFn(callNode: SyntaxNode): SyntaxNode | null {
+    const callee = getChildByField(callNode, 'function');
+    if (!callee || callee.type !== 'identifier' || getNodeText(callee, this.source) !== 'defineStore') return null;
+    const args = getChildByField(callNode, 'arguments');
+    if (!args) return null;
+    for (let i = 0; i < args.namedChildCount; i++) {
+      const arg = args.namedChild(i);
+      if (arg?.type !== 'arrow_function' && arg?.type !== 'function_expression') continue;
+      const body = getChildByField(arg, 'body');
+      if (body?.type === 'statement_block') return arg; // block body ⇒ setup form
+    }
+    return null;
+  }
+
+  /** Extract a Pinia setup store's actions: the body-local `const foo = () => …`
+   *  / `function foo(){}` declarations, named by the binding. (State refs and other
+   *  consts are left to the normal value-extraction; only the functions matter as
+   *  the store's callable surface.) */
+  private extractPiniaSetupBody(setupFn: SyntaxNode): void {
+    const body = getChildByField(setupFn, 'body');
+    if (!body || body.type !== 'statement_block') return;
+    for (let i = 0; i < body.namedChildCount; i++) {
+      const stmt = body.namedChild(i);
+      if (!stmt) continue;
+      if (stmt.type === 'function_declaration') {
+        this.extractFunction(stmt);
+      } else if (this.extractor!.variableTypes.includes(stmt.type)) {
+        for (let j = 0; j < stmt.namedChildCount; j++) {
+          const decl = stmt.namedChild(j);
+          if (decl?.type !== 'variable_declarator') continue;
+          const v = getChildByField(decl, 'value');
+          if (v?.type === 'arrow_function' || v?.type === 'function_expression') {
+            this.extractFunction(v); // name resolved from the parent declarator
+          }
+        }
+      }
+    }
+  }
+
   /**
    * Extract a variable declaration (const, let, var, etc.)
    *
@@ -1977,8 +2294,15 @@ export class TreeSitterExtractor {
 
           if (nameNode) {
             // Skip destructured patterns (e.g., `let { x, y } = $props()` in Svelte)
-            // These produce ugly multi-line names like "{ class: className }"
+            // These produce ugly multi-line names like "{ class: className }".
+            // EXCEPT `export const { useGetXQuery } = someApi` — the RTK Query
+            // generated hooks: real exported symbols destructured off a createApi
+            // result. Mint a node per binding matching the hook convention (gated
+            // on a bare-identifier RHS so ordinary destructures stay skipped).
             if (nameNode.type === 'object_pattern' || nameNode.type === 'array_pattern') {
+              if (nameNode.type === 'object_pattern' && valueNode?.type === 'identifier') {
+                this.extractRtkHookBindings(nameNode, isExported);
+              }
               continue;
             }
             const name = getNodeText(nameNode, this.source);
@@ -2025,23 +2349,72 @@ export class TreeSitterExtractor {
                 : valueNode?.type === 'call_expression'
                   ? this.findInitializerReturnedObject(valueNode)
                   : null;
-            const extractObjectMethods = isExported && !!objectOfFns;
+            // Only treat as an inline object-of-functions when the object actually
+            // HAS inline functions. A Pinia SETUP store `defineStore('id', () => {
+            // const foo = …; return { foo } })` returns an ALL-SHORTHAND object
+            // whose functions are body-local consts — it must fall through to a
+            // normal body walk (extracting those consts), not be skipped here.
+            const hasInlineFns = !!objectOfFns && this.objectHasInlineFunctions(objectOfFns);
+            const extractObjectMethods = isExported && !!objectOfFns && hasInlineFns;
+
+            // RTK Query: `createApi`/`injectEndpoints` define endpoints as
+            // object-literal properties whose values are `build.query/mutation(...)`
+            // calls — nested under an `endpoints` arrow, so neither the
+            // object-of-functions path above nor the normal walk extracts them.
+            // Extract each endpoint as a function node (named by its key), and skip
+            // walking the createApi call body (its handler arrows are extracted
+            // individually below, exactly like the store-factory case).
+            const rtkEndpoints =
+              valueNode?.type === 'call_expression' ? this.findRtkEndpointsObject(valueNode) : null;
+
+            // Pinia SETUP store: `defineStore('id', () => { const foo = …; return {…} })`.
+            // Its actions are body-local consts the generic walk can't reach.
+            const piniaSetup =
+              valueNode?.type === 'call_expression' ? this.findPiniaSetupFn(valueNode) : null;
+
+            // Vue store collections — make `actions`/`mutations`/`getters` findable
+            // function nodes (the foundation under any later dispatch-bridge synth).
+            // Two positions: INLINE in a store call (`defineStore({ actions: {…} })`
+            // / `createStore` / `new Vuex.Store`), and the non-exported Vuex-MODULE
+            // form (`const actions = {…}` at a store file's top level, wired via a
+            // `export default { actions }`). The Pinia SETUP form is handled by the
+            // body walk above (its actions are local consts).
+            const storeCollections: SyntaxNode[] = [];
+            if (valueNode?.type === 'call_expression' || valueNode?.type === 'new_expression') {
+              storeCollections.push(...this.findVueStoreCollectionObjects(valueNode));
+            }
+            if (objectOfFns && !extractObjectMethods &&
+                VUE_STORE_COLLECTION_NAMES.has(name) && this.looksLikeVueStoreFile()) {
+              storeCollections.push(objectOfFns);
+            }
 
             // Visit the initializer body for calls — EXCEPT object literals (their
             // function-valued properties are extracted below) and the store-factory
-            // call whose returned object we extract method-by-method below (walking
-            // the whole call would re-visit those method arrows and mis-attribute
-            // their inner calls to the file/module scope).
+            // / createApi / store-collection call whose nested objects we extract
+            // method-by-method below (walking the whole call would re-visit those
+            // method arrows and mis-attribute their inner calls to the file scope).
             if (valueNode &&
                 valueNode.type !== 'object' &&
                 valueNode.type !== 'object_expression' &&
-                !(extractObjectMethods && valueNode.type === 'call_expression')) {
+                !(extractObjectMethods && valueNode.type === 'call_expression') &&
+                !rtkEndpoints &&
+                !piniaSetup &&
+                storeCollections.length === 0) {
               this.visitFunctionBody(valueNode, '');
             }
 
             if (extractObjectMethods && objectOfFns) {
               this.extractObjectLiteralFunctions(objectOfFns);
             }
+            if (rtkEndpoints) {
+              this.extractRtkEndpoints(rtkEndpoints);
+            }
+            if (piniaSetup) {
+              this.extractPiniaSetupBody(piniaSetup);
+            }
+            for (const coll of storeCollections) {
+              this.extractObjectLiteralFunctions(coll);
+            }
           }
         }
       }

+ 80 - 41
src/mcp/tools.ts

@@ -1528,6 +1528,13 @@ export class ToolHandler {
         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.
+    if (typeof m?.synthesizedBy === 'string') {
+      const kind = m.synthesizedBy.replace(/-/g, ' ');
+      return { label: `${kind} (dynamic dispatch)`, compact: `dynamic: ${kind}${at}`, registeredAt };
+    }
     return null;
   }
 
@@ -1583,8 +1590,20 @@ export class ToolHandler {
       // A LARGE family that fails to connect on the chain is a polymorphic
       // interface/registry dispatch — surfaced by buildPolymorphicBoundaries below.
       const tokenFamily = new Map<string, Node[]>();
+      // Non-callable endpoints (CONSTANT/VARIABLE/FIELD) connected by a SYNTHESIZED
+      // edge. RTK thunks are `const X = createAsyncThunk(...)`, so a thunk→thunk hop
+      // is constant→constant — the CALLABLE-only `named` set can't hold it, and
+      // without this the hop is invisible to the Flow path at every tier (the
+      // Relationships section catches it only on repos ≥500 files). Kept SEPARATE
+      // from `named` (which drives the call-chain + source sizing, callable-only);
+      // fed only to the dynamic-dispatch-links scan below.
+      const dynNamed = new Map<string, Node>();
+      const DYN_KINDS = new Set(['constant', 'variable', 'field', 'property']);
+      const hasHeuristicEdge = (id: string): boolean =>
+        [...cg.getCallers(id), ...cg.getCallees(id)].some(({ edge }) => edge.provenance === 'heuristic');
       for (const t of tokens) {
-        const cands = this.findAllSymbols(cg, t).nodes.filter((n) => CALLABLE.has(n.kind));
+        const hits = this.findAllSymbols(cg, t).nodes;
+        const cands = hits.filter((n) => CALLABLE.has(n.kind));
         tokenFamily.set(t, cands);
         // A qualified or otherwise-specific name (<=3 hits) keeps all; an
         // ambiguous simple name keeps only candidates whose container is named.
@@ -1602,18 +1621,58 @@ export class ToolHandler {
           named.set(n.id, n);
           if (specific) uniqueNamedNodeIds.add(n.id);
         }
+        // Same token, non-callable synth endpoints (capped, precision-gated on an
+        // actual heuristic edge so plain config constants never qualify).
+        if (dynNamed.size < 12) {
+          for (const n of hits) {
+            if (CALLABLE.has(n.kind) || !DYN_KINDS.has(n.kind) || dynNamed.has(n.id)) continue;
+            if (hasHeuristicEdge(n.id)) dynNamed.set(n.id, n);
+            if (dynNamed.size >= 12) break;
+          }
+        }
         if (named.size > 40) break;
       }
+      // Surface synthesized (heuristic) edges incident to a named symbol — INCLUDING
+      // the non-callable CONSTANT endpoints in `dynNamed`. `skipInChain` drops a hop
+      // already shown in the rendered main chain (a 2-node chain renders nothing, so a
+      // direct named→named synth hop still surfaces — #687).
+      const collectSynthLinks = (skipInChain: ((e: Edge) => boolean) | null): string[] => {
+        const synthLines: string[] = [];
+        const synthSeen = new Set<string>();
+        for (const n of [...named.values(), ...dynNamed.values()]) {
+          if (synthLines.length >= 6) break;
+          for (const { node: other, edge } of [...cg.getCallers(n.id), ...cg.getCallees(n.id)]) {
+            if (synthLines.length >= 6) break;
+            if (edge.provenance !== 'heuristic' || other.id === n.id) continue;
+            if (skipInChain && skipInChain(edge)) continue;
+            const src = edge.source === n.id ? n : other;
+            const tgt = edge.source === n.id ? other : n;
+            const key = `${src.name}>${tgt.name}`;
+            if (synthSeen.has(key)) continue;
+            synthSeen.add(key);
+            const note = this.synthEdgeNote(edge);
+            synthLines.push(`- ${src.name} → ${tgt.name}   [${note ? note.compact : edge.kind}]`);
+          }
+        }
+        return synthLines;
+      };
       if (named.size < 2) {
-        // The agent named a flow but only one side resolved (the other end is
-        // anonymous / runtime-registered / not extracted). The resolved side's
-        // body may still hold the dynamic-dispatch site that EXPLAINS the gap —
-        // surface that instead of silently returning nothing.
-        if (named.size === 0) return EMPTY;
-        const boundaries = this.buildDynamicBoundaries(cg, [...named.values()], named);
-        if (!boundaries) return EMPTY;
-        const text = boundaries + '> Full source for these symbols is below.\n';
-        return { text, pathNodeIds: new Set(), namedNodeIds: new Set(named.keys()), uniqueNamedNodeIds, spineCallSites: new Map<string, number>() };
+        // <2 CALLABLES resolved. Two recoveries before giving up: (1) synthesized
+        // edges among named CONSTANT/VARIABLE endpoints — RTK thunk→thunk is
+        // constant→constant, so `named` can be empty while `dynNamed` holds the
+        // whole chain; (2) the one resolved callable's body may hold the
+        // dynamic-dispatch site that EXPLAINS a half-connected flow.
+        const synthLines = collectSynthLinks(null);
+        const boundaries = named.size === 0 ? '' : (this.buildDynamicBoundaries(cg, [...named.values()], named) || '');
+        if (synthLines.length === 0 && !boundaries) return EMPTY;
+        const out: string[] = [];
+        if (synthLines.length) out.push(
+          '## Dynamic-dispatch links among your symbols',
+          '(synthesized — the indirect hops grep/Read would reconstruct; the `@file:line` is the wiring site)',
+          '', ...synthLines, '');
+        if (boundaries) out.push(boundaries);
+        out.push('> Full source for these symbols is below.\n');
+        return { text: out.join('\n'), pathNodeIds: new Set(), namedNodeIds: new Set<string>([...named.keys(), ...dynNamed.keys()]), uniqueNamedNodeIds, spineCallSites: new Map<string, number>() };
       }
       const MAX_HOPS = 7;
       let best: Array<{ node: Node; edge: Edge | null }> | null = null;
@@ -1709,36 +1768,16 @@ export class ToolHandler {
         if (polyCands.length) polyText = this.buildPolymorphicBoundaries(cg, polyCands, named);
       }
 
-      // Supplementary: dynamic-dispatch (synthesized) edges incident to a NAMED
-      // symbol — the indirect hops an agent would otherwise grep/Read to
-      // reconstruct ("where do the appended `validators` actually run?"). The
-      // synth edge IS that answer, so surface it even when the OTHER end wasn't
-      // named (e.g. the agent names `validate` but not the `didCompleteTask`
-      // that drains the collection). On-topic by construction: only heuristic
-      // edges touching a symbol the agent named; skipped when the hop already
-      // shows in the main chain.
-      const synthLines: string[] = [];
-      const synthSeen = new Set<string>();
-      for (const n of named.values()) {
-        if (synthLines.length >= 6) break;
-        for (const { node: other, edge } of [...cg.getCallers(n.id), ...cg.getCallees(n.id)]) {
-          if (synthLines.length >= 6) break;
-          if (edge.provenance !== 'heuristic' || other.id === n.id) continue;
-          // "Already in the main chain" only applies when a chain RENDERS
-          // (hasMain). A 2-node chain populates pathIds but renders nothing,
-          // so a direct synthesized hop between two named symbols (custom
-          // EventBus emit→handler, #687) was invisible — too short for Flow,
-          // skipped here as in-chain. Surface it.
-          if (hasMain && pathIds.has(edge.source) && pathIds.has(edge.target)) continue;
-          const src = edge.source === n.id ? n : other;
-          const tgt = edge.source === n.id ? other : n;
-          const key = `${src.name}>${tgt.name}`;
-          if (synthSeen.has(key)) continue;
-          synthSeen.add(key);
-          const note = this.synthEdgeNote(edge);
-          synthLines.push(`- ${src.name} → ${tgt.name}   [${note ? note.compact : edge.kind}]`);
-        }
-      }
+      // Supplementary: dynamic-dispatch (synthesized) edges incident to a named
+      // symbol (incl. the non-callable CONSTANT endpoints in `dynNamed`) — the
+      // indirect hops an agent would otherwise grep/Read to reconstruct ("where do
+      // the appended `validators` actually run?"). Surfaced even when the OTHER end
+      // wasn't named. The skip drops a hop already in the rendered main chain; a
+      // 2-node chain renders nothing (hasMain false) so a direct named→named synth
+      // hop still surfaces — too short for Flow, but #687-visible here.
+      const synthLines = collectSynthLinks(
+        hasMain ? (e: Edge) => pathIds.has(e.source) && pathIds.has(e.target) : null
+      );
 
       if (!hasMain && synthLines.length === 0 && !boundaryText && !polyText) return EMPTY;
       const out: string[] = [];
@@ -1768,7 +1807,7 @@ export class ToolHandler {
       // must keep full source even if it's an off-spine polymorphic sibling — the
       // agent named `getResponseWithInterceptorChain` / `SQLCompiler.execute_sql`
       // as the mechanism, not as an interchangeable leaf. See the skeleton gate.
-      return { text: out.join('\n'), pathNodeIds: pathIds, namedNodeIds: new Set(named.keys()), uniqueNamedNodeIds, spineCallSites };
+      return { text: out.join('\n'), pathNodeIds: pathIds, namedNodeIds: new Set<string>([...named.keys(), ...dynNamed.keys()]), uniqueNamedNodeIds, spineCallSites };
     } catch {
       return EMPTY;
     }

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

@@ -1681,9 +1681,20 @@ function reduxThunkEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[]
     while ((m = THUNK_DISPATCH_RE.exec(safe)) && added < THUNK_FANOUT_CAP) {
       const name = m[1]!;
       if (name === node.name) continue; // self-dispatch (recursive thunk) — skip
-      const target = ctx
+      // Resolve the dispatched name, PREFERRING the thunk/action-creator over a same-named
+      // service function. `dispatch(X(...))` dispatches a thunk or an action-creator (both
+      // `constant`s) — never an unrelated helper that merely shares the name. On octo-call,
+      // `leaveCall` is BOTH a `createAsyncThunk` const AND a service function, and the bare
+      // `.find()` picked the function (wrong). Order: thunk const > other const > same-file
+      // callable > first match. A single candidate (no collision) is unaffected.
+      const cands = ctx
         .getNodesByName(name)
-        .find((n) => n.kind === 'constant' || n.kind === 'function' || n.kind === 'method');
+        .filter((n) => n.kind === 'constant' || n.kind === 'function' || n.kind === 'method');
+      const target =
+        cands.find((n) => !!n.signature && THUNK_DECL_RE.test(n.signature)) ??
+        cands.find((n) => n.kind === 'constant') ??
+        cands.find((n) => n.filePath === node.filePath) ??
+        cands[0];
       if (!target || target.id === node.id) continue;
       const key = `${node.id}>${target.id}`;
       if (seen.has(key)) continue;
@@ -1703,11 +1714,485 @@ function reduxThunkEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[]
   return edges;
 }
 
+// ── Object-literal registry dispatch ─────────────────────────────────────────
+// A command/handler registry maps string keys → handler class/function symbols in an
+// object literal, then dispatches by a RUNTIME key static parsing can't follow:
+//   this.commands = { [Cmd.ADD]: AddObjectCommand, ... }    // registration
+//   new this.commands[command](args).execute()              // dynamic dispatch
+// Bridge it like gin-middleware-chain: link each dispatching function → each registered
+// handler's callable entry (a class's execute/run/handle/… method — preferring the method
+// chained at the dispatch site — or the function value). Scoped to a registry + dispatch in
+// the SAME file (the cross-file barrel-namespace variant, e.g. trezor's getMethod, is
+// deferred). Gated on a real object literal with ≥2 entries that RESOLVE to callables (a
+// `{ width: 5 }` literal resolves to nothing → no edges); fan-out capped.
+const REGISTRY_ASSIGN_RE = /(?:(?:const|let|var)\s+([A-Za-z_$][\w$]*)|((?:this\.)?[A-Za-z_$][\w$]*))\s*=\s*\{/g;
+const REGISTRY_DISPATCH_RE = /(?:\bnew\s+)?((?:this\.)?[A-Za-z_$][\w$]*)\s*\[\s*([A-Za-z_$][\w$.]*)\s*\]\s*(?:\(|\.[A-Za-z_$])/g;
+const REGISTRY_MIN_ENTRIES = 2;
+const REGISTRY_FANOUT_CAP = 40;
+const REGISTRY_CLASS_ENTRY = new Set(['execute', 'run', 'handle', 'perform', 'process', 'call', 'apply', 'dispatch']);
+const REGISTRY_JS_EXT = /\.(?:ts|tsx|js|jsx|mjs|cjs)$/;
+
+/** From the index of an opening `{`, return the brace-balanced body up to its matching `}`. */
+function braceBody(src: string, openIdx: number): string | null {
+  let depth = 0;
+  for (let i = openIdx; i < src.length; i++) {
+    if (src[i] === '{') depth++;
+    else if (src[i] === '}' && --depth === 0) return src.slice(openIdx + 1, i);
+  }
+  return null;
+}
+
+/** Top-level `key: Identifier` entries of an object-literal body. DEPTH-AWARE: only depth-0
+ *  segments are considered, so method-shorthand bodies (`number(a,b){…}`), arrow values
+ *  (`x: () => …`), and nested objects (`x: { … }`) don't leak their inner `k: v` pairs as
+ *  bogus handlers. The per-segment anchor (`^… key: Ident …$`) keeps only pure identifier
+ *  values — a data value (`x: 5`), call, or arrow fails to match. */
+function registryEntryNames(body: string): string[] {
+  const segs: 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 === ',' && depth === 0) { segs.push(body.slice(start, i)); start = i + 1; }
+  }
+  segs.push(body.slice(start));
+  const names: string[] = [];
+  for (const seg of segs) {
+    const m = /^\s*(?:\[[^\]]+\]|['"]?[\w$]+['"]?)\s*:\s*([A-Za-z_$][\w$]*)\s*$/.exec(seg);
+    if (m && m[1]!.length >= 3 && !names.includes(m[1]!)) names.push(m[1]!);
+  }
+  return names;
+}
+
+/** Resolve a registered handler name to its callable entry: a function value, or a class's
+ *  `execute`-like method (preferring the method chained at the dispatch site), else the class. */
+function resolveRegistryHandler(ctx: ResolutionContext, name: string, chained: string | null): Node | null {
+  const cands = ctx.getNodesByName(name);
+  const fn = cands.find((n) => n.kind === 'function');
+  if (fn) return fn;
+  const cls = cands.find((n) => n.kind === 'class' || n.kind === 'struct');
+  if (cls) {
+    const methods = ctx
+      .getNodesInFile(cls.filePath)
+      .filter((n) => n.kind === 'method' && n.startLine >= cls.startLine && n.startLine <= (cls.endLine ?? cls.startLine));
+    const want = chained && REGISTRY_CLASS_ENTRY.has(chained) ? chained : null;
+    const entry =
+      (want && methods.find((m) => m.name === want)) ||
+      methods.find((m) => REGISTRY_CLASS_ENTRY.has(m.name)) ||
+      methods.find((m) => m.name === 'constructor');
+    return entry ?? cls;
+  }
+  // Require a CALLABLE target — a registry dispatched as `reg[k](…)` invokes a function/
+  // method, never a data `constant` (dropping it removes false positives like a `{ x: URL }`
+  // entry resolving to the global URL constant).
+  return cands.find((n) => n.kind === 'method') ?? null;
+}
+
+function objectRegistryEdges(ctx: ResolutionContext): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  for (const file of ctx.getAllFiles()) {
+    if (!REGISTRY_JS_EXT.test(file)) continue;
+    const content = ctx.readFile(file);
+    // Cheap pre-filter: a computed member access BY NAME (`ident[ident`) — the dispatch shape.
+    if (!content || !/[\w$]\s*\[\s*[A-Za-z_$]/.test(content)) continue;
+    // Skip minified/generated bundles (draco, three.min, base64…): their pervasive `h[x](...)`
+    // calls + single-letter `{a:b}` literals are a false-positive minefield. Average line
+    // length is the reliable tell — real source ~30–80, minified in the hundreds/thousands.
+    const newlines = (content.match(/\n/g)?.length ?? 0) + 1;
+    if (content.length / newlines > 200) continue;
+    const safe = stripCommentsForRegex(content, /\.(?:jsx?|mjs|cjs)$/.test(file) ? 'javascript' : 'typescript');
+
+    // 1. Dispatch sites: `(new )?<ref>[<ident-key>]` followed by a call or a chained method.
+    //    A quoted-string key (`['save']`) does NOT match — that's a static access, not dispatch.
+    REGISTRY_DISPATCH_RE.lastIndex = 0;
+    const dispatches: Array<{ ref: string; line: number; chained: string | null }> = [];
+    let dm: RegExpExecArray | null;
+    while ((dm = REGISTRY_DISPATCH_RE.exec(safe))) {
+      const win = safe.slice(dm.index, dm.index + 160);
+      const cm = /\]\s*\([^)]*\)\s*\.\s*([A-Za-z_$][\w$]*)/.exec(win) || /\]\s*\.\s*([A-Za-z_$][\w$]*)/.exec(win);
+      dispatches.push({ ref: dm[1]!, line: safe.slice(0, dm.index).split('\n').length, chained: cm ? cm[1]! : null });
+    }
+    if (!dispatches.length) continue;
+    // Normalize a leading `this.` so a class FIELD-INITIALIZER registry (`commands = {…}`)
+    // matches a `this.commands[k]` dispatch, not just the constructor form `this.commands = {…}`.
+    const norm = (r: string) => r.replace(/^this\./, '');
+    const refs = new Set(dispatches.map((d) => norm(d.ref)));
+
+    // 2. Registries: an object literal assigned to a dispatched ref, ≥2 entries resolving to callables.
+    REGISTRY_ASSIGN_RE.lastIndex = 0;
+    const registries = new Map<string, { names: string[]; line: number }>();
+    let am: RegExpExecArray | null;
+    while ((am = REGISTRY_ASSIGN_RE.exec(safe))) {
+      const lhs = norm(am[1] ?? am[2]!);
+      if (!refs.has(lhs) || registries.has(lhs)) continue;
+      const body = braceBody(safe, am.index + am[0].length - 1);
+      if (!body) continue;
+      const names = registryEntryNames(body); // depth-0 `key: Identifier` entries only
+      if (names.length >= REGISTRY_MIN_ENTRIES) {
+        registries.set(lhs, { names, line: safe.slice(0, am.index).split('\n').length });
+      }
+    }
+    if (!registries.size) continue;
+
+    // 3. Link each dispatcher → each registered handler's callable entry.
+    const nodesInFile = ctx.getNodesInFile(file);
+    for (const d of dispatches) {
+      const reg = registries.get(norm(d.ref));
+      if (!reg) continue;
+      const disp = enclosingFn(nodesInFile, d.line);
+      if (!disp) continue;
+      let added = 0;
+      for (const name of reg.names) {
+        if (added >= REGISTRY_FANOUT_CAP) break;
+        const target = resolveRegistryHandler(ctx, name, d.chained);
+        if (!target || target.id === disp.id) continue;
+        const key = `${disp.id}>${target.id}`;
+        if (seen.has(key)) continue;
+        seen.add(key);
+        edges.push({
+          source: disp.id,
+          target: target.id,
+          kind: 'calls',
+          line: d.line,
+          provenance: 'heuristic',
+          metadata: { synthesizedBy: 'object-registry', via: name, registeredAt: `${file}:${reg.line}` },
+        });
+        added++;
+      }
+    }
+  }
+  return edges;
+}
+
+// ── RTK Query generated-hook → endpoint ──────────────────────────────────────
+// RTK Query generates one `useGetXQuery`/`useUpdateYMutation` hook per endpoint
+// (`createApi({ endpoints: b => ({ getX: b.query(...) }) })`). Components call the
+// hook; the fetch logic lives in the endpoint's queryFn. The hook↔endpoint link is
+// pure NAMING CONVENTION (no static edge): strip `use` + the optional `Lazy`
+// variant + the `Query|Mutation` suffix, lowercase the head → the endpoint key.
+// Both are extracted as function nodes (the hook from its `export const {…}=api`
+// binding, carrying a sentinel signature; the endpoint from the createApi object),
+// so bridging hook→endpoint connects `component → useGetXQuery → getX → queryFn`.
+// Gated on the extraction sentinel so it only ever fires on genuinely-generated
+// hooks (never a hand-written `useFooQuery`), and on a SAME-FILE endpoint (RTK
+// colocates the hooks and their api in one module) — 0 on any non-RTK repo.
+const RTK_HOOK_DERIVE_RE = /^use([A-Z][A-Za-z0-9]*?)(?:Query|Mutation)$/;
+// MUST match the signature set in tree-sitter.ts `extractRtkHookBindings`.
+const RTK_GENERATED_HOOK_SIGNATURE = '= RTK Query generated hook';
+
+/** Derive the endpoint key from a generated-hook name (`useLazyGetRecordsQuery`
+ *  → `getRecords`), or null if it doesn't fit the convention. */
+function rtkEndpointNameFromHook(hook: string): string | null {
+  const m = RTK_HOOK_DERIVE_RE.exec(hook);
+  if (!m) return null;
+  let mid = m[1]!;
+  if (mid.startsWith('Lazy')) mid = mid.slice(4); // useLazyGetX → getX (same endpoint)
+  if (!mid) return null;
+  return mid.charAt(0).toLowerCase() + mid.slice(1);
+}
+
+function rtkQueryEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  for (const hook of queries.iterateNodesByKind('function')) {
+    // Only our extracted generated-hook bindings (sentinel) — not a real hook fn.
+    if (hook.signature !== RTK_GENERATED_HOOK_SIGNATURE) continue;
+    const endpointName = rtkEndpointNameFromHook(hook.name);
+    if (!endpointName) continue;
+    // The endpoint is a same-file function by the derived name (RTK colocates the
+    // api definition and its generated-hook exports in one module).
+    const target = ctx
+      .getNodesByName(endpointName)
+      .find((n) => n.kind === 'function' && n.filePath === hook.filePath);
+    if (!target || target.id === hook.id) continue;
+    const key = `${hook.id}>${target.id}`;
+    if (seen.has(key)) continue;
+    seen.add(key);
+    edges.push({
+      source: hook.id,
+      target: target.id,
+      kind: 'calls',
+      line: hook.startLine,
+      provenance: 'heuristic',
+      metadata: { synthesizedBy: 'rtk-query', via: endpointName, registeredAt: `${hook.filePath}:${hook.startLine}` },
+    });
+  }
+  return edges;
+}
+
+// ── Pinia useStore().action() dispatch bridge ────────────────────────────────
+// A Pinia store factory `export const useXStore = defineStore(...)` exposes its
+// actions as methods on the store instance; a consumer does `const s = useXStore()`
+// then `s.action()`. The call is a method-on-instance with no static edge to the
+// action (which lives in the store's module). Bridge it: map each factory → its
+// file, bind `const <var> = useXStore()` per consumer file, and link the enclosing
+// function → the `<var>.method()` action node IN THE STORE'S FILE. The same-store-
+// file gate keeps it precise (a Pinia built-in like `$patch` or an unrelated
+// same-named method resolves to nothing). Covers both the options and setup store
+// forms uniformly (the action is a function node in the store file either way).
+const PINIA_CONSUMER_EXT = /\.(?:ts|tsx|js|jsx|mjs|cjs|vue)$/;
+const PINIA_FACTORY_RE = /\b(?:export\s+)?const\s+(\w+)\s*=\s*defineStore\s*\(/g;
+const PINIA_BIND_RE = /\bconst\s+(\w+)\s*=\s*(?:await\s+)?(\w+)\s*\(/g;
+const PINIA_CALL_RE = /(\w+)\s*\.\s*(\w+)\s*\(/g;
+const PINIA_FANOUT_CAP = 80;
+
+function piniaStoreEdges(ctx: ResolutionContext): Edge[] {
+  // 1. Map each `const useXStore = defineStore(...)` factory → its store file.
+  const factoryFile = new Map<string, string>();
+  for (const file of ctx.getAllFiles()) {
+    if (!PINIA_CONSUMER_EXT.test(file)) continue;
+    const content = ctx.readFile(file);
+    if (!content || !content.includes('defineStore')) continue;
+    PINIA_FACTORY_RE.lastIndex = 0;
+    let m: RegExpExecArray | null;
+    while ((m = PINIA_FACTORY_RE.exec(content))) factoryFile.set(m[1]!, file);
+  }
+  if (!factoryFile.size) return [];
+
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  for (const file of ctx.getAllFiles()) {
+    if (!PINIA_CONSUMER_EXT.test(file)) continue;
+    const content = ctx.readFile(file);
+    if (!content || !content.includes('Store')) continue;
+    const safe = stripCommentsForRegex(content, /\.(?:jsx?|mjs|cjs)$/.test(file) ? 'javascript' : 'typescript');
+
+    // 2. Bind store vars in this file: `const <var> = <known-factory>(...)`.
+    const varStore = new Map<string, string>();
+    PINIA_BIND_RE.lastIndex = 0;
+    let bm: RegExpExecArray | null;
+    while ((bm = PINIA_BIND_RE.exec(safe))) {
+      const sf = factoryFile.get(bm[2]!);
+      if (sf) varStore.set(bm[1]!, sf);
+    }
+    if (!varStore.size) continue;
+
+    // 3. Link `<var>.<method>(` → the action function node in the store's file.
+    const nodesInFile = ctx.getNodesInFile(file);
+    const fallbackDispatcher = nodesInFile.find((n) => n.kind === 'component'); // .vue top-level setup
+    PINIA_CALL_RE.lastIndex = 0;
+    let cm: RegExpExecArray | null;
+    let added = 0;
+    while ((cm = PINIA_CALL_RE.exec(safe)) && added < PINIA_FANOUT_CAP) {
+      const storeFile = varStore.get(cm[1]!);
+      if (!storeFile) continue;
+      const method = cm[2]!;
+      const line = safe.slice(0, cm.index).split('\n').length;
+      const disp = enclosingFn(nodesInFile, line) ?? fallbackDispatcher;
+      if (!disp) continue;
+      const target = ctx
+        .getNodesByName(method)
+        .find((n) => n.kind === 'function' && n.filePath === storeFile);
+      if (!target || target.id === disp.id) continue;
+      const key = `${disp.id}>${target.id}`;
+      if (seen.has(key)) continue;
+      seen.add(key);
+      edges.push({
+        source: disp.id,
+        target: target.id,
+        kind: 'calls',
+        line,
+        provenance: 'heuristic',
+        metadata: { synthesizedBy: 'pinia-store', via: method, registeredAt: `${file}:${line}` },
+      });
+      added++;
+    }
+  }
+  return edges;
+}
+
+// ── Vuex string-keyed dispatch / commit bridge ───────────────────────────────
+// Vuex dispatches actions/mutations by a runtime STRING key: `dispatch('user/login')`
+// / `commit('SET_TOKEN')` / `this.$store.dispatch('app/toggleDevice')`. The action
+// & mutation definitions are object-literal methods in store module files (now
+// extracted as function nodes). Bridge the string key to its node: the LAST `/`
+// segment is the action/mutation name; the preceding segment is the namespace
+// (≈ the store module's file). Resolve the name to a function node IN A STORE FILE
+// (the store-file gate excludes a same-named `api/` helper — `getInfo`/`login`
+// commonly collide), disambiguated by the namespace appearing in the path (or, for
+// a root key, the same file — Vuex's local-module `commit('M')` inside an action).
+const VUEX_DISPATCH_RE = /\b(?:dispatch|commit)\s*\(\s*['"]([A-Za-z][\w/]*)['"]/g;
+const VUEX_STORE_SIGNAL = /\bdefineStore\b|\bcreateStore\b|\bVuex\b|\bmutations\b|\bactions\b|\bgetters\b|\bnamespaced\b/g;
+const VUEX_FANOUT_CAP = 120;
+
+/** A path segment (dir or filename stem) equals `seg` — `…/modules/user.js` has
+ *  the segment `user` for namespace `user`. */
+function pathHasSegment(filePath: string, seg: string): boolean {
+  return new RegExp('[\\\\/]' + seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\\\/.]').test(filePath);
+}
+
+function vuexDispatchEdges(ctx: ResolutionContext): Edge[] {
+  const storeFileCache = new Map<string, boolean>();
+  const isStoreFile = (file: string): boolean => {
+    let v = storeFileCache.get(file);
+    if (v === undefined) {
+      const c = ctx.readFile(file);
+      const seen = new Set<string>();
+      if (c) {
+        VUEX_STORE_SIGNAL.lastIndex = 0;
+        let sm: RegExpExecArray | null;
+        while ((sm = VUEX_STORE_SIGNAL.exec(c))) { seen.add(sm[0]); if (seen.size >= 2) break; }
+      }
+      v = seen.size >= 2;
+      storeFileCache.set(file, v);
+    }
+    return v;
+  };
+
+  const resolve = (key: string, dispatchFile: string): Node | null => {
+    const segs = key.split('/');
+    const action = segs[segs.length - 1]!;
+    const cands = ctx.getNodesByName(action).filter((n) => n.kind === 'function' && isStoreFile(n.filePath));
+    if (!cands.length) return null;
+    if (segs.length > 1) {
+      const mod = segs[segs.length - 2]!; // immediate namespace ≈ the module file
+      return cands.find((c) => pathHasSegment(c.filePath, mod)) ?? (cands.length === 1 ? cands[0]! : null);
+    }
+    // Root key: a local `commit('M')` inside an action targets the same module file;
+    // otherwise accept only an unambiguous single store-wide match.
+    return cands.find((c) => c.filePath === dispatchFile) ?? (cands.length === 1 ? cands[0]! : null);
+  };
+
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  for (const file of ctx.getAllFiles()) {
+    if (!PINIA_CONSUMER_EXT.test(file)) continue;
+    const content = ctx.readFile(file);
+    if (!content || (!content.includes('dispatch(') && !content.includes('commit('))) continue;
+    const safe = stripCommentsForRegex(content, /\.(?:jsx?|mjs|cjs)$/.test(file) ? 'javascript' : 'typescript');
+    const nodesInFile = ctx.getNodesInFile(file);
+    const fallback = nodesInFile.find((n) => n.kind === 'component'); // .vue top-level
+    VUEX_DISPATCH_RE.lastIndex = 0;
+    let m: RegExpExecArray | null;
+    let added = 0;
+    while ((m = VUEX_DISPATCH_RE.exec(safe)) && added < VUEX_FANOUT_CAP) {
+      const key = m[1]!;
+      const line = safe.slice(0, m.index).split('\n').length;
+      const disp = enclosingFn(nodesInFile, line) ?? fallback;
+      if (!disp) continue;
+      const target = resolve(key, file);
+      if (!target || target.id === disp.id) continue;
+      const edgeKey = `${disp.id}>${target.id}`;
+      if (seen.has(edgeKey)) continue;
+      seen.add(edgeKey);
+      edges.push({
+        source: disp.id,
+        target: target.id,
+        kind: 'calls',
+        line,
+        provenance: 'heuristic',
+        metadata: { synthesizedBy: 'vuex-dispatch', via: key, registeredAt: `${file}:${line}` },
+      });
+      added++;
+    }
+  }
+  return edges;
+}
+
+// ── Celery task dispatch (Python) ─────────────────────────────────────────────
+// Celery decouples a task's call site from its body through async dispatch:
+//   # tasks.py
+//   @shared_task                       # also @app.task / @celery_app.task / @<app>.task / @task
+//   def process(account_ids): ...
+//   # views.py — a DIFFERENT module
+//   process.apply_async(kwargs={...})  # or process.delay(...) — dynamic, no static edge
+// Bridge it: link the enclosing function/method at each `.delay(`/`.apply_async(` site → the
+// task function body. Precision rests on the DECORATOR gate — the dispatched name must resolve
+// to a Python function carrying a celery task decorator (read from the source lines above its
+// `def`, since the def's own startLine excludes the decorator). A `.delay()` on a non-task
+// object resolves to no task node → no edge, so a Celery-free repo yields 0. Same-file /
+// unique-candidate disambiguation like vuex. (Canvas forms — `group(t).delay()`, `t.s()`/`.si()`
+// — have no single identifier before `.delay`/`.apply_async`, so they're skipped, not mis-bridged.)
+const CELERY_DISPATCH_RE = /\b([A-Za-z_]\w*)\s*\.\s*(?:delay|apply_async)\s*\(/g;
+// A task decorator: bare `@shared_task`/`@task` or attribute `@app.task`/`@celery_app.task`,
+// each optionally called with args. `\b`-bounded and `@`-anchored so `@mytask`, or a symbol
+// merely named `task`, can't match. No `/g`, so `.test()` is stateless across reuse.
+const CELERY_TASK_DECORATOR_RE = /@\s*(?:[A-Za-z_][\w.]*\.)?(?:shared_task|task)\b/;
+const CELERY_PY_EXT = /\.py$/;
+const CELERY_FANOUT_CAP = 80;
+const CELERY_DECORATOR_LOOKBACK = 12; // max lines above a `def` to scan for its decorators
+
+function celeryDispatchEdges(ctx: ResolutionContext): Edge[] {
+  // Memoize the decorator check per task-candidate node: it reads the file and scans a few
+  // lines above the def. Only called on names that are actually `.delay`/`.apply_async`
+  // receivers, so the candidate set stays small.
+  const taskCache = new Map<string, boolean>();
+  const isCeleryTask = (node: Node): boolean => {
+    let v = taskCache.get(node.id);
+    if (v !== undefined) return v;
+    v = false;
+    if (node.kind === 'function' && CELERY_PY_EXT.test(node.filePath)) {
+      const content = ctx.readFile(node.filePath);
+      if (content) {
+        const lines = content.split('\n');
+        // startLine is the `def` line (decorators sit ABOVE it). Walk upward, stopping at the
+        // previous declaration so a non-task def can never inherit the prior def's decorator.
+        const stop = Math.max(0, node.startLine - 1 - CELERY_DECORATOR_LOOKBACK);
+        for (let i = node.startLine - 2; i >= stop; i--) {
+          const t = (lines[i] ?? '').trim();
+          if (/^(?:async\s+def|def|class)\b/.test(t)) break; // previous decl → stop
+          if (CELERY_TASK_DECORATOR_RE.test(t)) { v = true; break; }
+        }
+      }
+    }
+    taskCache.set(node.id, v);
+    return v;
+  };
+
+  const resolve = (name: string, dispatchFile: string): Node | null => {
+    const cands = ctx.getNodesByName(name).filter((n) => n.kind === 'function' && isCeleryTask(n));
+    if (!cands.length) return null;
+    if (cands.length === 1) return cands[0]!;
+    // Cross-module name collision: prefer a task defined in the dispatching file, else bail
+    // (ambiguous — precision over recall, like vuex's root-key resolution).
+    return cands.find((c) => c.filePath === dispatchFile) ?? null;
+  };
+
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  for (const file of ctx.getAllFiles()) {
+    if (!CELERY_PY_EXT.test(file)) continue;
+    const content = ctx.readFile(file);
+    if (!content || (!content.includes('.delay(') && !content.includes('.apply_async('))) continue;
+    const safe = stripCommentsForRegex(content, 'python');
+    const nodesInFile = ctx.getNodesInFile(file);
+    CELERY_DISPATCH_RE.lastIndex = 0;
+    let m: RegExpExecArray | null;
+    let added = 0;
+    while ((m = CELERY_DISPATCH_RE.exec(safe)) && added < CELERY_FANOUT_CAP) {
+      const name = m[1]!;
+      const line = safe.slice(0, m.index).split('\n').length;
+      const disp = enclosingFn(nodesInFile, line);
+      if (!disp) continue; // module-level dispatch — no source symbol to attribute
+      const target = resolve(name, file);
+      if (!target || target.id === disp.id) continue;
+      const key = `${disp.id}>${target.id}`;
+      if (seen.has(key)) continue;
+      seen.add(key);
+      edges.push({
+        source: disp.id,
+        target: target.id,
+        kind: 'calls',
+        line,
+        provenance: 'heuristic',
+        metadata: { synthesizedBy: 'celery-dispatch', via: name, registeredAt: `${file}:${line}` },
+      });
+      added++;
+    }
+  }
+  return edges;
+}
+
 /**
  * Synthesize dispatcher→callback edges (field observers + EventEmitters +
  * React re-render + JSX children + Vue templates + SvelteKit load + RN event
  * channel + Fabric native-impl + MyBatis Java↔XML + Gin middleware chain +
- * Redux-thunk dispatch chain).
+ * Redux-thunk dispatch chain + object-literal registry dispatch + RTK Query
+ * generated-hook → endpoint + Pinia useStore().action() + Vuex string dispatch +
+ * Celery task .delay()/.apply_async() → task body).
  * Returns the count added. Never throws into indexing — callers wrap in try/catch.
  */
 export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
@@ -1746,6 +2231,11 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
   const mybatisEdges = mybatisJavaXmlEdges(queries);
   const ginEdges = ginMiddlewareChainEdges(queries, ctx);
   const thunkEdges = reduxThunkEdges(queries, ctx);
+  const registryEdges = objectRegistryEdges(ctx);
+  const rtkEdges = rtkQueryEdges(queries, ctx);
+  const piniaEdges = piniaStoreEdges(ctx);
+  const vuexEdges = vuexDispatchEdges(ctx);
+  const celeryEdges = celeryDispatchEdges(ctx);
 
   const merged: Edge[] = [];
   const seen = new Set<string>();
@@ -1770,6 +2260,11 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
     ...mybatisEdges,
     ...ginEdges,
     ...thunkEdges,
+    ...registryEdges,
+    ...rtkEdges,
+    ...piniaEdges,
+    ...vuexEdges,
+    ...celeryEdges,
   ]) {
     const key = `${e.source}>${e.target}`;
     if (seen.has(key)) continue;

Некоторые файлы не были показаны из-за большого количества измененных файлов