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

+7 -2
View File
@@ -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",
+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,
};
};
@@ -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,
+14
View File
@@ -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",
+17 -2
View File
@@ -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 }) => {