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:
co-authored by
Claude Opus 4.8
parent
7f970296cf
commit
e9f7422223
@@ -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?.();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user