feat(javascript): recognize tRPC procedures and GraphQL operations (#1670)

* feat(javascript): recognize tRPC procedures and GraphQL operations

Record RPC client syntax as network endpoint candidates in both
analyze_javascript_application and analyze_web_bundle, distinguished by
mechanism:

- trpc:procedure:<method> keeps the static member path of
  root.<path>.<method>(...), which tRPC proxies preserve through
  identifier minification. React hooks and TanStack option factories are
  recognized directly. Vanilla query/mutate need a root bound to
  createTRPCClient/createTRPCProxyClient, or a tRPC source marker with a
  router path of at least two segments; subscribe needs the marker and a
  tRPC observer argument. `this` roots, browser globals and RTK Query
  `endpoints` chains are never procedures; cache-key helpers are excluded.
- trpc🔗<link> records the literal url of documented HTTP links.
- graphql:<type> records named operations from string and cooked template
  literals with a valid operation header, and from precompiled
  OperationDefinition objects; a graphql-tag loc.source.body copy is not
  reported twice.

Method, callback and factory names follow @trpc/client and React/TanStack
integration sources and documentation for 11.19. Carrier segments before
the router path, useUtils helpers and minifier-renamed links remain
documented limitations.

Validation: typecheck; domain tests for recognition and false positives
(Apollo, RxJS, stores, RTK Query, browser globals, prose); web bundle
analyzer test; end-to-end analysis of a minified fixture through the
built MCP server on Windows 11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(javascript): preserve RPC binding identity and bundled calls

* style(reference-source): fix CI formatting

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
Pompilio Donato
2026-10-10 23:28:52 -03:00
committed by GitHub
parent 83e52559b8
commit 3a7a3074e3
9 changed files with 819 additions and 2 deletions
@@ -0,0 +1,329 @@
import { describe, expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
const endpoints = (source: string) =>
analyzeJavaScriptStaticSource(source).endpoints.map(
({ kind, value, mechanism }) => ({ kind, value, mechanism }),
);
const rpcEndpoints = (source: string) =>
endpoints(source).filter(
({ mechanism }) =>
mechanism.startsWith("trpc:") || mechanism.startsWith("graphql:"),
);
describe("tRPC procedure recognition", () => {
it("recognizes React hook procedures on a renamed proxy root", () => {
expect(
rpcEndpoints(`
const a = n.post.byId.useQuery({ id: 1 });
const b = n.post.add.useMutation();
n.feed.useInfiniteQuery({ limit: 10 });
n.onEvent.useSubscription(undefined, { onData() {} });
n.invoice.list.useSuspenseQuery();
`),
).toEqual([
{
kind: "network",
value: "post.byId",
mechanism: "trpc:procedure:useQuery",
},
{
kind: "network",
value: "post.add",
mechanism: "trpc:procedure:useMutation",
},
{
kind: "network",
value: "feed",
mechanism: "trpc:procedure:useInfiniteQuery",
},
{
kind: "network",
value: "onEvent",
mechanism: "trpc:procedure:useSubscription",
},
{
kind: "network",
value: "invoice.list",
mechanism: "trpc:procedure:useSuspenseQuery",
},
]);
});
it("recognizes TanStack React Query integration option factories", () => {
expect(
rpcEndpoints(`
useQuery(e.invoice.list.queryOptions({ page: 1 }));
useMutation(e.invoice.create.mutationOptions());
client.invalidateQueries({ queryKey: e.invoice.list.queryKey() });
`),
).toEqual([
{
kind: "network",
value: "invoice.list",
mechanism: "trpc:procedure:queryOptions",
},
{
kind: "network",
value: "invoice.create",
mechanism: "trpc:procedure:mutationOptions",
},
]);
});
it("recognizes vanilla client calls and link URLs only in sources carrying a tRPC marker", () => {
expect(
rpcEndpoints(`
const client = createTRPCClient({
links: [httpBatchLink({ url: "/api/trpc" })],
});
const bilbo = await client.getUser.query("id_bilbo");
await client.post.add.mutate({ title: "x" });
client.onPost.subscribe(undefined, { onData() {} });
`),
).toEqual([
{
kind: "network",
value: "/api/trpc",
mechanism: "trpc:link:httpBatchLink",
},
{ kind: "network", value: "getUser", mechanism: "trpc:procedure:query" },
{
kind: "network",
value: "post.add",
mechanism: "trpc:procedure:mutate",
},
{
kind: "network",
value: "onPost",
mechanism: "trpc:procedure:subscribe",
},
]);
expect(
rpcEndpoints(`
const rows = await pool.users.query("select 1");
emitter.changes.subscribe(listener);
store.cart.mutate(update);
`),
).toEqual([]);
});
it("requires corroboration for request methods shared with Apollo, RxJS and stores", () => {
// The source carries a tRPC marker, so only the call shape can decide.
expect(
rpcEndpoints(`
class TRPCClientError extends Error {}
this.client.query({ query: q });
e.queryManager.query(options);
this.untypedClient.query("path", input);
this.route.params.subscribe((params) => params);
t.current.subscribe(listener);
r.store.subscribe(() => {});
n.cache.mutate(update);
o.k.endpoints.getPosts.useQuery();
e.api.endpoints.list.useQuery();
this.trpc.invoice.list.useQuery();
`),
).toEqual([]);
expect(
rpcEndpoints(`
class TRPCClientError extends Error {}
e.invoice.list.query({ page: 1 });
e.invoice.onChange.subscribe(undefined, { onData() {} });
`),
).toEqual([
{
kind: "network",
value: "invoice.list",
mechanism: "trpc:procedure:query",
},
{
kind: "network",
value: "invoice.onChange",
mechanism: "trpc:procedure:subscribe",
},
]);
});
it("never reports browser globals or RTK Query endpoints as procedures", () => {
expect(
rpcEndpoints(`
class TRPCClientError extends Error {}
navigator.permissions.query({ name: "geolocation" });
window.navigator.permissions.query({ name: "camera" });
api.endpoints.getPosts.useQuery();
api.useGetPostsQuery();
useQuery({ queryKey: ["x"] });
`),
).toEqual([]);
});
});
describe("tRPC binding identity and bundled syntax", () => {
it("keeps client bindings local to their lexical declaration", () => {
expect(
rpcEndpoints(`
function first() { const c = createTRPCClient({}); c.users.query(); }
function unrelated(c) { c.users.query("select 1"); }
const c = createTRPCClient({});
function closure() { c.posts.query(); }
function shadowed() { c.users.query(); var c = pool; }
{ const c = pool; c.users.query(); }
try {} catch (c) { c.users.query(); }
`),
).toEqual([
{ kind: "network", value: "users", mechanism: "trpc:procedure:query" },
{ kind: "network", value: "posts", mechanism: "trpc:procedure:query" },
]);
});
it("does not corroborate overwritten or destructured client bindings", () => {
expect(
rpcEndpoints(`
let c = createTRPCClient({}); c = pool; c.users.query();
const { client } = createTRPCClient({}); client.users.query();
`),
).toEqual([]);
expect(
rpcEndpoints(`
var c = createTRPCClient({}); c.users.query();
`),
).toEqual([
{ kind: "network", value: "users", mechanism: "trpc:procedure:query" },
]);
});
it("recognizes surviving factory names inside bundled call wrappers", () => {
expect(
rpcEndpoints(`
const c = (0, t.createTRPCClient)({
links: [(0, t.httpBatchLink)({ url: "/api/rpc" })],
});
c.users.query();
const p = (t.createTRPCProxyClient as any)({});
p.posts.query();
(0, t.httpLink)({ url: "/api/other" });
`),
).toEqual([
{
kind: "network",
value: "/api/rpc",
mechanism: "trpc:link:httpBatchLink",
},
{ kind: "network", value: "users", mechanism: "trpc:procedure:query" },
{ kind: "network", value: "posts", mechanism: "trpc:procedure:query" },
{ kind: "network", value: "/api/other", mechanism: "trpc:link:httpLink" },
]);
});
it("preserves procedure paths beyond the former nesting cutoff", () => {
const path = Array.from({ length: 64 }, (_, i) => `router${i}`).join(".");
expect(rpcEndpoints(`root.${path}.useQuery();`)).toEqual([
{ kind: "network", value: path, mechanism: "trpc:procedure:useQuery" },
]);
});
});
describe("GraphQL operation recognition", () => {
it("recognizes named operations in tagged templates and string literals", () => {
expect(
rpcEndpoints(`
const GET_USER = gql\`
# comment
query GetUser($id: ID!) { user(id: $id) { name } }
\`;
const UPDATE = "mutation UpdateInvoice($input: InvoiceInput!) { updateInvoice(input: $input) { id } }";
const ON = \`subscription OnInvoice { invoiceChanged { id } } \${fragment}\`;
`),
).toEqual([
{ kind: "network", value: "GetUser", mechanism: "graphql:query" },
{
kind: "network",
value: "UpdateInvoice",
mechanism: "graphql:mutation",
},
{
kind: "network",
value: "OnInvoice",
mechanism: "graphql:subscription",
},
]);
});
it("retains operations after long ignored prefixes and long headers", () => {
const name = "Operation" + "x".repeat(512);
const text =
"#" +
"comment".repeat(700) +
"\nquery " +
name +
" ".repeat(600) +
"{ users { id } }";
expect(rpcEndpoints(`const doc = ${JSON.stringify(text)};`)).toEqual([
{ kind: "network", value: name, mechanism: "graphql:query" },
]);
});
it("recognizes precompiled GraphQL document objects", () => {
expect(
rpcEndpoints(`
const doc = { kind: "Document", definitions: [{
kind: "OperationDefinition",
operation: "query",
name: { kind: "Name", value: "ListInvoices" },
selectionSet: { kind: "SelectionSet", selections: [] },
}] };
`),
).toEqual([
{ kind: "network", value: "ListInvoices", mechanism: "graphql:query" },
]);
});
it("ignores anonymous operations and ordinary prose", () => {
expect(
rpcEndpoints(`
const a = "query { viewer { id } }";
const b = "query string parameters";
const c = "mutation observer started";
const d = { kind: "OperationDefinition", operation: "query" };
const e = "mutation Observer {";
const f = "query Search(";
const g = "subscription Plan (monthly)";
`),
).toEqual([]);
});
it("reports a precompiled graphql-tag document once, not again from loc.source.body", () => {
expect(
rpcEndpoints(`
const doc = { kind: "Document", definitions: [{
kind: "OperationDefinition",
operation: "query",
name: { kind: "Name", value: "GetUser" },
selectionSet: { kind: "SelectionSet", selections: [] },
}], loc: { start: 0, end: 40, source: {
body: "query GetUser($id: ID!) { user(id: $id) { name } }",
name: "GraphQL request",
locationOffset: { line: 1, column: 1 },
} } };
`),
).toEqual([
{ kind: "network", value: "GetUser", mechanism: "graphql:query" },
]);
});
});
it("keeps existing network endpoint recognition unchanged", () => {
expect(
endpoints(`fetch("/api/invoices"); n.invoice.list.useQuery();`),
).toEqual([
{ kind: "network", value: "/api/invoices", mechanism: "call:fetch" },
{
kind: "network",
value: "invoice.list",
mechanism: "trpc:procedure:useQuery",
},
]);
});