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)) {