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>
This commit is contained in:
Colby McHenry
2026-06-20 19:23:37 -05:00
co-authored by Claude Opus 4.8
parent 7f970296cf
commit e9f7422223
5 changed files with 453 additions and 9 deletions
+60 -1
View File
@@ -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;