diff --git a/docs/guides/javascript/javascript-artifact-reconstruction.md b/docs/guides/javascript/javascript-artifact-reconstruction.md index e5606971c6..c92a455da8 100644 --- a/docs/guides/javascript/javascript-artifact-reconstruction.md +++ b/docs/guides/javascript/javascript-artifact-reconstruction.md @@ -456,9 +456,13 @@ RPC client syntax also contributes network endpoint candidates, in both - `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 standalone Source object's `body` - remains an operation candidate even when it has `locationOffset` metadata. + selection set starting with a field, named fragment spread, or inline fragment + — or in a precompiled `OperationDefinition` document object. A precompiled + operation name must be one complete GraphQL Name token (ASCII letters, + digits, and underscores, with no leading digit). Recognition is limited to + static operation headers; it does not validate a complete GraphQL document or + prove that the application sends the operation. A standalone Source object's + `body` remains an operation candidate even when it has `locationOffset` metadata. A graphql-tag `loc.source.body` copy is suppressed only when that source belongs to a recovered document with the same operation and its containing document, location and source have proven private metadata ownership. Writes, diff --git a/src/domain/javascript/javascriptGraphqlOperations.ts b/src/domain/javascript/javascriptGraphqlOperations.ts index af7b922122..f95d0b0768 100644 --- a/src/domain/javascript/javascriptGraphqlOperations.ts +++ b/src/domain/javascript/javascriptGraphqlOperations.ts @@ -110,6 +110,30 @@ const graphqlNameAt = ( : { value: name, end: index + name.length }; }; +/** GraphQL selections may start with a field, named spread, or inline fragment. */ +const graphqlSelectionAt = (text: string, start: number): boolean => { + let index = skipGraphqlIgnored(text, start); + for (;;) { + if (graphqlNameAt(text, index) !== undefined) return true; + if (!text.startsWith("...", index)) return false; + index = skipGraphqlIgnored(text, index + 3); + const fragment = graphqlNameAt(text, index); + if (fragment !== undefined) + return ( + fragment.value !== "on" || + graphqlNameAt(text, skipGraphqlIgnored(text, fragment.end)) !== + undefined + ); + if (text.charAt(index) === "@") + return ( + graphqlNameAt(text, skipGraphqlIgnored(text, index + 1)) !== undefined + ); + if (text.charAt(index) !== "{") return false; + // Untyped inline fragments can nest; scan their prefix without recursion. + index = skipGraphqlIgnored(text, index + 1); + } +}; + /** Recover a named operation from GraphQL document text. */ const graphqlOperationFromText = ( text: string, @@ -122,12 +146,13 @@ const graphqlOperationFromText = ( const index = skipGraphqlIgnored(text, name.end); const next = text.charAt(index); // Keep the existing bounded recognition scope: a named operation followed - // by variable definitions, a directive, or a selection set with a field. + // by variable definitions, a directive, or a recognized selection prefix. const hasHeader = next === "(" ? text.charAt(skipGraphqlIgnored(text, index + 1)) === "$" - : (next === "@" || next === "{") && - graphqlNameAt(text, skipGraphqlIgnored(text, index + 1)) !== undefined; + : next === "@" + ? graphqlNameAt(text, skipGraphqlIgnored(text, index + 1)) !== undefined + : next === "{" && graphqlSelectionAt(text, index + 1); return hasHeader ? { value: name.value, mechanism: `graphql:${type.value}` } : undefined; @@ -149,9 +174,10 @@ const graphqlOperationFromDocumentNode = ( ) return undefined; const value = semanticFinalObjectStringProperty(name, "value"); - return value === undefined - ? undefined - : { value, mechanism: `graphql:${type}` }; + if (value === undefined) return undefined; + return graphqlNameAt(value, 0)?.end === value.length + ? { value, mechanism: `graphql:${type}` } + : undefined; }; interface GraphqlSourceDependency { diff --git a/src/domain/javascript/javascriptRpcEndpoints.test.ts b/src/domain/javascript/javascriptRpcEndpoints.test.ts index 763f78b74a..77d2427aa5 100644 --- a/src/domain/javascript/javascriptRpcEndpoints.test.ts +++ b/src/domain/javascript/javascriptRpcEndpoints.test.ts @@ -415,7 +415,10 @@ describe("GraphQL operation recognition", () => { "query GetUser # prose\nparameters", "query Get# split name\nUser { user }", "query GetUser @ # missing directive name", - "query GetUser { # fragment\n...UserFields }", + "query GetUser { # missing fragment name\n... }", + "query GetUser { ... on }", + "query GetUser { ... @ }", + "query GetUser { ....UserFields }", "query\u00a0GetUser { user }", "query\vGetUser { user }", ])("keeps incomplete or unsupported header text unknown: %s", (text) => { diff --git a/tests/boundary/javascript/graphqlOperationGrammar.test.ts b/tests/boundary/javascript/graphqlOperationGrammar.test.ts new file mode 100644 index 0000000000..49d68a0da0 --- /dev/null +++ b/tests/boundary/javascript/graphqlOperationGrammar.test.ts @@ -0,0 +1,127 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { parse as parseGraphql } from "graphql"; + +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const documents = [ + "query FieldFirst { user }", + "query NamedSpread { ...UserFields } fragment UserFields on Query { user }", + "query TypedInline { ... on Query { user } }", + "query DirectedInline { ... @include(if: true) { user } }", + "query UntypedInline { ... { ... { user } } }", +]; + +it.each(["text", "DocumentNode"] as const)( + "retains GraphQL operation grammar and source Evidence through application analysis: %s", + async (representation) => { + const root = await createTestTempDirectory("rea-graphql-grammar-"); + const names = + representation === "text" + ? documents.map((text) => { + const operation = parseGraphql(text).definitions.find( + (definition) => definition.kind === "OperationDefinition", + ); + if (operation?.name === undefined) + throw new Error("Expected named operation"); + return operation.name.value; + }) + : ["_", "_GetUser2"]; + const definitions = + representation === "text" + ? documents + .map( + (text, index) => + `const document${index} = ${index % 3 === 0 ? JSON.stringify(text) : index % 3 === 1 ? "gql`" + text + "`" : `{ body: ${JSON.stringify(text)}, locationOffset: { line: 1, column: 1 } }`};`, + ) + .join("\n") + : [...names, "", "two words", "123Name", "GetUser\n", "GetUser💫"] + .map( + (name, index) => + `const document${index} = { kind: "OperationDefinition", operation: "query", name: { kind: "Name", value: ${JSON.stringify(name)} } };`, + ) + .join("\n"); + const source = `throw new Error("This authored input must never execute");\n${definitions}\nconst malformed = "query Broken { ... }";\nconst prose = "query string parameters";\nfetch(dynamicEndpoint);\n`; + await writeFile(join(root, "app.mjs"), source); + const analyzed = await analyzeJavaScriptApplication({ + input_path: root, + format: "directory", + }); + if (!analyzed.ok) throw analyzed.error; + expect(analyzed.value.parameters).toMatchObject({ + input_path: root, + format: "directory", + }); + expect(analyzed.value.authority).toBe("shipped-artifact"); + const result = javascriptApplicationAnalysisResultSchema.parse( + analyzed.value.normalized_result, + ); + const endpoints = result.graph.nodes.filter( + ({ kind }) => kind === "endpoint", + ); + const graphql = endpoints + .flatMap(({ observations }) => observations) + .filter( + ({ properties }) => + typeof properties.mechanism === "string" && + properties.mechanism.startsWith("graphql:"), + ); + expect(graphql.map(({ properties }) => properties.value).sort()).toEqual( + [...names].sort(), + ); + const sha256 = createHash("sha256").update(source).digest("hex"); + for (const observation of graphql) { + expect(observation.properties.mechanism).toBe("graphql:query"); + expect(observation.evidence).toMatchObject({ + authority: "ast-static-analysis", + state: "observed", + confidence: "exact", + artifact: { available: true, sha256 }, + location: { + available: true, + value: { + kind: "source-range", + source: "app.mjs", + start: { line: expect.any(Number), column: expect.any(Number) }, + end: { line: expect.any(Number), column: expect.any(Number) }, + }, + }, + }); + const location = observation.evidence.location; + if (!location.available || location.value.kind !== "source-range") + throw new Error("Expected the operation's source range"); + const name = String(observation.properties.value); + expect(location.value.start.line).toBe(names.indexOf(name) + 2); + expect(location.value.end.line).toBe(location.value.start.line); + const selected = source + .split("\n") + [location.value.start.line - 1]!.slice( + location.value.start.column, + location.value.end.column, + ); + expect(selected).toContain(name); + } + const unresolved = endpoints + .flatMap(({ observations }) => observations) + .find(({ properties }) => properties.value === "dynamicEndpoint"); + expect(unresolved).toMatchObject({ + properties: { mechanism: "call:fetch", resolution: "partial" }, + evidence: { + limitations: expect.arrayContaining([ + expect.stringContaining("unresolved source expression"), + ]), + }, + }); + const calls = result.graph.edges.filter( + ({ relation }) => relation === "calls", + ); + expect(calls).toHaveLength(names.length + 1); + expect(calls.every(({ evidence }) => evidence.state === "inferred")).toBe( + true, + ); + }, +);