fix(javascript): recover GraphQL selection prefixes and validate names (#10065)

fix(javascript): align GraphQL operation grammar
This commit is contained in:
morluto authored and GitHub committed 2026-11-11 18:16:50 +08:00
1 parent f7d564f436
commit d1895841a4
4 files changed
+170 -10

No files matched your search

@@ -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,
@@ -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 {
@@ -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) => {
@@ -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,
);
},
);