diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 6446432d9..02373d625 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -247,6 +247,46 @@ while locally shadowed receivers can still contribute network endpoint candidates. A template recovered with a parser error and no cooked value stays dynamic; its raw spelling is not treated as a valid JavaScript string. +RPC client syntax also contributes network endpoint candidates, in both +`analyze_javascript_application` and `analyze_web_bundle`, distinguished by +`mechanism`: + +- `trpc:procedure:` records the static member path between the root + expression and the method, such as `invoice.list` in + `root.invoice.list.useQuery(...)`. tRPC proxies keep these property names + through identifier minification. React hooks and TanStack option factories + (`useQuery`, `useMutation`, `useInfiniteQuery`, `useSubscription`, suspense + and prefetch variants, `queryOptions`, `infiniteQueryOptions`, + `mutationOptions`, `subscriptionOptions`) are recognized directly. The + vanilla `query` and `mutate` methods are recognized on a root bound to + `createTRPCClient` or `createTRPCProxyClient` in the same lexical binding, or on a + path of at least two segments in a source containing `TRPCClientError`, + `trpc-accept`, or `/trpc`. Factory bindings must have one unconditional + initializer and no binding assignments; shadowed locals do not inherit client + identity. `subscribe` additionally requires a tRPC observer + argument (`onData`, `onError`, `onStarted`, `onStopped`, `onComplete`, or + `onConnectionStateChange`). `this` roots, browser globals, and any path + containing an RTK Query `endpoints` segment are never procedures; cache-key + helpers such as `queryKey` issue no request and are excluded. +- `trpc:link:` records the literal `url` option of `httpLink`, + `httpBatchLink`, `httpBatchStreamLink`, and `httpSubscriptionLink` when the + link's callee name survives bundling, including `(0, module.httpBatchLink)` + import-call wrappers. +- `graphql:query`, `graphql:mutation`, and `graphql:subscription` record a + named operation found at the start of a string or cooked template literal — + the name must be followed by variable definitions (`($`), a directive, or a + selection set starting with a field name — or in a precompiled + `OperationDefinition` document object. A graphql-tag `loc.source.body` copy is + not reported twice. Anonymous operations are not reported. + +A procedure path is the server-side procedure name, not a resolved URL; the +transport URL depends on the link configuration and batching. When the proxy is +reached through a member expression, such as a webpack module binding +(`l.S.invoice.list`) or a property holding the proxy (`o.api.post.byId`), the +recorded path keeps those leading carrier segments; the procedure path is its +suffix. Utility calls such as `useUtils().invoice.list.fetch()`, `useQueries`, +and links whose callee was renamed by a minifier are not recognized. + Each recovered bundle module retains the exact factory-source digest. A complete bounded AST also receives a `babel-ast-v1` structural fingerprint that ignores ordinary identifier names while retaining syntax, literals, operators, object diff --git a/src/domain/javascript/javascriptRpcEndpoints.test.ts b/src/domain/javascript/javascriptRpcEndpoints.test.ts new file mode 100644 index 000000000..05062b4c7 --- /dev/null +++ b/src/domain/javascript/javascriptRpcEndpoints.test.ts @@ -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", + }, + ]); +}); diff --git a/src/domain/javascript/javascriptRpcEndpoints.ts b/src/domain/javascript/javascriptRpcEndpoints.ts new file mode 100644 index 000000000..1ccf6d3e5 --- /dev/null +++ b/src/domain/javascript/javascriptRpcEndpoints.ts @@ -0,0 +1,322 @@ +import * as t from "@babel/types"; + +import { + unwrapJavaScriptExpression, + semanticStaticPropertyKey, +} from "./javascriptAstValues.js"; +import { calleeName } from "./javascriptStaticAnalysisHelpers.js"; + +/** One RPC operation or transport URL recovered from client-side syntax. */ +export interface JavaScriptRpcEndpoint { + readonly value: string; + readonly mechanism: string; +} + +/** Per-source RPC recognition state; feed it every AST node in pre-order. */ +export interface JavaScriptRpcScan { + inspect(node: t.Node): JavaScriptRpcEndpoint | undefined; +} + +// tRPC proxies preserve router and procedure names as property names, so they +// survive identifier minification. Method names follow @trpc/react-query and +// @trpc/tanstack-react-query 11.19 documentation; cache-key helpers such as +// queryKey are excluded because they never issue a request. +const TRPC_HOOK_METHODS: ReadonlySet = new Set([ + "useQuery", + "useSuspenseQuery", + "useInfiniteQuery", + "useSuspenseInfiniteQuery", + "usePrefetchQuery", + "usePrefetchInfiniteQuery", + "useMutation", + "useSubscription", + "queryOptions", + "infiniteQueryOptions", + "mutationOptions", + "subscriptionOptions", +]); + +// @trpc/client 11.19 clientCallTypeMap methods; common outside tRPC (Apollo, +// database pools, stores), so they need corroborating evidence. +const TRPC_REQUEST_METHODS: ReadonlySet = new Set(["query", "mutate"]); +// @trpc/client 11.19 TRPCSubscriptionObserver callbacks; RxJS observers use +// next/error/complete instead. +const TRPC_SUBSCRIPTION_CALLBACKS: ReadonlySet = new Set([ + "onStarted", + "onData", + "onError", + "onStopped", + "onComplete", + "onConnectionStateChange", +]); +// @trpc/client 11.19 client factories (createTRPCProxyClient is the +// deprecated alias of createTRPCClient). +const TRPC_CLIENT_FACTORIES: ReadonlySet = new Set([ + "createTRPCClient", + "createTRPCProxyClient", +]); +// Built-in HTTP links documented by tRPC 11.19 that carry a `url` option. +const TRPC_URL_LINKS: ReadonlySet = new Set([ + "httpLink", + "httpBatchLink", + "httpBatchStreamLink", + "httpSubscriptionLink", +]); +// Literals retained by @trpc/client 11.19 bundles (`this.name`, the default +// accept header key) or by the conventional transport path. +const TRPC_SOURCE_MARKERS = ["TRPCClientError", "trpc-accept", "/trpc"]; + +// Browser-owned roots whose methods share tRPC names, such as +// navigator.permissions.query. +const BROWSER_GLOBAL_NAMES: ReadonlySet = new Set([ + "window", + "self", + "globalThis", + "document", + "navigator", + "location", + "history", + "console", + "performance", + "screen", + "top", + "parent", + "frames", +]); + +const PROCEDURE_SEGMENT = /^[A-Za-z_$][\w$]*$/u; + +interface MemberPath { + readonly root: t.Node; + /** Non-computed property names from the root outward. */ + readonly properties: readonly string[]; +} + +/** Read a static member path and the expression it starts from. */ +const memberPath = (callee: t.Node): MemberPath | undefined => { + const reversed: string[] = []; + let current: t.Node = callee; + while ( + t.isMemberExpression(current) || + t.isOptionalMemberExpression(current) + ) { + const property = semanticStaticPropertyKey( + current.property, + current.computed, + ); + if (property === null || !PROCEDURE_SEGMENT.test(property)) + return undefined; + reversed.push(property); + current = current.object; + } + return { root: current, properties: reversed.reverse() }; +}; + +const objectKeys = (node: t.Node | undefined): readonly string[] => + t.isObjectExpression(node) + ? node.properties.flatMap((property) => { + if (!t.isObjectProperty(property) && !t.isObjectMethod(property)) + return []; + const key = semanticStaticPropertyKey(property.key, property.computed); + return key === null ? [] : [key]; + }) + : []; + +const objectStringProperty = ( + node: t.ObjectExpression, + key: string, +): string | undefined => { + for (const property of node.properties) + if ( + t.isObjectProperty(property) && + semanticStaticPropertyKey(property.key, property.computed) === key && + t.isStringLiteral(property.value) + ) + return property.value.value; + return undefined; +}; + +const objectProperty = ( + node: t.ObjectExpression, + key: string, +): t.Node | undefined => { + for (const property of node.properties) + if ( + t.isObjectProperty(property) && + semanticStaticPropertyKey(property.key, property.computed) === key + ) + return property.value; + return undefined; +}; + +const GRAPHQL_OPERATION_TYPES: ReadonlySet = new Set([ + "query", + "mutation", + "subscription", +]); +// A named GraphQL operation header (GraphQL October 2021, OperationDefinition): +// type, name, then variable definitions starting with `$`, a directive, or a +// selection set starting with a field name. +const GRAPHQL_OPERATION_HEADER = + /^(query|mutation|subscription)[\s,]+([_A-Za-z][_0-9A-Za-z]*)[\s,]*(?:\([\s,]*\$|@[_A-Za-z]|\{[\s,]*[_A-Za-z])/u; + +const isGraphqlIgnoredCharacter = (character: string): boolean => + character === "," || character === "" || /\s/u.test(character); + +/** Recover a named operation from GraphQL document text. */ +export const graphqlOperationFromText = ( + text: string, +): JavaScriptRpcEndpoint | undefined => { + // Skip GraphQL ignored tokens: whitespace, commas, BOM and # comments. + const limit = text.length; + let index = 0; + while (index < limit) { + const character = text.charAt(index); + if (isGraphqlIgnoredCharacter(character)) index += 1; + else if (character === "#") { + while (index < limit && !/[\n\r]/u.test(text.charAt(index))) index += 1; + } else break; + } + if (index >= limit) return undefined; + const header = GRAPHQL_OPERATION_HEADER.exec(text.slice(index)); + const type = header?.[1]; + const name = header?.[2]; + return type === undefined || name === undefined + ? undefined + : { value: name, mechanism: `graphql:${type}` }; +}; + +/** Recover a named operation from a precompiled GraphQL document object. */ +export const graphqlOperationFromDocumentNode = ( + node: t.ObjectExpression, +): JavaScriptRpcEndpoint | undefined => { + if (objectStringProperty(node, "kind") !== "OperationDefinition") + return undefined; + const type = objectStringProperty(node, "operation"); + if (type === undefined || !GRAPHQL_OPERATION_TYPES.has(type)) + return undefined; + const name = objectProperty(node, "name"); + if ( + !t.isObjectExpression(name) || + objectStringProperty(name, "kind") !== "Name" + ) + return undefined; + const value = objectStringProperty(name, "value"); + return value === undefined + ? undefined + : { value, mechanism: `graphql:${type}` }; +}; + +/** Bundlers use `(0, imported.factory)` to call an import without a receiver. */ +const rpcFactoryName = (callee: t.Node): string => { + let current = unwrapJavaScriptExpression(callee).node; + while (t.isSequenceExpression(current)) { + const last = current.expressions.at(-1); + if (last === undefined) return ""; + current = unwrapJavaScriptExpression(last).node; + } + return calleeName(current); +}; + +/** Whether an initializer has the syntax of a documented tRPC client factory. */ +export const isJavaScriptRpcClientFactory = (node: t.Node): boolean => { + const call = unwrapJavaScriptExpression(node).node; + return ( + (t.isCallExpression(call) || t.isOptionalCallExpression(call)) && + TRPC_CLIENT_FACTORIES.has( + rpcFactoryName(call.callee).split(".").pop() ?? "", + ) + ); +}; + +/** Recover the literal `url` option passed to a documented tRPC HTTP link. */ +const trpcLinkUrl = ( + node: t.CallExpression | t.OptionalCallExpression, +): JavaScriptRpcEndpoint | undefined => { + const name = rpcFactoryName(node.callee); + const factory = name.slice(name.lastIndexOf(".") + 1); + if (!TRPC_URL_LINKS.has(factory)) return undefined; + const options = node.arguments[0]; + if (!t.isObjectExpression(options)) return undefined; + const url = objectStringProperty(options, "url"); + return url === undefined + ? undefined + : { value: url, mechanism: `trpc:link:${factory}` }; +}; + +/** + * Create the per-source scan. Hook and option-factory methods are distinctive. + * `query`/`mutate` need a root bound to a tRPC client factory, 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. + */ +export const createJavaScriptRpcScan = ( + source: string, + clientRoots: ReadonlySet, +): JavaScriptRpcScan => { + let marker: boolean | undefined; + const hasMarker = (): boolean => + (marker ??= TRPC_SOURCE_MARKERS.some((value) => source.includes(value))); + // graphql-tag documents retain their source text in loc.source.body; that + // text repeats the OperationDefinition already recovered from the object. + const documentSourceBodies = new WeakSet(); + + const procedure = ( + node: t.CallExpression | t.OptionalCallExpression, + ): JavaScriptRpcEndpoint | undefined => { + const path = memberPath(node.callee); + if (path === undefined || t.isThisExpression(path.root)) return undefined; + if (t.isIdentifier(path.root) && BROWSER_GLOBAL_NAMES.has(path.root.name)) + return undefined; + const method = path.properties[path.properties.length - 1]; + const segments = path.properties.slice(0, -1); + const first = segments[0]; + if (method === undefined || first === undefined) return undefined; + if (BROWSER_GLOBAL_NAMES.has(first) || segments.includes("endpoints")) + return undefined; + const bound = clientRoots.has(path.root); + const recognized = TRPC_HOOK_METHODS.has(method) + ? true + : TRPC_REQUEST_METHODS.has(method) + ? bound || (segments.length >= 2 && hasMarker()) + : method === "subscribe" && + (bound || hasMarker()) && + objectKeys(node.arguments[1]).some((key) => + TRPC_SUBSCRIPTION_CALLBACKS.has(key), + ); + return recognized + ? { value: segments.join("."), mechanism: `trpc:procedure:${method}` } + : undefined; + }; + + return { + inspect: (node) => { + if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) + return trpcLinkUrl(node) ?? procedure(node); + if (t.isObjectExpression(node)) { + const body = objectProperty(node, "body"); + if ( + t.isStringLiteral(body) && + objectProperty(node, "locationOffset") !== undefined + ) { + documentSourceBodies.add(body); + return undefined; + } + return graphqlOperationFromDocumentNode(node); + } + if (t.isStringLiteral(node)) + return documentSourceBodies.has(node) + ? undefined + : graphqlOperationFromText(node.value); + if (t.isTemplateLiteral(node)) { + // A template recovered without a cooked value stays dynamic. + const cooked = node.quasis[0]?.value.cooked; + return cooked === undefined || cooked === null + ? undefined + : graphqlOperationFromText(cooked); + } + return undefined; + }, + }; +}; diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index b0e8e5ee7..5d65f413e 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -59,6 +59,8 @@ import { type ParsedJavaScriptSource, } from "./javascriptSourceParser.js"; +import { isJavaScriptRpcClientFactory } from "./javascriptRpcEndpoints.js"; + interface BindPatternInput { readonly pattern: t.Node; readonly initializer: t.Node | null; @@ -181,6 +183,40 @@ export function* analyzeParsedJavaScriptSemanticsSteps( }; } +/** Resolve tRPC client roots by lexical identity, excluding overwritten bindings. */ +export function* classifyParsedJavaScriptRpcClientRootsSteps( + file: ParsedJavaScriptSource, +): Generator> { + const state = createState(file.program); + yield* collectDefinitionsSteps(file.program, state); + const roots = new Set(); + const clients = new Map(); + yield* traverseJavaScriptAstSteps(file.program, { + enter: (node) => { + if (!t.isMemberExpression(node) && !t.isOptionalMemberExpression(node)) + return; + const root = node.object; + if (!t.isIdentifier(root)) return; + const binding = resolveSemanticBindingState(state, root, root.name); + if (binding === undefined) return; + let client = clients.get(binding.bindingId); + if (client === undefined) { + const [initializer] = binding.initializers; + client = + initializer !== undefined && + initializer.projection.length === 0 && + binding.initializers.length === 1 && + !binding.definitions.some(({ kind }) => kind === "assignment") && + !state.conditionalInitializers.has(initializer.node) && + isJavaScriptRpcClientFactory(initializer.node); + clients.set(binding.bindingId, client); + } + if (client) roots.add(root); + }, + }); + return roots; +} + /** Receiver identity facts for the overloaded `.open` syntax only. */ export type JavaScriptOpenReceiverFact = | "window" diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index 8d306af25..50af5c765 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -23,6 +23,7 @@ import { inspectCall, inspectRouteProperty, inspectRoleProperty, + inspectRpcNode, } from "./javascriptStaticAnalysisCalls.js"; import { inspectBundlerRegistration, @@ -37,6 +38,7 @@ import { import type { JavaScriptStaticAnalysis } from "./javascriptStaticAnalysisTypes.js"; import { classifyParsedJavaScriptElectronBindingsSteps, + classifyParsedJavaScriptRpcClientRootsSteps, classifyParsedJavaScriptOpenReceivers, type JavaScriptOpenReceiverFact, } from "./javascriptSemanticAnalysis.js"; @@ -45,6 +47,8 @@ import { type ParsedJavaScriptSource, } from "./javascriptSourceParser.js"; +import { createJavaScriptRpcScan } from "./javascriptRpcEndpoints.js"; + /** Parse one JavaScript artifact and recover static structure only. */ export const analyzeJavaScriptStaticSource = ( source: string, @@ -78,11 +82,13 @@ export function* analyzeParsedJavaScriptStaticSourceSteps( const accumulator = createJavaScriptAnalysisAccumulator(source.length); const electronBindings = yield* classifyParsedJavaScriptElectronBindingsSteps(file); + const clientRoots = yield* classifyParsedJavaScriptRpcClientRootsSteps(file); yield* traverseStaticSourceSteps(file, { source, accumulator, ...(openReceiverFacts === undefined ? {} : { openReceiverFacts }), electronBindings, + rpcScan: createJavaScriptRpcScan(source, clientRoots), }); addSourceMapDirectives(source, file.comments ?? [], accumulator); return finalizeStaticAnalysis(source, file, accumulator); @@ -223,4 +229,5 @@ const inspectNode = ( inspectRouteProperty(node, findings); inspectRoleProperty(node, findings); } + inspectRpcNode(node, findings); }; diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 50dd2f17c..5d339781b 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -201,6 +201,13 @@ const inspectEndpointCall = ( }); }; +/** Record tRPC procedures, tRPC link URLs and named GraphQL operations. */ +export const inspectRpcNode = (node: t.Node, context: FindingContext): void => { + const rpc = context.rpcScan.inspect(node); + if (rpc !== undefined) + addEndpoint(context, { node, kind: "network", ...rpc }); +}; + export const inspectRouteProperty = ( node: t.ObjectProperty, context: FindingContext, diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index 93b27bf1c..44f6f3184 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -1,4 +1,5 @@ import type * as t from "@babel/types"; +import type { JavaScriptRpcScan } from "./javascriptRpcEndpoints.js"; import type { JavaScriptBundlerRegistration, @@ -57,6 +58,7 @@ export interface JavaScriptAnalysisAccumulator { /** Shared source and accumulator for helper inspections. */ export interface JavaScriptFindingContext { + readonly rpcScan: JavaScriptRpcScan; readonly source: string; readonly accumulator: JavaScriptAnalysisAccumulator; readonly openReceiverFacts?: ReadonlyMap< diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index 1cfe649dc..816ef5cc0 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -603,3 +603,56 @@ it("retains direct and literal computed call names", () => { expect.objectContaining({ name: "literal-tool" }), ]); }); + +it("recognizes tRPC procedures and named GraphQL operations in captured scripts", () => { + const result = analyzeCapturedWebBundle( + inspection(` + const n = createTRPCClient({ links: [httpBatchLink({ url: "/api/trpc" })] }); + e.invoice.list.useQuery({ competencia: "2026-09" }); + n.empresa.byCnpj.query("00000000000191"); + const q = "query ListarNotas($competencia: String!) { notas { id } }"; + this.client.query({ query: q }); + `), + ); + expect( + result.observations.endpoints.map(({ value, mechanism }) => ({ + value, + mechanism, + })), + ).toEqual([ + { value: "/api/trpc", mechanism: "trpc:link:httpBatchLink" }, + { value: "invoice.list", mechanism: "trpc:procedure:useQuery" }, + { value: "empresa.byCnpj", mechanism: "trpc:procedure:query" }, + { value: "ListarNotas", mechanism: "graphql:query" }, + ]); +}); + +it("keeps RPC lexical identity and bundled factory recognition in captured scripts", () => { + const longOperation = "Operation" + "x".repeat(512); + const document = + "#" + + "comment".repeat(700) + + "\nquery " + + longOperation + + " { users { id } }"; + const result = analyzeCapturedWebBundle( + inspection(` + function first() { const c = createTRPCClient({}); } + function unrelated(c) { c.users.query("select 1"); } + const c = (0, t.createTRPCClient)({ links: [(0, t.httpBatchLink)({ url: "/api/rpc" })] }); + c.posts.query(); + const doc = ${JSON.stringify(document)}; + `), + ); + expect( + result.observations.endpoints.map(({ value, mechanism }) => ({ + value, + mechanism, + })), + ).toEqual([ + { value: "/api/rpc", mechanism: "trpc:link:httpBatchLink" }, + { value: "posts", mechanism: "trpc:procedure:query" }, + { value: longOperation, mechanism: "graphql:query" }, + ]); + expect(result.completeness.status).toBe("complete"); +}); diff --git a/src/domain/webBundleAnalyzerInspection.ts b/src/domain/webBundleAnalyzerInspection.ts index daf3a2292..fd8c541ac 100644 --- a/src/domain/webBundleAnalyzerInspection.ts +++ b/src/domain/webBundleAnalyzerInspection.ts @@ -5,8 +5,14 @@ import { sanitizeEndpointCandidate } from "./browserObservation.js"; import { compositeKey } from "./unicodeCodePointOrder.js"; import type { WebPageInspection } from "./browserObservationSchemas.js"; import type { WebBundleAnalysis } from "./webBundleAnalysis.js"; -import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js"; -import { classifyParsedJavaScriptOpenReceivers } from "./javascript/javascriptSemanticAnalysis.js"; +import { + completeSemanticSteps, + traverseJavaScriptAst, +} from "./javascript/javascriptSemanticTraversal.js"; +import { + classifyParsedJavaScriptOpenReceivers, + classifyParsedJavaScriptRpcClientRootsSteps, +} from "./javascript/javascriptSemanticAnalysis.js"; import type { JavaScriptOpenReceiverFact } from "./javascript/javascriptSemanticAnalysis.js"; import { semanticStaticPropertyName } from "./javascript/javascriptAstValues.js"; import { @@ -22,6 +28,7 @@ import { resolveSpecifier, isUrlLikeModuleSpecifier, } from "./webBundleAnalyzerAst.js"; +import { createJavaScriptRpcScan } from "./javascript/javascriptRpcEndpoints.js"; type BundleObservations = WebBundleAnalysis["observations"]; type ChunkEdge = BundleObservations["chunks"]["edges"][number]; @@ -66,10 +73,24 @@ export const analyzeScript = ( accumulator.parsedScripts += 1; detectVendorFingerprints(script, accumulator); const openReceiverFacts = classifyParsedJavaScriptOpenReceivers(file); + const rpc = createJavaScriptRpcScan( + script.source.artifact.text, + completeSemanticSteps(classifyParsedJavaScriptRpcClientRootsSteps(file)), + ); traverseJavaScriptAst(file, { enter: (node) => { accumulator.visitedNodes += 1; inspectNode(script, node, accumulator, openReceiverFacts); + const endpoint = rpc.inspect(node); + if (endpoint !== undefined) + addFinding({ + collection: accumulator.endpoints, + script, + rawValue: endpoint.value, + mechanism: endpoint.mechanism, + node, + accumulator, + }); }, }); };