From 12b3cdb8041d82e90dc7360db9e74782ac8583ae Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:00:21 +0800 Subject: [PATCH] fix(javascript): preserve final RPC property and source-body ownership Normalize exact template and bigint keys at the shared AST boundary. Keep overwritten GraphQL body literals owned by their source object so they cannot resurface as standalone operations. Reviewed-PR: #3244 Co-authored-by: Vaishnavi220506 --- src/domain/javascript/javascriptAstValues.ts | 14 ++++ .../javascript/javascriptRpcEndpoints.test.ts | 38 ++++++++++ .../javascript/javascriptRpcEndpoints.ts | 69 ++++++++++++++++--- 3 files changed, 111 insertions(+), 10 deletions(-) diff --git a/src/domain/javascript/javascriptAstValues.ts b/src/domain/javascript/javascriptAstValues.ts index 7a43865242..8be3a5e838 100644 --- a/src/domain/javascript/javascriptAstValues.ts +++ b/src/domain/javascript/javascriptAstValues.ts @@ -81,6 +81,20 @@ export const semanticStaticPropertyKey = ( property = unwrapJavaScriptExpression(property).node; if (t.isStringLiteral(property) || t.isNumericLiteral(property)) return String(property.value); + if (t.isBigIntLiteral(property)) return String(property.value); + if ( + t.isUnaryExpression(property, { operator: "-" }) && + t.isBigIntLiteral(property.argument) + ) + return String(-BigInt(property.argument.value)); + if ( + t.isTemplateLiteral(property) && + property.expressions.length === 0 && + property.quasis.length === 1 + ) { + const cooked = property.quasis[0]?.value.cooked; + return cooked === null || cooked === undefined ? null : cooked; + } return !computed && t.isIdentifier(property) ? property.name : null; }; diff --git a/src/domain/javascript/javascriptRpcEndpoints.test.ts b/src/domain/javascript/javascriptRpcEndpoints.test.ts index 16969e9a40..e0f049a388 100644 --- a/src/domain/javascript/javascriptRpcEndpoints.test.ts +++ b/src/domain/javascript/javascriptRpcEndpoints.test.ts @@ -24,6 +24,12 @@ describe("RPC object property ownership", () => { ['...options, url: "/trpc/live", headers: {}', "/trpc/live"], ['[key]: runtimeUrl, url: "/trpc/live"', "/trpc/live"], ['url: "/trpc/old", ["url"]: "/trpc/live"', "/trpc/live"], + ['url: "/trpc/old", [`url`]: "/trpc/live"', "/trpc/live"], + ['url: "/trpc/live", [`headers`]: {}', "/trpc/live"], + ['url: "/trpc/live", [1n]: {}', "/trpc/live"], + ['url: "/trpc/live", [0x1n]: {}', "/trpc/live"], + ['url: "/trpc/live", [0b1n]: {}', "/trpc/live"], + ['url: "/trpc/live", [-1n]: {}', "/trpc/live"], ] as const)( "reads only the final known link URL from {%s}", (options, value) => { @@ -392,7 +398,9 @@ describe("GraphQL operation recognition", () => { { kind: "network", value: name, mechanism: "graphql:query" }, ]); }); +}); +describe("GraphQL document ownership", () => { it("recognizes precompiled GraphQL document objects", () => { expect( rpcEndpoints(` @@ -440,6 +448,36 @@ describe("GraphQL operation recognition", () => { { kind: "network", value: "GetUser", mechanism: "graphql:query" }, ]); }); + + it.each([ + `body: "query Old { user }", body: "query Current { user }", locationOffset: { line: 1, column: 1 }`, + `body: "query Old { user }", locationOffset: { line: 1, column: 1 }, ...sourceOptions`, + `body: "query Old { user }", locationOffset: { line: 1, column: 1 }, ...{ body: "query Current { user }" }`, + `...{ body: "query Old { user }" }, ...runtimeOptions, locationOffset: { line: 1, column: 1 }`, + ])( + "does not report overwritten or uncertain GraphQL source bodies: %s", + (sourceFields) => { + expect( + rpcEndpoints(` + const source = { ${sourceFields} }; + const operation = { + kind: "OperationDefinition", + operation: "query", + name: { kind: "Name", value: "Current" }, + loc: { source }, + }; + `), + ).toEqual([ + { kind: "network", value: "Current", mechanism: "graphql:query" }, + ]); + }, + ); + + it("keeps ordinary GraphQL string scans outside Source-shaped objects", () => { + expect(rpcEndpoints('const text = "query Standalone { user }";')).toEqual([ + { kind: "network", value: "Standalone", mechanism: "graphql:query" }, + ]); + }); }); it("keeps existing network endpoint recognition unchanged", () => { diff --git a/src/domain/javascript/javascriptRpcEndpoints.ts b/src/domain/javascript/javascriptRpcEndpoints.ts index 7c97430c0e..08e6f3aa61 100644 --- a/src/domain/javascript/javascriptRpcEndpoints.ts +++ b/src/domain/javascript/javascriptRpcEndpoints.ts @@ -146,6 +146,57 @@ const objectStringProperty = ( return t.isStringLiteral(value) ? value.value : undefined; }; +/** + * Find source-body string values owned by a GraphQL Source-shaped literal. + * A later duplicate or spread can make the effective `body` unknown, but the + * earlier strings still belong to this object and must not become standalone + * operation findings during the string scan. + */ +const graphqlSourceBodyLiterals = ( + node: t.ObjectExpression, +): readonly t.StringLiteral[] | undefined => { + const bodyStrings = (object: t.ObjectExpression) => + object.properties.flatMap((property) => + t.isObjectProperty(property) && + semanticStaticPropertyKey(property.key, property.computed) === "body" && + t.isStringLiteral(property.value) + ? [property.value] + : [], + ); + const bodies = bodyStrings(node); + const hasLocationOffset = node.properties.some( + (property) => + t.isObjectProperty(property) && + semanticStaticPropertyKey(property.key, property.computed) === + "locationOffset", + ); + if (!hasLocationOffset) return undefined; + + // Inline object spreads can own additional body values which the surrounding + // Source object may select. Include only body-key string literals from those + // spread values; ordinary strings elsewhere in the object remain scannable. + const visited = new WeakSet(); + visited.add(node); + const pending = node.properties.flatMap((property) => { + if (!t.isSpreadElement(property)) return []; + const spread = unwrapJavaScriptExpression(property.argument).node; + return t.isObjectExpression(spread) ? [spread] : []; + }); + while (pending.length > 0) { + const current = pending.pop(); + if (current === undefined || visited.has(current)) continue; + visited.add(current); + bodies.push(...bodyStrings(current)); + for (const property of current.properties) { + if (t.isSpreadElement(property)) { + const spread = unwrapJavaScriptExpression(property.argument).node; + if (t.isObjectExpression(spread)) pending.push(spread); + } + } + } + return bodies.length === 0 ? undefined : bodies; +}; + const GRAPHQL_OPERATION_TYPES: ReadonlySet = new Set([ "query", "mutation", @@ -281,9 +332,10 @@ export const createJavaScriptRpcScan = ( 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(); + // graphql-tag documents retain source text in loc.source.body. Keep each + // direct body literal owned by a Source-shaped object out of the independent + // string scan, including duplicates that a later property or spread hides. + const graphqlDocumentSourceBodies = new WeakSet(); const procedure = ( node: t.CallExpression | t.OptionalCallExpression, @@ -318,18 +370,15 @@ export const createJavaScriptRpcScan = ( 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); + const bodies = graphqlSourceBodyLiterals(node); + if (bodies !== undefined) { + for (const body of bodies) graphqlDocumentSourceBodies.add(body); return undefined; } return graphqlOperationFromDocumentNode(node); } if (t.isStringLiteral(node)) - return documentSourceBodies.has(node) + return graphqlDocumentSourceBodies.has(node) ? undefined : graphqlOperationFromText(node.value); if (t.isTemplateLiteral(node)) {