diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index b166bf65eb..c37a99002a 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -273,6 +273,12 @@ RPC client syntax also contributes network endpoint candidates, in both `OperationDefinition` document object. A graphql-tag `loc.source.body` copy is not reported twice. Anonymous operations are not reported. +Literal link options and precompiled document fields follow object property +order: the final definition owns the key. A later spread, dynamic computed key, +method, or accessor can make a field unknown, so an overwritten literal is not +reported as the endpoint or operation. A final explicit property after a spread +or dynamic key remains usable. + 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 diff --git a/src/domain/javascript/javascriptRpcEndpoints.test.ts b/src/domain/javascript/javascriptRpcEndpoints.test.ts index b163e14443..16969e9a40 100644 --- a/src/domain/javascript/javascriptRpcEndpoints.test.ts +++ b/src/domain/javascript/javascriptRpcEndpoints.test.ts @@ -13,6 +13,50 @@ const rpcEndpoints = (source: string) => mechanism.startsWith("trpc:") || mechanism.startsWith("graphql:"), ); +describe("RPC object property ownership", () => { + it.each([ + ['url: "/trpc/old", url: "/trpc/live"', "/trpc/live"], + ['url: "/trpc/old", url: runtimeUrl', null], + ['url: "/trpc/old", ...options', null], + ['url: "/trpc/old", [key]: runtimeUrl', null], + ['url: "/trpc/old", get url() { return runtimeUrl; }', null], + ['url: "/trpc/old", url() {}', null], + ['...options, url: "/trpc/live", headers: {}', "/trpc/live"], + ['[key]: runtimeUrl, url: "/trpc/live"', "/trpc/live"], + ['url: "/trpc/old", ["url"]: "/trpc/live"', "/trpc/live"], + ] as const)( + "reads only the final known link URL from {%s}", + (options, value) => { + expect(rpcEndpoints(`httpBatchLink({ ${options} });`)).toEqual( + value === null + ? [] + : [{ kind: "network", value, mechanism: "trpc:link:httpBatchLink" }], + ); + }, + ); + + it.each([ + ['operation: "mutation"', "Old", "graphql:mutation"], + ['name: { kind: "Name", value: "Current" }', "Current", "graphql:query"], + ['kind: "FragmentDefinition"', null, null], + ["name: runtimeName", null, null], + ["get name() { return runtimeName; }", null, null], + ["...options", null, null], + ["[key]: runtimeValue", null, null], + ] as const)( + "honors final GraphQL document properties: %s", + (override, value, mechanism) => { + const source = `const doc = { + kind: "OperationDefinition", operation: "query", + name: { kind: "Name", value: "Old" }, ${override} + };`; + expect(rpcEndpoints(source)).toEqual( + value === null ? [] : [{ kind: "network", value, mechanism }], + ); + }, + ); +}); + describe("tRPC procedure recognition", () => { it("recognizes React hook procedures on a renamed proxy root", () => { expect( diff --git a/src/domain/javascript/javascriptRpcEndpoints.ts b/src/domain/javascript/javascriptRpcEndpoints.ts index 9ad4e3cf20..7c97430c0e 100644 --- a/src/domain/javascript/javascriptRpcEndpoints.ts +++ b/src/domain/javascript/javascriptRpcEndpoints.ts @@ -122,33 +122,30 @@ const objectKeys = (node: t.Node | undefined): readonly string[] => }) : []; -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; -}; - +/** Later properties own a key; an intervening dynamic write leaves it unknown. */ 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; + for (let index = node.properties.length - 1; index >= 0; index -= 1) { + const property = node.properties[index]; + if (property === undefined || t.isSpreadElement(property)) return undefined; + const name = semanticStaticPropertyKey(property.key, property.computed); + if (name === null) return undefined; + if (name === key) + return t.isObjectProperty(property) ? property.value : undefined; + } return undefined; }; +const objectStringProperty = ( + node: t.ObjectExpression, + key: string, +): string | undefined => { + const value = objectProperty(node, key); + return t.isStringLiteral(value) ? value.value : undefined; +}; + const GRAPHQL_OPERATION_TYPES: ReadonlySet = new Set([ "query", "mutation", diff --git a/tests/acceptance/applications/javascriptRpcPropertyOwnershipCli.test.ts b/tests/acceptance/applications/javascriptRpcPropertyOwnershipCli.test.ts new file mode 100644 index 0000000000..a369e7519d --- /dev/null +++ b/tests/acceptance/applications/javascriptRpcPropertyOwnershipCli.test.ts @@ -0,0 +1,57 @@ +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { + analyzeJavaScriptDirectory, + runNodeJsonControl, +} from "../../support/cli/javascriptApplication.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "retains only final known RPC object properties through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-rpc-properties-"); + await writeFixtureFiles(root, { + "entry.mjs": ` + function httpBatchLink(options) { return options.url; } + const options = { url: "/trpc/runtime" }; + const key = "url"; + const links = [ + httpBatchLink({ url: "/trpc/old", url: "/trpc/live" }), + httpBatchLink({ url: "/trpc/spread-old", ...options }), + httpBatchLink({ url: "/trpc/computed-old", [key]: options.url }), + httpBatchLink({ ...options, url: "/trpc/final", headers: {} }), + ]; + const doc = { + kind: "OperationDefinition", operation: "query", operation: "mutation", + name: { kind: "Name", value: "Old" }, + name: { kind: "Name", value: "Current" }, + }; + console.log(JSON.stringify([links, doc.operation, doc.name.value])); + `, + }); + // This control checks JavaScript object semantics, not a live tRPC client. + expect(await runNodeJsonControl(root, "entry.mjs")).toEqual([ + ["/trpc/live", "/trpc/runtime", "/trpc/runtime", "/trpc/final"], + "mutation", + "Current", + ]); + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root); + const endpoints = graph.nodes + .filter((node) => node.kind === "endpoint") + .flatMap((node) => node.observations) + .filter(({ properties }) => + /^(trpc:link:|graphql:)/u.test(String(properties.mechanism)), + ) + .map(({ properties }) => [properties.value, properties.mechanism]); + expect(endpoints).toEqual( + expect.arrayContaining([ + ["/trpc/live", "trpc:link:httpBatchLink"], + ["/trpc/final", "trpc:link:httpBatchLink"], + ["Current", "graphql:mutation"], + ]), + ); + expect(endpoints).toHaveLength(3); + }, +);