fix(javascript): retain standalone GraphQL source operations

Retain named operations in GraphQL Source bodies with locationOffset metadata.
Use final property ownership and parsed lexical facts to suppress copies only
when a recovered document, its containers and source aliases have proven
private metadata ownership. Preserve independent observations after writes,
exports, opaque uses, classic-script global exposure or direct evaluation.

Share interpretation across application and captured-web analysis. Build
optional reference indexes once per source and checkpoint examined records,
including suppressed findings, to retain cooperative cancellation.

Fixes #3311

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-15 15:59:42 +08:00
1 parent c1fd496e6a
commit 6688ed6db7
13 files changed
+986 -234

No files matched your search

+12 -191
View File
@@ -3,8 +3,11 @@ import * as t from "@babel/types";
import {
unwrapJavaScriptExpression,
semanticStaticPropertyKey,
semanticFinalObjectStringProperty,
} from "./javascriptAstValues.js";
import { calleeName } from "./javascriptStaticAnalysisHelpers.js";
import type { JavaScriptBindingIndex } from "./javascriptSemanticState.js";
import { createJavaScriptGraphqlScan } from "./javascriptGraphqlOperations.js";
/** One RPC operation or transport URL recovered from client-side syntax. */
export interface JavaScriptRpcEndpoint {
@@ -12,9 +15,12 @@ export interface JavaScriptRpcEndpoint {
readonly mechanism: string;
}
/** Per-source RPC recognition state; feed it every AST node in pre-order. */
/** Feed every AST node in pre-order, then drain finish() for Source findings. */
export interface JavaScriptRpcScan {
inspect(node: t.Node): JavaScriptRpcEndpoint | undefined;
finish(): Iterable<
(JavaScriptRpcEndpoint & { readonly node: t.Node }) | void
>;
}
// tRPC proxies preserve router and procedure names as property names, so they
@@ -122,165 +128,6 @@ const objectKeys = (node: t.Node | undefined): readonly string[] =>
})
: [];
/** Later properties own a key; an intervening dynamic write leaves it unknown. */
const objectProperty = (
node: t.ObjectExpression,
key: string,
): t.Node | undefined => {
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;
};
/**
* 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<t.ObjectExpression>();
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<string> = new Set([
"query",
"mutation",
"subscription",
]);
// GraphQL October 2021 §2.1.7: ignored BOM, tab/space, line endings, commas;
// comments are consumed separately below.
const isGraphqlIgnoredCharacter = (character: string): boolean =>
character === "," ||
character === "\uFEFF" ||
character === "\t" ||
character === " " ||
character === "\n" ||
character === "\r";
/** Return the first offset after GraphQL ignored tokens. */
const skipGraphqlIgnored = (text: string, start: number): number => {
const limit = text.length;
let index = start;
while (index < limit) {
const character = text.charAt(index);
if (isGraphqlIgnoredCharacter(character)) index += 1;
else if (character === "#") {
while (index < limit && !/[\n\r]/u.test(text.charAt(index))) index += 1;
} else break;
}
return index;
};
const graphqlNameAt = (
text: string,
index: number,
): { readonly value: string; readonly end: number } | undefined => {
const name = /^[_A-Za-z][_0-9A-Za-z]*/u.exec(text.slice(index))?.[0];
return name === undefined
? undefined
: { value: name, end: index + name.length };
};
/** Recover a named operation from GraphQL document text. */
export const graphqlOperationFromText = (
text: string,
): JavaScriptRpcEndpoint | undefined => {
const type = graphqlNameAt(text, skipGraphqlIgnored(text, 0));
if (type === undefined || !GRAPHQL_OPERATION_TYPES.has(type.value))
return undefined;
const name = graphqlNameAt(text, skipGraphqlIgnored(text, type.end));
if (name === undefined) return undefined;
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.
const hasHeader =
next === "("
? text.charAt(skipGraphqlIgnored(text, index + 1)) === "$"
: (next === "@" || next === "{") &&
graphqlNameAt(text, skipGraphqlIgnored(text, index + 1)) !== undefined;
return hasHeader
? { value: name.value, mechanism: `graphql:${type.value}` }
: undefined;
};
/** Recover a named operation from a precompiled GraphQL document object. */
export const graphqlOperationFromDocumentNode = (
node: t.ObjectExpression,
): JavaScriptRpcEndpoint | undefined => {
if (objectStringProperty(node, "kind") !== "OperationDefinition")
return undefined;
const type = objectStringProperty(node, "operation");
if (type === undefined || !GRAPHQL_OPERATION_TYPES.has(type))
return undefined;
const name = objectProperty(node, "name");
if (
!t.isObjectExpression(name) ||
objectStringProperty(name, "kind") !== "Name"
)
return undefined;
const value = objectStringProperty(name, "value");
return value === undefined
? undefined
: { value, mechanism: `graphql:${type}` };
};
/** Bundlers use `(0, imported.factory)` to call an import without a receiver. */
const rpcFactoryName = (callee: t.Node): string => {
let current = unwrapJavaScriptExpression(callee).node;
@@ -312,7 +159,7 @@ const trpcLinkUrl = (
if (!TRPC_URL_LINKS.has(factory)) return undefined;
const options = node.arguments[0];
if (!t.isObjectExpression(options)) return undefined;
const url = objectStringProperty(options, "url");
const url = semanticFinalObjectStringProperty(options, "url");
return url === undefined
? undefined
: { value: url, mechanism: `trpc:link:${factory}` };
@@ -328,14 +175,12 @@ const trpcLinkUrl = (
export const createJavaScriptRpcScan = (
source: string,
clientRoots: ReadonlySet<t.Node>,
index: JavaScriptBindingIndex,
): JavaScriptRpcScan => {
let marker: boolean | undefined;
const hasMarker = (): boolean =>
(marker ??= TRPC_SOURCE_MARKERS.some((value) => source.includes(value)));
// 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<t.Node>();
const graphql = createJavaScriptGraphqlScan(index);
const procedure = (
node: t.CallExpression | t.OptionalCallExpression,
@@ -369,32 +214,8 @@ export const createJavaScriptRpcScan = (
inspect: (node) => {
if (t.isCallExpression(node) || t.isOptionalCallExpression(node))
return trpcLinkUrl(node) ?? procedure(node);
if (t.isObjectExpression(node)) {
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 graphqlDocumentSourceBodies.has(node)
? undefined
: graphqlOperationFromText(node.value);
if (t.isTemplateLiteral(node)) {
// Interpolations may contain fragment definitions. Continue only
// across quasis that contain ignored tokens; prose or dynamic text
// before an operation prevents claiming a later operation belongs to
// this document prefix.
for (const quasi of node.quasis) {
const cooked = quasi.value.cooked;
if (cooked === undefined || cooked === null) return undefined;
if (skipGraphqlIgnored(cooked, 0) === cooked.length) continue;
return graphqlOperationFromText(cooked);
}
return undefined;
}
return undefined;
return graphql.inspect(node);
},
finish: graphql.finish,
};
};