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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user