Browse Source

feat(resolution): synthesize RTK Query hook→endpoint dispatch edges

Adds the RTK Query member of the dispatch-through-indirection family
(synthesizedBy:'rtk-query'). An RTK Query endpoint defined inside
`createApi({ endpoints })` and the `useGetXQuery`/`useUpdateYMutation` hook it
generates were both invisible to static extraction, so a `component →
useGetXQuery → getX → queryFn` flow had nothing to connect and explore
dead-ended on the API slice.

Extraction (tree-sitter.ts): mint a function node per endpoint — named by its
key, spanning the queryFn/query handler so its calls attribute — handling both
the `endpoints: build => ({...})` arrow and `endpoints(builder){ return {...} }`
method forms, with a bare-node fallback for factory handlers
(`queryFn: makeFn(url)`); and a function node per generated-hook binding from
`export const {...} = api`, carrying a sentinel signature.

Resolution (callback-synthesizer.ts): rtkQueryEdges bridges each generated-hook
node to its same-file endpoint by the naming convention (strip use + optional
Lazy + Query|Mutation, lowercase head). Component→hook is normal import/call
resolution; the hook→endpoint hop surfaces in explore as `dynamic: rtk query`.

Validated 100% precision (hooks == synth edges, 0 cross-file) on basetool (54),
minusx-metabase (11), shapeshift (13); 0 on the uwave-web control (no createApi
→ a complete no-op). The sentinel gate correctly ignores hand-written
look-alikes (shapeshift's useFoxyQuery is a real custom hook, never bridged).
Full suite green (1608); new __tests__/rtk-query-synthesizer.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby McHenry 2 days ago
parent
commit
e9f7422223

+ 2 - 0
CHANGELOG.md

@@ -11,6 +11,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
+- `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 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.

+ 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?.();
+  });
+});

+ 10 - 3
docs/design/dispatch-synthesizer-backlog.md

@@ -55,7 +55,7 @@ Status legend (matches the playbook): ✅ done+validated · 🟡 shipped but und
 | Shape | Ecosystem | The static anchor that bridges it | Mechanism | Status |
 |---|---|---|---|---|
 | **Name→class registry / command bus** | any (TS/JS first) | object-literal registry `{key: Handler}` + computed-key dispatch `(new) reg[var](…)` | S (fan-out, `object-registry`) | ✅ **SHIPPED v1 (2026-06-20)** — `objectRegistryEdges`. Links each dispatcher fn → each registered handler's callable entry (a class's `execute`/run/handle method — preferring the method chained at the dispatch — or the function value). Precise on **xrengine** (CommandManager, 64 edges, class registry → `.execute`), **Prebid.js** (7: builder/consent/message dispatch, fn registry), **warp-drive** (1). **0 false positives** after: minified-file skip (avg line >200), **depth-aware** entry parse (top-level `key: Ident` only — method-shorthand/nested-object bodies don't leak), callable-only targets (no data `constant`), dynamic-dispatch gate. Handles constructor + field-initializer (`this.` normalized) forms. **Deferred (recall, documented):** assign-then-call (`const h=reg[k]; h()` — warp-drive's main `COMMANDS`), augmentation (`reg[k]=H` — Prebid single-entry), method-shorthand entry recall, and the **cross-file barrel-namespace** variant (trezor `getMethod`: `import * as M; M[method]→new` + computed dynamic import + camel↔Pascal — the hard tier, still 🔬). |
-| **RTK Query** | TS / Redux Toolkit | `createApi({ endpoints: b => ({ getX: b.query(...) }) })` → generated `useGetXQuery` hook → component; endpoint name ↔ hook name (`getX`↔`useGetXQuery`) is convention | X (extract endpoints) + S (endpoint→hook) | 🔬 **found on shapeshift** (14 `createApi` files, currently invisible). The modern RTK default — likely higher traffic than hand thunks now. |
+| **RTK Query** | TS / Redux Toolkit | `createApi({ endpoints: b => ({ getX: b.query(...) }) })` → generated `useGetXQuery` hook → component; endpoint name ↔ hook name (`getX`↔`useGetXQuery`) is convention | X (extract endpoints) + S (endpoint→hook) | ✅ **SHIPPED (2026-06-20)** — `synthesizedBy:'rtk-query'`. **X:** extraction mints a function node per endpoint (named by its key, spanning the `queryFn`/`query` handler so its calls attribute; both `endpoints: b => ({…})` arrow and `endpoints(b){ return {…} }` method forms; a factory-handler endpoint `queryFn: makeFn(url)` falls back to a bare node spanning the builder call) **and** per generated-hook binding from `export const {…} = api` (carrying the sentinel signature `= RTK Query generated hook`). **S:** `rtkQueryEdges` bridges hook→same-file endpoint by the naming convention (strip `use` + optional `Lazy` + `Query`/`Mutation`, lc head). Component→hook is normal import/call resolution; hook→endpoint surfaces in explore as `dynamic: rtk query`. Validated **100% precision** (hooks == synth edges, **0 cross-file**) on **basetool** (small, 54 edges, both forms + factory fallback), **minusx-metabase** (small, 11), **shapeshift** (large, 13); **0** on the uwave-web control (no `createApi` → a complete no-op, 0 nodes/edges added). Sentinel gate correctly ignores hand-written look-alikes (shapeshift's `useFoxyQuery` is a real custom hook, never bridged). **Deferred:** cross-module `injectEndpoints` where the hook destructuring's RHS isn't the same bare api const (synth requires same-file endpoint). |
 | **Vuex / Pinia** | Vue | `store.dispatch('ns/action')` / `commit('mutation')` → action/mutation by string key (namespaced) | S (string-keyed, like `event-emitter`) | ⬜ |
 | **NgRx effects** | Angular | `createEffect(() => actions.pipe(ofType(LoginAction), …))` → effect handler; `Store.dispatch(new LoginAction())` → effect by action type/class | S (type/class-keyed) | ⬜ |
 
@@ -80,6 +80,8 @@ Status legend (matches the playbook): ✅ done+validated · 🟡 shipped but und
 | Shape | `synthesizedBy` | Validated on |
 |---|---|---|
 | Redux thunk | `redux-thunk` | ✅ **generalizes (2026-06-20)** — precise on uwave-web (small, 5 edges), session-desktop (medium, 2), trezor (large, 211); control shapeshift (RTK Query, no thunks) = 0. Receiver-agnostic (`api.dispatch`/`thunkApi.dispatch`/`window.…dispatch` all matched). **⚠️ 2 follow-ups below.** |
+| Object-literal registry | `object-registry` | ✅ **shipped (2026-06-20)** — xrengine `CommandManager` (64), Prebid.js (7), warp-drive (1); 0 false positives after 4 precision gates. |
+| RTK Query | `rtk-query` | ✅ **shipped (2026-06-20)** — 100% precision (hooks == synth edges, 0 cross-file) on basetool (54), minusx-metabase (11), shapeshift (13); 0 on uwave-web control. Extraction mints endpoint + generated-hook nodes; synth bridges hook→endpoint by convention. |
 | (see playbook §6 / `callback-synthesizer.ts` for the other ~20 channels) | | |
 
 ### redux-thunk follow-ups (found by the n>1 validation — this is exactly what it's for)
@@ -141,5 +143,10 @@ For each shape, before marking ✅:
       n8n/VS-Code-class registries). The **facade** (`connect-common/factory.ts`) is
       **low-value** — it collapses every method to a single `call` fan-in with no
       per-method disambiguation; bridging it buys ~nothing. Build the registry, skip the facade.
-- [ ] **RTK Query (workstream 2 spillover):** shapeshift handed us a free, real,
-      already-indexed target. Strong candidate right after redux-thunk is de-risked.
+- [x] **RTK Query (workstream 2 spillover):** ✅ **shipped (2026-06-20)** —
+      `synthesizedBy:'rtk-query'`, validated on basetool / minusx-metabase /
+      shapeshift (+ uwave control). See the Tier-A row for the mechanism.
+      **Next RTK spillover:** the cross-module `injectEndpoints` case (hooks
+      destructured off an enhanced api in a different file than the base) — the
+      synth's same-file gate skips it today; would need a same-`reducerPath` or
+      import-following relaxation, validated on a repo that splits endpoints.

+ 184 - 5
src/extraction/tree-sitter.ts

@@ -36,6 +36,14 @@ 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)$/;
+
 /**
  * Extract the name from a node based on language
  */
@@ -1945,6 +1953,156 @@ 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',
+      });
+    }
+  }
+
   /**
    * Extract a variable declaration (const, let, var, etc.)
    *
@@ -1977,8 +2135,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);
@@ -2027,21 +2192,35 @@ export class TreeSitterExtractor {
                   : null;
             const extractObjectMethods = isExported && !!objectOfFns;
 
+            // 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;
+
             // 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 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).
             if (valueNode &&
                 valueNode.type !== 'object' &&
                 valueNode.type !== 'object_expression' &&
-                !(extractObjectMethods && valueNode.type === 'call_expression')) {
+                !(extractObjectMethods && valueNode.type === 'call_expression') &&
+                !rtkEndpoints) {
               this.visitFunctionBody(valueNode, '');
             }
 
             if (extractObjectMethods && objectOfFns) {
               this.extractObjectLiteralFunctions(objectOfFns);
             }
+            if (rtkEndpoints) {
+              this.extractRtkEndpoints(rtkEndpoints);
+            }
           }
         }
       }

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

@@ -1867,11 +1867,68 @@ function objectRegistryEdges(ctx: ResolutionContext): Edge[] {
   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;
+}
+
 /**
  * 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 + object-literal registry dispatch).
+ * Redux-thunk dispatch chain + object-literal registry dispatch + RTK Query
+ * generated-hook → endpoint).
  * Returns the count added. Never throws into indexing — callers wrap in try/catch.
  */
 export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
@@ -1911,6 +1968,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
   const ginEdges = ginMiddlewareChainEdges(queries, ctx);
   const thunkEdges = reduxThunkEdges(queries, ctx);
   const registryEdges = objectRegistryEdges(ctx);
+  const rtkEdges = rtkQueryEdges(queries, ctx);
 
   const merged: Edge[] = [];
   const seen = new Set<string>();
@@ -1936,6 +1994,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
     ...ginEdges,
     ...thunkEdges,
     ...registryEdges,
+    ...rtkEdges,
   ]) {
     const key = `${e.source}>${e.target}`;
     if (seen.has(key)) continue;