diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index e66d52d724..ac87b51834 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -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, diff --git a/src/domain/javascript/javascriptAstValues.ts b/src/domain/javascript/javascriptAstValues.ts index 8be3a5e838..cd2b5d754f 100644 --- a/src/domain/javascript/javascriptAstValues.ts +++ b/src/domain/javascript/javascriptAstValues.ts @@ -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, diff --git a/src/domain/javascript/javascriptGraphqlOperations.ts b/src/domain/javascript/javascriptGraphqlOperations.ts new file mode 100644 index 0000000000..820379b784 --- /dev/null +++ b/src/domain/javascript/javascriptGraphqlOperations.ts @@ -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(); + 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 = 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(); + 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 | 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>; + readonly neighbors: Map>; + readonly byName: Map>; +} + +function* graphqlSourceReferenceIndexSteps( + copies: readonly GraphqlSourceCopy[], +): Generator { + const allowed = new Map>(); + const neighbors = new Map>(); + const byName = new Map>(); + 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(); + if (location !== undefined) sites.add(JSON.stringify(location)); + allowed.set(bindingId, sites); + const names = byName.get(name) ?? new Set(); + names.add(bindingId); + byName.set(name, names); + if (first === undefined || first === bindingId) continue; + const left = neighbors.get(first) ?? new Set(); + const right = neighbors.get(bindingId) ?? new Set(); + 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> { + const { allowed, neighbors, byName } = index; + const uncertain = new Set(); + 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 { + 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(); + const copied = new WeakMap>(); + 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(); + 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; + }, + }; +}; diff --git a/src/domain/javascript/javascriptRpcEndpoints.test.ts b/src/domain/javascript/javascriptRpcEndpoints.test.ts index e0f049a388..104c0e1be4 100644 --- a/src/domain/javascript/javascriptRpcEndpoints.test.ts +++ b/src/domain/javascript/javascriptRpcEndpoints.test.ts @@ -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", diff --git a/src/domain/javascript/javascriptRpcEndpoints.ts b/src/domain/javascript/javascriptRpcEndpoints.ts index 08e6f3aa61..a7af6d2461 100644 --- a/src/domain/javascript/javascriptRpcEndpoints.ts +++ b/src/domain/javascript/javascriptRpcEndpoints.ts @@ -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(); - 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 = 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, + 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(); + 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, }; }; diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index df6f4d21b0..5d63b7699a 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -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; } -/** - * 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 { - 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 { 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( diff --git a/src/domain/javascript/javascriptSemanticAnalysisSteps.test.ts b/src/domain/javascript/javascriptSemanticAnalysisSteps.test.ts index 1afd5e48bd..175ca23526 100644 --- a/src/domain/javascript/javascriptSemanticAnalysisSteps.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysisSteps.test.ts @@ -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(); diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index 96fa37d679..6f26f68b44 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -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; + referencesSteps(): Generator; +} + /** * Return one unconditional source initializer for a declared lexical binding. * Repeated variable declarations without another initializer do not write it. diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index 0b87da8645..49158abe68 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -25,6 +25,7 @@ import { inspectRouteProperty, inspectRoleProperty, inspectRpcNode, + finishRpcScanSteps, indexVitePreloadDependencyTable, } from "./javascriptStaticAnalysisCalls.js"; import { @@ -81,8 +82,9 @@ export function* analyzeParsedJavaScriptStaticSourceSteps( ): Generator { 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 = ( diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 16a123b8b5..008da7dd47 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -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 { + for (const rpc of context.rpcScan.finish()) { + if (rpc !== undefined) addEndpoint(context, { kind: "network", ...rpc }); + yield; + } +} + export const inspectRouteProperty = ( node: t.ObjectProperty, context: FindingContext, diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index ed0a151eb0..cc137be20e 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -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", diff --git a/src/domain/webBundleAnalyzerInspection.ts b/src/domain/webBundleAnalyzerInspection.ts index 93a4f9c708..9fd7599976 100644 --- a/src/domain/webBundleAnalyzerInspection.ts +++ b/src/domain/webBundleAnalyzerInspection.ts @@ -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 = ( diff --git a/tests/acceptance/applications/javascriptRpcPropertyOwnershipCli.test.ts b/tests/acceptance/applications/javascriptRpcPropertyOwnershipCli.test.ts index a369e7519d..205a05a28c 100644 --- a/tests/acceptance/applications/javascriptRpcPropertyOwnershipCli.test.ts +++ b/tests/acceptance/applications/javascriptRpcPropertyOwnershipCli.test.ts @@ -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 }) => {