mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
13 files changed
+986
-234
No files matched your search
@@ -276,8 +276,13 @@ RPC client syntax also contributes network endpoint candidates, in both
|
||||
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 graphql-tag `loc.source.body` copy is
|
||||
not reported twice. Anonymous operations are not reported.
|
||||
`OperationDefinition` document object. 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,
|
||||
escapes and classic-script global bindings retain the source observation.
|
||||
Anonymous operations are not reported.
|
||||
|
||||
Literal link options and precompiled document fields follow object property
|
||||
order: the final definition owns the key. A later spread, dynamic computed key,
|
||||
|
||||
@@ -98,6 +98,30 @@ export const semanticStaticPropertyKey = (
|
||||
return !computed && t.isIdentifier(property) ? property.name : null;
|
||||
};
|
||||
|
||||
/** Later properties own a key; an intervening dynamic write leaves it unknown. */
|
||||
export const semanticFinalObjectProperty = (
|
||||
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;
|
||||
};
|
||||
|
||||
export const semanticFinalObjectStringProperty = (
|
||||
node: t.ObjectExpression,
|
||||
key: string,
|
||||
): string | undefined => {
|
||||
const value = semanticFinalObjectProperty(node, key);
|
||||
return t.isStringLiteral(value) ? value.value : undefined;
|
||||
};
|
||||
|
||||
/** Read exact consumed object-pattern keys, leaving dynamic keys unknown. */
|
||||
export const semanticObjectPatternKeys = (
|
||||
pattern: t.ObjectPattern,
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
import * as t from "@babel/types";
|
||||
import {
|
||||
unwrapJavaScriptExpression,
|
||||
semanticStaticPropertyKey,
|
||||
semanticFinalObjectProperty,
|
||||
semanticFinalObjectStringProperty,
|
||||
} from "./javascriptAstValues.js";
|
||||
import { range } from "./javascriptStaticAnalysisHelpers.js";
|
||||
import type { JavaScriptSemanticReference } from "./javascriptSemanticIr.js";
|
||||
import {
|
||||
resolveSemanticBindingState,
|
||||
semanticStableInitializer,
|
||||
type JavaScriptSemanticAnalysisState,
|
||||
type JavaScriptBindingIndex,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import type {
|
||||
JavaScriptRpcEndpoint,
|
||||
JavaScriptRpcScan,
|
||||
} from "./javascriptRpcEndpoints.js";
|
||||
|
||||
/**
|
||||
* 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 hasLocationOffset = node.properties.some(
|
||||
(property) =>
|
||||
t.isObjectProperty(property) &&
|
||||
semanticStaticPropertyKey(property.key, property.computed) ===
|
||||
"locationOffset",
|
||||
);
|
||||
if (!hasLocationOffset) return 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);
|
||||
|
||||
// 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. */
|
||||
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. */
|
||||
const graphqlOperationFromDocumentNode = (
|
||||
node: t.ObjectExpression,
|
||||
): JavaScriptRpcEndpoint | undefined => {
|
||||
if (semanticFinalObjectStringProperty(node, "kind") !== "OperationDefinition")
|
||||
return undefined;
|
||||
const type = semanticFinalObjectStringProperty(node, "operation");
|
||||
if (type === undefined || !GRAPHQL_OPERATION_TYPES.has(type))
|
||||
return undefined;
|
||||
const name = semanticFinalObjectProperty(node, "name");
|
||||
if (
|
||||
!t.isObjectExpression(name) ||
|
||||
semanticFinalObjectStringProperty(name, "kind") !== "Name"
|
||||
)
|
||||
return undefined;
|
||||
const value = semanticFinalObjectStringProperty(name, "value");
|
||||
return value === undefined
|
||||
? undefined
|
||||
: { value, mechanism: `graphql:${type}` };
|
||||
};
|
||||
|
||||
interface GraphqlSourceDependency {
|
||||
readonly bindingId: string;
|
||||
readonly name: string;
|
||||
readonly location?: JavaScriptSemanticReference["location"];
|
||||
}
|
||||
|
||||
interface GraphqlSourceCopy {
|
||||
readonly source: t.ObjectExpression;
|
||||
readonly dependencies: readonly GraphqlSourceDependency[];
|
||||
readonly operations: readonly JavaScriptRpcEndpoint[];
|
||||
}
|
||||
|
||||
/** One private constant owns its initializer; exported or projected values do not. */
|
||||
const graphqlPrivateBinding = (
|
||||
node: t.Identifier,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
sourceType: t.Program["sourceType"],
|
||||
) => {
|
||||
const binding = resolveSemanticBindingState(state, node, node.name);
|
||||
if (binding === undefined || binding.mutable) return undefined;
|
||||
const scope = state.scopesById.get(binding.scopeId);
|
||||
if (
|
||||
scope === undefined ||
|
||||
(scope.kind === "program" && sourceType !== "module")
|
||||
)
|
||||
return undefined;
|
||||
const initializer = semanticStableInitializer(binding, state);
|
||||
if (initializer?.projection.length !== 0) return undefined;
|
||||
const declaration = state.parentsByNode.get(initializer.node);
|
||||
const statement = t.isVariableDeclarator(declaration)
|
||||
? state.parentsByNode.get(declaration)
|
||||
: undefined;
|
||||
return t.isVariableDeclaration(statement) &&
|
||||
!t.isExportNamedDeclaration(state.parentsByNode.get(statement))
|
||||
? { binding, initializer }
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Resolve potential metadata ownership; references must still prove exclusivity. */
|
||||
const graphqlSourceObjectValue = (
|
||||
node: t.Node | undefined,
|
||||
index: JavaScriptBindingIndex,
|
||||
dependencies: GraphqlSourceDependency[],
|
||||
): t.ObjectExpression | undefined => {
|
||||
if (node === undefined) return undefined;
|
||||
const state = index.bindings;
|
||||
const visited = new Set<t.Node>();
|
||||
while (node !== undefined && !visited.has(node)) {
|
||||
visited.add(node);
|
||||
node = unwrapJavaScriptExpression(node).node;
|
||||
if (t.isObjectExpression(node)) return node;
|
||||
if (!t.isIdentifier(node)) return undefined;
|
||||
const owner = graphqlPrivateBinding(node, state, index.sourceType);
|
||||
if (owner === undefined) return undefined;
|
||||
const { binding, initializer } = owner;
|
||||
if (
|
||||
initializer.node.end == null ||
|
||||
node.start == null ||
|
||||
node.loc == null ||
|
||||
initializer.node.end > node.start
|
||||
)
|
||||
return undefined;
|
||||
dependencies.push({
|
||||
bindingId: binding.bindingId,
|
||||
name: binding.name,
|
||||
location: range(node),
|
||||
});
|
||||
node = initializer.node;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const graphqlDocumentSource = (
|
||||
node: t.ObjectExpression,
|
||||
index: JavaScriptBindingIndex,
|
||||
): Omit<GraphqlSourceCopy, "operations"> | undefined => {
|
||||
const dependencies: GraphqlSourceDependency[] = [];
|
||||
const loc = graphqlSourceObjectValue(
|
||||
semanticFinalObjectProperty(node, "loc"),
|
||||
index,
|
||||
dependencies,
|
||||
);
|
||||
const source =
|
||||
loc === undefined
|
||||
? undefined
|
||||
: graphqlSourceObjectValue(
|
||||
semanticFinalObjectProperty(loc, "source"),
|
||||
index,
|
||||
dependencies,
|
||||
);
|
||||
return source !== undefined &&
|
||||
graphqlContainerOwnership(node, index, dependencies)
|
||||
? { source, dependencies }
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Follow containment to the binding that can expose or mutate this document. */
|
||||
const graphqlContainerOwnership = (
|
||||
document: t.ObjectExpression,
|
||||
index: JavaScriptBindingIndex,
|
||||
dependencies: GraphqlSourceDependency[],
|
||||
): boolean => {
|
||||
const state = index.bindings;
|
||||
let node: t.Node = document;
|
||||
for (;;) {
|
||||
const parent = state.parentsByNode.get(node);
|
||||
if (parent === undefined) return false;
|
||||
if (t.isObjectProperty(parent) && parent.value === node) {
|
||||
const object = state.parentsByNode.get(parent);
|
||||
if (!t.isObjectExpression(object)) return false;
|
||||
node = object;
|
||||
} else if (
|
||||
t.isArrayExpression(parent) ||
|
||||
unwrapJavaScriptExpression(parent).node === node
|
||||
)
|
||||
node = parent;
|
||||
else if (t.isExpressionStatement(parent)) return true;
|
||||
else if (t.isVariableDeclarator(parent) && parent.init === node) {
|
||||
if (!t.isIdentifier(parent.id)) return false;
|
||||
const owner = graphqlPrivateBinding(parent.id, state, index.sourceType);
|
||||
if (owner?.initializer.node !== node) return false;
|
||||
const { binding } = owner;
|
||||
dependencies.push({ bindingId: binding.bindingId, name: binding.name });
|
||||
return true;
|
||||
} else return false;
|
||||
}
|
||||
};
|
||||
|
||||
const graphqlDocumentOperations = (
|
||||
node: t.ObjectExpression,
|
||||
operation: JavaScriptRpcEndpoint | undefined,
|
||||
): readonly JavaScriptRpcEndpoint[] => {
|
||||
if (operation !== undefined) return [operation];
|
||||
if (semanticFinalObjectStringProperty(node, "kind") !== "Document") return [];
|
||||
const definitions = semanticFinalObjectProperty(node, "definitions");
|
||||
return t.isArrayExpression(definitions)
|
||||
? definitions.elements.flatMap((definition) => {
|
||||
const recovered = t.isObjectExpression(definition)
|
||||
? graphqlOperationFromDocumentNode(definition)
|
||||
: undefined;
|
||||
return recovered === undefined ? [] : [recovered];
|
||||
})
|
||||
: [];
|
||||
};
|
||||
|
||||
const graphqlOperationKey = (operation: JavaScriptRpcEndpoint): string =>
|
||||
`${operation.mechanism}:${operation.value}`;
|
||||
|
||||
/** Any non-metadata use makes that connected alias component uncertain. */
|
||||
interface GraphqlSourceReferenceIndex {
|
||||
readonly allowed: Map<string, Set<string>>;
|
||||
readonly neighbors: Map<string, Set<string>>;
|
||||
readonly byName: Map<string, Set<string>>;
|
||||
}
|
||||
|
||||
function* graphqlSourceReferenceIndexSteps(
|
||||
copies: readonly GraphqlSourceCopy[],
|
||||
): Generator<void, GraphqlSourceReferenceIndex> {
|
||||
const allowed = new Map<string, Set<string>>();
|
||||
const neighbors = new Map<string, Set<string>>();
|
||||
const byName = new Map<string, Set<string>>();
|
||||
let examined = 0;
|
||||
for (const { dependencies } of copies) {
|
||||
const first = dependencies[0]?.bindingId;
|
||||
for (const { bindingId, name, location } of dependencies) {
|
||||
if (++examined % 1024 === 0) yield;
|
||||
const sites = allowed.get(bindingId) ?? new Set<string>();
|
||||
if (location !== undefined) sites.add(JSON.stringify(location));
|
||||
allowed.set(bindingId, sites);
|
||||
const names = byName.get(name) ?? new Set<string>();
|
||||
names.add(bindingId);
|
||||
byName.set(name, names);
|
||||
if (first === undefined || first === bindingId) continue;
|
||||
const left = neighbors.get(first) ?? new Set<string>();
|
||||
const right = neighbors.get(bindingId) ?? new Set<string>();
|
||||
left.add(bindingId);
|
||||
right.add(first);
|
||||
neighbors.set(first, left);
|
||||
neighbors.set(bindingId, right);
|
||||
}
|
||||
}
|
||||
return { allowed, neighbors, byName };
|
||||
}
|
||||
|
||||
function* uncertainGraphqlSourceBindingsSteps(
|
||||
index: GraphqlSourceReferenceIndex,
|
||||
references: readonly JavaScriptSemanticReference[],
|
||||
): Generator<void, ReadonlySet<string>> {
|
||||
const { allowed, neighbors, byName } = index;
|
||||
const uncertain = new Set<string>();
|
||||
let examined = 0;
|
||||
for (const reference of references) {
|
||||
if (++examined % 1024 === 0) yield;
|
||||
// Direct eval may reach private lexical objects. Indirect eval cannot;
|
||||
// classic-script globals are already rejected at private-binding admission.
|
||||
if (reference.name === "eval") return new Set(allowed.keys());
|
||||
if (reference.bindingId !== null) {
|
||||
const sites = allowed.get(reference.bindingId);
|
||||
if (
|
||||
sites !== undefined &&
|
||||
(reference.role !== "read" ||
|
||||
!sites.has(JSON.stringify(reference.location)))
|
||||
)
|
||||
uncertain.add(reference.bindingId);
|
||||
} else if (
|
||||
reference.resolution === "unknown" ||
|
||||
reference.resolution === "ambiguous"
|
||||
)
|
||||
for (const bindingId of byName.get(reference.name) ?? [])
|
||||
uncertain.add(bindingId);
|
||||
}
|
||||
const pending = [...uncertain];
|
||||
for (
|
||||
let bindingId = pending.pop();
|
||||
bindingId !== undefined;
|
||||
bindingId = pending.pop()
|
||||
)
|
||||
for (const neighbor of neighbors.get(bindingId) ?? []) {
|
||||
if (++examined % 1024 === 0) yield;
|
||||
if (!uncertain.has(neighbor)) {
|
||||
uncertain.add(neighbor);
|
||||
pending.push(neighbor);
|
||||
}
|
||||
}
|
||||
return uncertain;
|
||||
}
|
||||
|
||||
function* provenGraphqlSourceCopiesSteps(
|
||||
copies: readonly GraphqlSourceCopy[],
|
||||
references: readonly JavaScriptSemanticReference[],
|
||||
): Generator<GraphqlSourceCopy | void, void> {
|
||||
const index = yield* graphqlSourceReferenceIndexSteps(copies);
|
||||
const uncertain = yield* uncertainGraphqlSourceBindingsSteps(
|
||||
index,
|
||||
references,
|
||||
);
|
||||
for (const copy of copies)
|
||||
yield copy.dependencies.every(({ bindingId }) => !uncertain.has(bindingId))
|
||||
? copy
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const graphqlOperationFromTemplate = (
|
||||
node: t.TemplateLiteral,
|
||||
): JavaScriptRpcEndpoint | undefined => {
|
||||
// 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;
|
||||
};
|
||||
|
||||
export const createJavaScriptGraphqlScan = (
|
||||
index: JavaScriptBindingIndex,
|
||||
): JavaScriptRpcScan => {
|
||||
const bodies = new WeakSet<t.Node>();
|
||||
const copied = new WeakMap<t.ObjectExpression, Set<string>>();
|
||||
const copies: GraphqlSourceCopy[] = [];
|
||||
const sources: {
|
||||
readonly source: t.ObjectExpression;
|
||||
readonly node: t.StringLiteral;
|
||||
readonly operation: JavaScriptRpcEndpoint;
|
||||
}[] = [];
|
||||
const retainCopy = ({ source, operations }: GraphqlSourceCopy) => {
|
||||
const keys = copied.get(source) ?? new Set<string>();
|
||||
for (const operation of operations)
|
||||
keys.add(graphqlOperationKey(operation));
|
||||
copied.set(source, keys);
|
||||
};
|
||||
return {
|
||||
inspect: (node: t.Node): JavaScriptRpcEndpoint | undefined => {
|
||||
if (t.isStringLiteral(node))
|
||||
return bodies.has(node)
|
||||
? undefined
|
||||
: graphqlOperationFromText(node.value);
|
||||
if (t.isTemplateLiteral(node)) return graphqlOperationFromTemplate(node);
|
||||
if (!t.isObjectExpression(node)) return undefined;
|
||||
const operation = graphqlOperationFromDocumentNode(node);
|
||||
if (semanticFinalObjectProperty(node, "loc") !== undefined) {
|
||||
const operations = graphqlDocumentOperations(node, operation);
|
||||
const source =
|
||||
operations.length === 0
|
||||
? undefined
|
||||
: graphqlDocumentSource(node, index);
|
||||
if (source !== undefined) {
|
||||
copies.push({ ...source, operations });
|
||||
}
|
||||
}
|
||||
const owned = graphqlSourceBodyLiterals(node);
|
||||
if (owned !== undefined) {
|
||||
for (const body of owned) bodies.add(body);
|
||||
const body = semanticFinalObjectProperty(node, "body");
|
||||
const recovered = t.isStringLiteral(body)
|
||||
? graphqlOperationFromText(body.value)
|
||||
: undefined;
|
||||
if (t.isStringLiteral(body) && recovered !== undefined)
|
||||
sources.push({ source: node, node: body, operation: recovered });
|
||||
}
|
||||
return operation;
|
||||
},
|
||||
*finish() {
|
||||
if (sources.length > 0 && copies.length > 0) {
|
||||
const references = yield* index.referencesSteps();
|
||||
for (const copy of provenGraphqlSourceCopiesSteps(copies, references)) {
|
||||
if (copy !== undefined) retainCopy(copy);
|
||||
yield;
|
||||
}
|
||||
}
|
||||
for (const { source, node, operation } of sources)
|
||||
yield copied.get(source)?.has(graphqlOperationKey(operation))
|
||||
? undefined
|
||||
: { node, ...operation };
|
||||
sources.length = 0;
|
||||
copies.length = 0;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -401,6 +401,255 @@ describe("GraphQL operation recognition", () => {
|
||||
});
|
||||
|
||||
describe("GraphQL document ownership", () => {
|
||||
it("retains the only operation in a standalone Source object", () => {
|
||||
expect(
|
||||
rpcEndpoints(`const source = {
|
||||
body: "query GetUser { user { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};`),
|
||||
).toEqual([
|
||||
{ kind: "network", value: "GetUser", mechanism: "graphql:query" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not deduplicate an unrelated Source against the same operation name", () => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
const source = {
|
||||
body: "query GetUser { user { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};
|
||||
const operation = {
|
||||
kind: "OperationDefinition", operation: "query",
|
||||
name: { kind: "Name", value: "GetUser" },
|
||||
};
|
||||
`),
|
||||
).toEqual([
|
||||
{ kind: "network", value: "GetUser", mechanism: "graphql:query" },
|
||||
{ kind: "network", value: "GetUser", mechanism: "graphql:query" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["referenced", "inline"])(
|
||||
"deduplicates a proven %s source reference",
|
||||
(representation) => {
|
||||
const source = `{
|
||||
body: "query GetUser { user { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
}`;
|
||||
const document = `export {}; const doc = { kind: "Document", definitions: [{
|
||||
kind: "OperationDefinition", operation: "query",
|
||||
name: { kind: "Name", value: "GetUser" },
|
||||
}], loc: { source: ${representation === "referenced" ? "source" : source} } };`;
|
||||
expect(
|
||||
rpcEndpoints(
|
||||
representation === "referenced"
|
||||
? `const source = ${source};` + document
|
||||
: document,
|
||||
),
|
||||
).toEqual([
|
||||
{ kind: "network", value: "GetUser", mechanism: "graphql:query" },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("retains source text that differs from its document operation", () => {
|
||||
const actual = rpcEndpoints(`
|
||||
const source = {
|
||||
body: "query SourceUser { user { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};
|
||||
const operation = {
|
||||
kind: "OperationDefinition", operation: "mutation",
|
||||
name: { kind: "Name", value: "UpdateUser" }, loc: { source },
|
||||
};
|
||||
`);
|
||||
expect(actual).toHaveLength(2);
|
||||
expect(actual).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "network", value: "SourceUser", mechanism: "graphql:query" },
|
||||
{ kind: "network", value: "UpdateUser", mechanism: "graphql:mutation" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("selects only the final known standalone Source body", () => {
|
||||
expect(
|
||||
rpcEndpoints(`const source = {
|
||||
body: "query Old { user { id } }",
|
||||
body: "query Current { user { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};`),
|
||||
).toEqual([
|
||||
{ kind: "network", value: "Current", mechanism: "graphql:query" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not prove source ownership from an uninitialized reference", () => {
|
||||
const actual = rpcEndpoints(`
|
||||
const operation = {
|
||||
kind: "OperationDefinition", operation: "query",
|
||||
name: { kind: "Name", value: "GetUser" }, loc: { source },
|
||||
};
|
||||
const source = {
|
||||
body: "query GetUser { user { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};
|
||||
`);
|
||||
expect(actual).toHaveLength(2);
|
||||
expect(actual.every(({ value }) => value === "GetUser")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GraphQL document container ownership", () => {
|
||||
it.each([
|
||||
["", "", 2],
|
||||
["export {};", "", 1],
|
||||
["function inspect() {", "}", 1],
|
||||
] as const)(
|
||||
"distinguishes global script bindings from private lexical objects: %s",
|
||||
(prefix, suffix, count) => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
${prefix}
|
||||
const source={body:"query GetUser { user { id } }",locationOffset:{line:1,column:1}};
|
||||
const doc={kind:"OperationDefinition",operation:"query",name:{kind:"Name",value:"GetUser"},loc:{source}};
|
||||
${suffix}
|
||||
`),
|
||||
).toHaveLength(count);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not prove a copy when a caller supplies direct eval", () => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
function inspect(eval) {
|
||||
const source={body:"query GetUser { user { id } }",locationOffset:{line:1,column:1}};
|
||||
const doc={kind:"OperationDefinition",operation:"query",name:{kind:"Name",value:"GetUser"},loc:{source}};
|
||||
eval("doc.loc.source={}");
|
||||
}
|
||||
`),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["export {}; ", "", 1],
|
||||
["export {}; ", "holder.docs[0].loc.source = {};", 2],
|
||||
["export ", "", 2],
|
||||
] as const)(
|
||||
"checks containing object and array ownership: %s %s",
|
||||
(prefix, effect, count) => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
${prefix}const holder = {docs:[{kind:"OperationDefinition",operation:"query",name:{kind:"Name",value:"GetUser"},loc:{source:{body:"query GetUser { user { id } }",locationOffset:{line:1,column:1}}}}]};
|
||||
${effect}
|
||||
`),
|
||||
).toHaveLength(count);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["", 'const indirect=globalThis; indirect.eval("loc.source={}");', 2],
|
||||
["export {};", 'globalThis.eval("loc.source={}");', 1],
|
||||
["export {};", 'eval("loc.source={}");', 2],
|
||||
] as const)(
|
||||
"respects lexical exposure for indirect evaluation: %s %s",
|
||||
(prefix, effect, count) => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
${prefix}
|
||||
const source={body:"query GetUser { user { id } }",locationOffset:{line:1,column:1}};
|
||||
const loc={source};
|
||||
const doc={kind:"OperationDefinition",operation:"query",name:{kind:"Name",value:"GetUser"},loc};
|
||||
${effect}
|
||||
`),
|
||||
).toHaveLength(count);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"doc.loc.source = {};",
|
||||
"mutate(doc);",
|
||||
"export { doc };",
|
||||
'globalThis.eval("source.body = replacement");',
|
||||
])("retains sources exposed through their document: %s", (effect) => {
|
||||
const actual = rpcEndpoints(`
|
||||
const source = {body:"query GetUser { user { id } }",locationOffset:{line:1,column:1}};
|
||||
const doc = {kind:"OperationDefinition",operation:"query",name:{kind:"Name",value:"GetUser"},loc:{source}};
|
||||
${effect}
|
||||
`);
|
||||
expect(actual).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each(["doc.loc.source = {};", "mutate(doc);", "export { doc };"])(
|
||||
"retains inline sources when the document escapes: %s",
|
||||
(effect) => {
|
||||
const actual = rpcEndpoints(`
|
||||
const doc = {kind:"OperationDefinition",operation:"query",name:{kind:"Name",value:"GetUser"},loc:{source:{body:"query GetUser { user { id } }",locationOffset:{line:1,column:1}}}};
|
||||
${effect}
|
||||
`);
|
||||
expect(actual).toHaveLength(2);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("GraphQL source alias effects", () => {
|
||||
it.each([
|
||||
'source.body = "query Changed { user { id } }";',
|
||||
"mutate(source);",
|
||||
'eval("source.body = replacement");',
|
||||
])(
|
||||
"retains Source observations after an unproven member effect: %s",
|
||||
(effect) => {
|
||||
const actual = rpcEndpoints(`
|
||||
const source = {
|
||||
body: "query GetUser { user { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};
|
||||
${effect}
|
||||
const operation = {
|
||||
kind: "OperationDefinition", operation: "query",
|
||||
name: { kind: "Name", value: "GetUser" }, loc: { source },
|
||||
};
|
||||
`);
|
||||
expect(actual.filter(({ value }) => value === "GetUser")).toHaveLength(2);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not prove source ownership through a changed location alias", () => {
|
||||
const actual = rpcEndpoints(`
|
||||
const source = {
|
||||
body: "query GetUser { user { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};
|
||||
const loc = { source };
|
||||
loc.source = {};
|
||||
const operation = {
|
||||
kind: "OperationDefinition", operation: "query",
|
||||
name: { kind: "Name", value: "GetUser" }, loc,
|
||||
};
|
||||
`);
|
||||
expect(actual).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each(["inline", "specifier"])(
|
||||
"retains separately exported sources: %s",
|
||||
(form) => {
|
||||
const actual = rpcEndpoints(`
|
||||
${form === "inline" ? "export" : ""} const source = {
|
||||
body: "query GetUser { user { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};
|
||||
${form === "specifier" ? "export { source };" : ""}
|
||||
const doc = { kind: "OperationDefinition", operation: "query",
|
||||
name: { kind: "Name", value: "GetUser" }, loc: { source } };
|
||||
`);
|
||||
expect(actual).toHaveLength(2);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("GraphQL document recognition", () => {
|
||||
it("recognizes precompiled GraphQL document objects", () => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
@@ -430,9 +679,10 @@ describe("GraphQL document ownership", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports a precompiled graphql-tag document once, not again from loc.source.body", () => {
|
||||
it("reports a private precompiled document once, not again from loc.source.body", () => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
export {};
|
||||
const doc = { kind: "Document", definitions: [{
|
||||
kind: "OperationDefinition",
|
||||
operation: "query",
|
||||
@@ -459,6 +709,7 @@ describe("GraphQL document ownership", () => {
|
||||
(sourceFields) => {
|
||||
expect(
|
||||
rpcEndpoints(`
|
||||
export {};
|
||||
const source = { ${sourceFields} };
|
||||
const operation = {
|
||||
kind: "OperationDefinition",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
JavaScriptSemanticAnalysisState,
|
||||
JavaScriptSemanticBindingState,
|
||||
JavaScriptSemanticScopeState,
|
||||
JavaScriptBindingIndex,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import {
|
||||
currentSemanticScope,
|
||||
@@ -243,46 +244,37 @@ export interface JavaScriptReceiverFacts {
|
||||
readonly rpcClientRoots: ReadonlySet<t.Node>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve only the lexical receiver identities needed to distinguish browser
|
||||
* `window.open` from XHR and global storage `.open` calls, configuration
|
||||
* `.get` reads from HTTP client calls, and tRPC client roots (excluding
|
||||
* overwritten bindings). This deliberately builds definitions without running
|
||||
* semantic value or provenance analysis.
|
||||
*/
|
||||
export const classifyParsedJavaScriptReceivers = (
|
||||
file: ParsedJavaScriptSource,
|
||||
): JavaScriptReceiverFacts =>
|
||||
completeSemanticSteps(classifyParsedJavaScriptReceiversSteps(file));
|
||||
|
||||
/** Classify receivers while yielding during lexical and AST scans. */
|
||||
export function* classifyParsedJavaScriptReceiversSteps(
|
||||
file: ParsedJavaScriptSource,
|
||||
): Generator<void, JavaScriptReceiverFacts> {
|
||||
const state = createState(file.program);
|
||||
yield* collectDefinitionsSteps(file.program, state);
|
||||
return yield* classifyDefinitionReceiversSteps(file.program, state);
|
||||
export interface JavaScriptReceiverIndex extends JavaScriptBindingIndex {
|
||||
readonly receivers: JavaScriptReceiverFacts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index bindings and classify receivers from one definitions pass. Receiver
|
||||
* facts read the definitions-only phase, so they are classified before the
|
||||
* same state is extended with ambient writes.
|
||||
* facts read the definitions-only phase. Consumers request ambient writes and
|
||||
* lexical references only when needed; each optional index is built once.
|
||||
*/
|
||||
export function* indexJavaScriptBindingsWithReceiversSteps(
|
||||
program: t.Program,
|
||||
): Generator<
|
||||
void,
|
||||
{
|
||||
readonly bindings: JavaScriptSemanticAnalysisState;
|
||||
readonly receivers: JavaScriptReceiverFacts;
|
||||
}
|
||||
> {
|
||||
): Generator<void, JavaScriptReceiverIndex> {
|
||||
const state = createState(program);
|
||||
yield* collectDefinitionsSteps(program, state);
|
||||
const receivers = yield* classifyDefinitionReceiversSteps(program, state);
|
||||
yield* indexJavaScriptAmbientWritesSteps(program, state);
|
||||
return { bindings: state, receivers };
|
||||
let ambientIndexed = false;
|
||||
let references: readonly JavaScriptSemanticReference[] | undefined;
|
||||
return {
|
||||
sourceType: program.sourceType,
|
||||
bindings: state,
|
||||
receivers,
|
||||
*indexAmbientWritesSteps() {
|
||||
if (ambientIndexed) return;
|
||||
yield* indexJavaScriptAmbientWritesSteps(program, state);
|
||||
ambientIndexed = true;
|
||||
},
|
||||
*referencesSteps() {
|
||||
references ??= yield* collectSemanticReferencesSteps(program, state);
|
||||
return references;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function* classifyDefinitionReceiversSteps(
|
||||
|
||||
@@ -5,15 +5,47 @@ import {
|
||||
analyzeParsedJavaScriptSemanticsSteps,
|
||||
classifyParsedJavaScriptElectronBindings,
|
||||
classifyParsedJavaScriptElectronBindingsSteps,
|
||||
classifyParsedJavaScriptReceivers,
|
||||
classifyParsedJavaScriptReceiversSteps,
|
||||
indexJavaScriptBindingsWithReceiversSteps,
|
||||
} from "./javascriptSemanticAnalysis.js";
|
||||
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
|
||||
import { createJavaScriptGraphqlScan } from "./javascriptGraphqlOperations.js";
|
||||
import {
|
||||
completeSemanticSteps,
|
||||
traverseJavaScriptAst,
|
||||
traverseJavaScriptAstSteps,
|
||||
} from "./javascriptSemanticTraversal.js";
|
||||
|
||||
it.each([false, true])(
|
||||
"checkpoints suppressed GraphQL sources, including alias proof (aliases: %s)",
|
||||
(aliased) => {
|
||||
const count = 1100;
|
||||
const source = Array.from({ length: count }, (_, index) => {
|
||||
const value = `{body:"query Q${index} { id }",locationOffset:{line:1,column:1}}`;
|
||||
const declaration = aliased ? `const source${index}=${value};` : "";
|
||||
return `${declaration}const doc${index}={kind:"OperationDefinition",operation:"query",name:{kind:"Name",value:"Q${index}"},loc:{source:${aliased ? `source${index}` : value}}};`;
|
||||
}).join("\n");
|
||||
const parsed = parseJavaScriptSource(source, "sources.mjs");
|
||||
if (parsed === null) throw new Error("Expected parsed source");
|
||||
const index = completeSemanticSteps(
|
||||
indexJavaScriptBindingsWithReceiversSteps(parsed.program),
|
||||
);
|
||||
const scan = createJavaScriptGraphqlScan(index);
|
||||
let operations = 0;
|
||||
traverseJavaScriptAst(parsed.program, {
|
||||
enter: (node) => {
|
||||
if (scan.inspect(node) !== undefined) operations += 1;
|
||||
},
|
||||
});
|
||||
expect(operations).toBe(count);
|
||||
let checkpoints = 0;
|
||||
for (const finding of scan.finish()) {
|
||||
expect(finding).toBeUndefined();
|
||||
checkpoints += 1;
|
||||
}
|
||||
expect(checkpoints).toBeGreaterThanOrEqual(count);
|
||||
},
|
||||
);
|
||||
|
||||
const source = `
|
||||
import { readFile } from "node:fs";
|
||||
const shared = { token: "TOKEN" };
|
||||
@@ -110,15 +142,14 @@ new NativeWindow();
|
||||
`;
|
||||
const parsed = parseJavaScriptSource(text, "receivers.js");
|
||||
if (parsed === null) throw new Error("Expected parsed source");
|
||||
const openSteps = classifyParsedJavaScriptReceiversSteps(parsed);
|
||||
const openSteps = indexJavaScriptBindingsWithReceiversSteps(parsed.program);
|
||||
let openStep = openSteps.next();
|
||||
expect(openStep.done).toBe(false);
|
||||
while (openStep.done !== true) openStep = openSteps.next();
|
||||
expect([...openStep.value.openReceivers]).toEqual([
|
||||
expect([...openStep.value.receivers.openReceivers]).toEqual([
|
||||
[text.indexOf('ambient.open("/local")'), "local"],
|
||||
[text.indexOf('ambient.open("/global")'), "window"],
|
||||
]);
|
||||
expect(openStep.value).toEqual(classifyParsedJavaScriptReceivers(parsed));
|
||||
|
||||
const electronSteps = classifyParsedJavaScriptElectronBindingsSteps(parsed);
|
||||
let electronStep = electronSteps.next();
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
JavaScriptSemanticModuleLink,
|
||||
JavaScriptSemanticCallable,
|
||||
JavaScriptSemanticScope,
|
||||
JavaScriptSemanticReference,
|
||||
} from "./javascriptSemanticIr.js";
|
||||
import type { JavaScriptModuleResolutionMode } from "./javascriptModuleResolutionMode.js";
|
||||
|
||||
@@ -109,6 +110,14 @@ export interface JavaScriptSemanticAnalysisState {
|
||||
readonly ambientWriteEffects: JavaScriptSemanticMemberEffect[];
|
||||
}
|
||||
|
||||
/** One parsed source owns its optional indexes and their per-source lifetime. */
|
||||
export interface JavaScriptBindingIndex {
|
||||
readonly sourceType: t.Program["sourceType"];
|
||||
readonly bindings: JavaScriptSemanticAnalysisState;
|
||||
indexAmbientWritesSteps(): Generator<void, void>;
|
||||
referencesSteps(): Generator<void, readonly JavaScriptSemanticReference[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return one unconditional source initializer for a declared lexical binding.
|
||||
* Repeated variable declarations without another initializer do not write it.
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
inspectRouteProperty,
|
||||
inspectRoleProperty,
|
||||
inspectRpcNode,
|
||||
finishRpcScanSteps,
|
||||
indexVitePreloadDependencyTable,
|
||||
} from "./javascriptStaticAnalysisCalls.js";
|
||||
import {
|
||||
@@ -81,8 +82,9 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
|
||||
): Generator<void, JavaScriptStaticAnalysis> {
|
||||
const accumulator = createJavaScriptAnalysisAccumulator(source.length);
|
||||
// One definitions pass serves receiver classification and binding lookup.
|
||||
const { bindings, receivers } =
|
||||
yield* indexJavaScriptBindingsWithReceiversSteps(file.program);
|
||||
const index = yield* indexJavaScriptBindingsWithReceiversSteps(file.program);
|
||||
const { bindings, receivers } = index;
|
||||
yield* index.indexAmbientWritesSteps();
|
||||
const electronIdentity = yield* indexJavaScriptElectronIdentitySteps(
|
||||
file.program,
|
||||
bindings,
|
||||
@@ -95,7 +97,7 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
|
||||
vitePreloadDependencyTable: indexVitePreloadDependencyTable(file.program),
|
||||
openReceiverFacts: receivers.openReceivers,
|
||||
electronBindings: electronIdentity.bindings,
|
||||
rpcScan: createJavaScriptRpcScan(source, receivers.rpcClientRoots),
|
||||
rpcScan: createJavaScriptRpcScan(source, receivers.rpcClientRoots, index),
|
||||
},
|
||||
bindings,
|
||||
);
|
||||
@@ -122,6 +124,7 @@ function* traverseStaticSourceSteps(
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
yield* finishRpcScanSteps(findings);
|
||||
}
|
||||
|
||||
const finalizeStaticAnalysis = (
|
||||
|
||||
@@ -454,6 +454,16 @@ export const inspectRpcNode = (node: t.Node, context: FindingContext): void => {
|
||||
addEndpoint(context, { node, kind: "network", ...rpc });
|
||||
};
|
||||
|
||||
/** Resolve deferred Source bodies after their document owners have been seen. */
|
||||
export function* finishRpcScanSteps(
|
||||
context: FindingContext,
|
||||
): Generator<void, void> {
|
||||
for (const rpc of context.rpcScan.finish()) {
|
||||
if (rpc !== undefined) addEndpoint(context, { kind: "network", ...rpc });
|
||||
yield;
|
||||
}
|
||||
}
|
||||
|
||||
export const inspectRouteProperty = (
|
||||
node: t.ObjectProperty,
|
||||
context: FindingContext,
|
||||
|
||||
@@ -8,6 +8,20 @@ import { createWebTextArtifact } from "./webContentArtifact.js";
|
||||
|
||||
const origin = "https://app.example.test";
|
||||
|
||||
it("retains standalone GraphQL Source operations in captured scripts", () => {
|
||||
const result = analyzeCapturedWebBundle(
|
||||
inspection(`
|
||||
const source = {
|
||||
body: "query GetUser { user { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};
|
||||
`),
|
||||
);
|
||||
expect(result.observations.endpoints).toEqual([
|
||||
expect.objectContaining({ value: "GetUser", mechanism: "graphql:query" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
kind: "recovered",
|
||||
|
||||
@@ -5,7 +5,8 @@ import { compositeKey } from "./unicodeCodePointOrder.js";
|
||||
import type { WebPageInspection } from "./browserObservationSchemas.js";
|
||||
import type { WebBundleAnalysis } from "./webBundleAnalysis.js";
|
||||
import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js";
|
||||
import { classifyParsedJavaScriptReceivers } from "./javascript/javascriptSemanticAnalysis.js";
|
||||
import { indexJavaScriptBindingsWithReceiversSteps } from "./javascript/javascriptSemanticAnalysis.js";
|
||||
import { completeSemanticSteps } from "./javascript/javascriptSemanticTraversal.js";
|
||||
import type { JavaScriptOpenReceiverFact } from "./javascript/javascriptSemanticAnalysis.js";
|
||||
import { semanticStaticPropertyName } from "./javascript/javascriptAstValues.js";
|
||||
import {
|
||||
@@ -61,11 +62,15 @@ export const analyzeScript = (
|
||||
if ((file.errors?.length ?? 0) > 0)
|
||||
accumulator.recoveredScripts.push(script.script_key);
|
||||
detectVendorFingerprints(script, accumulator);
|
||||
const receivers = classifyParsedJavaScriptReceivers(file);
|
||||
const index = completeSemanticSteps(
|
||||
indexJavaScriptBindingsWithReceiversSteps(file.program),
|
||||
);
|
||||
const { receivers } = index;
|
||||
const openReceiverFacts = receivers.openReceivers;
|
||||
const rpc = createJavaScriptRpcScan(
|
||||
script.source.artifact.text,
|
||||
receivers.rpcClientRoots,
|
||||
index,
|
||||
);
|
||||
traverseJavaScriptAst(file, {
|
||||
enter: (node) => {
|
||||
@@ -83,6 +88,16 @@ export const analyzeScript = (
|
||||
});
|
||||
},
|
||||
});
|
||||
for (const endpoint of rpc.finish())
|
||||
if (endpoint !== undefined)
|
||||
addFinding({
|
||||
collection: accumulator.endpoints,
|
||||
script,
|
||||
rawValue: endpoint.value,
|
||||
mechanism: endpoint.mechanism,
|
||||
node: endpoint.node,
|
||||
accumulator,
|
||||
});
|
||||
};
|
||||
|
||||
const inspectNode = (
|
||||
|
||||
@@ -8,6 +8,104 @@ import {
|
||||
} from "../../support/cli/javascriptApplication.js";
|
||||
import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js";
|
||||
|
||||
cliTest.for(["CLI", "stdio MCP"] as const)(
|
||||
"retains standalone GraphQL sources and only suppresses owned copies through %s",
|
||||
async (surface, { cli }) => {
|
||||
const root = await createTestTempDirectory("rea-graphql-source-");
|
||||
await writeFixtureFiles(root, {
|
||||
"classic.js": `
|
||||
const document = {
|
||||
kind: "OperationDefinition", operation: "query",
|
||||
name: { kind: "Name", value: "ClassicSource" },
|
||||
loc: { source: {
|
||||
body: "query ClassicSource { viewer { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
} },
|
||||
};
|
||||
`,
|
||||
"entry.mjs": `
|
||||
const source = {
|
||||
body: "query Standalone { viewer { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};
|
||||
const compiledSource = {
|
||||
body: "query Compiled { viewer { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};
|
||||
const document = { kind: "Document", definitions: [{
|
||||
kind: "OperationDefinition", operation: "query",
|
||||
name: { kind: "Name", value: "Compiled" },
|
||||
}], loc: { source: compiledSource } };
|
||||
const independentSource = {
|
||||
body: "query Independent { viewer { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};
|
||||
const unrelated = {
|
||||
kind: "OperationDefinition", operation: "query",
|
||||
name: { kind: "Name", value: "Independent" },
|
||||
};
|
||||
const changedSource = {
|
||||
body: "query ChangedSource { viewer { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};
|
||||
const location = { source: changedSource };
|
||||
location.source = {};
|
||||
const changedDocument = {
|
||||
kind: "OperationDefinition", operation: "query",
|
||||
name: { kind: "Name", value: "ChangedSource" }, loc: location,
|
||||
};
|
||||
const exposedSource = {
|
||||
body: "query ExposedSource { viewer { id } }",
|
||||
locationOffset: { line: 1, column: 1 },
|
||||
};
|
||||
const exposedDocument = {
|
||||
kind: "OperationDefinition", operation: "query",
|
||||
name: { kind: "Name", value: "ExposedSource" },
|
||||
loc: { source: exposedSource },
|
||||
};
|
||||
exposedDocument.loc.source = {};
|
||||
`,
|
||||
});
|
||||
const result = await analyzeJavaScriptDirectory(surface, cli, root);
|
||||
const operations = result.graph.nodes
|
||||
.filter((node) => node.kind === "endpoint")
|
||||
.flatMap((node) => node.observations)
|
||||
.filter(({ properties }) => properties.mechanism === "graphql:query");
|
||||
expect(operations.map(({ properties }) => properties.value).sort()).toEqual(
|
||||
[
|
||||
"ChangedSource",
|
||||
"ChangedSource",
|
||||
"ClassicSource",
|
||||
"ClassicSource",
|
||||
"Compiled",
|
||||
"ExposedSource",
|
||||
"ExposedSource",
|
||||
"Independent",
|
||||
"Independent",
|
||||
"Standalone",
|
||||
],
|
||||
);
|
||||
for (const observation of operations)
|
||||
expect(observation.evidence).toMatchObject({
|
||||
authority: "ast-static-analysis",
|
||||
state: "observed",
|
||||
artifact: { available: true },
|
||||
location: {
|
||||
available: true,
|
||||
value: {
|
||||
kind: "source-range",
|
||||
source:
|
||||
observation.properties.value === "ClassicSource"
|
||||
? "classic.js"
|
||||
: "entry.mjs",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.statistics.parse_failures).toBe(0);
|
||||
expect(result.graph.coverage.status).toBe("complete");
|
||||
},
|
||||
);
|
||||
|
||||
cliTest.for(["CLI", "stdio MCP"] as const)(
|
||||
"retains only final known RPC object properties through %s",
|
||||
async (surface, { cli }) => {
|
||||
|
||||
Reference in new issue
Block a user