diff --git a/src/domain/javascript/electronStaticAnalysisBrowser.ts b/src/domain/javascript/electronStaticAnalysisBrowser.ts index cc24eff53d..a164219f12 100644 --- a/src/domain/javascript/electronStaticAnalysisBrowser.ts +++ b/src/domain/javascript/electronStaticAnalysisBrowser.ts @@ -63,7 +63,7 @@ const needsConstantOptions = (node: t.NewExpression): boolean => { const lookup = staticObjectProperty(options, "webPreferences"); return ( lookup.status === "explicit" && - t.isIdentifier(unwrapJavaScriptExpression(lookup.property.value).node) + t.isIdentifier(unwrapJavaScriptExpression(lookup.value).node) ); }; @@ -83,7 +83,7 @@ export function* finishElectronBrowserWindowsSteps( const lookup = staticObjectProperty(options, "webPreferences"); return ( lookup.status === "explicit" && - hasElectronOptionObjectCandidate(lookup.property.value, index) + hasElectronOptionObjectCandidate(lookup.value, index) ); }) ) @@ -117,7 +117,7 @@ const inspectBrowserWindow = ( const lookup = staticObjectProperty(resolvedOptions, "webPreferences"); const declaredPreferences = lookup.status === "explicit" - ? unwrapJavaScriptExpression(lookup.property.value).node + ? unwrapJavaScriptExpression(lookup.value).node : undefined; const resolvedPreferences = (index && resolveElectronOptionObject(declaredPreferences, node, index)) ?? @@ -215,14 +215,14 @@ const collectWebPreferences = ( const effectivePreload = staticObjectProperty(object, "preload"); const preloadPath = effectivePreload.status === "explicit" - ? staticPath(effectivePreload.property.value, pathIntrinsics) + ? staticPath(effectivePreload.value, pathIntrinsics) : undefined; const preload: ElectronBrowserWindowPreload = effectivePreload.status === "explicit" && preloadPath !== undefined ? { preload_path: preloadPath, preload_resolution_context: staticPathResolutionContext( - effectivePreload.property.value, + effectivePreload.value, pathIntrinsics, ), } @@ -344,8 +344,8 @@ const inspectUtilityProcess = ( // A nullish serviceName selects Electron's default display name. const declaredName = serviceNameProperty.status === "explicit" && - !shapes.isNullish(serviceNameProperty.property.value) - ? serviceNameProperty.property.value + !shapes.isNullish(serviceNameProperty.value) + ? serviceNameProperty.value : undefined; const serviceName = declaredName === undefined diff --git a/src/domain/javascript/javascriptAstValues.test.ts b/src/domain/javascript/javascriptAstValues.test.ts index 7cbd87a4d4..1530850779 100644 --- a/src/domain/javascript/javascriptAstValues.test.ts +++ b/src/domain/javascript/javascriptAstValues.test.ts @@ -5,11 +5,95 @@ import { semanticMemberChain, semanticStaticPropertyKey, staticObjectProperty, + staticObjectPropertyReader, staticObjectPropertyNames, } from "./javascriptAstValues.js"; import { SEMANTIC_EXPRESSION_DEPTH_LIMIT } from "./javascriptSemanticResourceLimits.js"; -it("copies ordinary members without proving requested methods or getter effects", () => { +it("keeps string spread indices separate from named descriptor fields", () => { + for (const text of ["", "x", "😀", "x".repeat(1_000_000)]) { + const original = t.objectProperty( + t.identifier("value"), + t.identifier("sender"), + ); + const object = t.objectExpression([ + original, + t.spreadElement(t.stringLiteral(text)), + ]); + const read = staticObjectPropertyReader(object); + expect(read("value")).toEqual({ + status: "explicit", + property: original, + value: original.value, + }); + expect(read("get")).toEqual({ status: "missing" }); + expect(read("set")).toEqual({ status: "missing" }); + expect(read("0").status).toBe(text.length > 0 ? "dynamic" : "missing"); + expect(read("1").status).toBe(text.length > 1 ? "dynamic" : "missing"); + for (const key of ["00", "-0", "1.0", "length"]) + expect(read(key)).toEqual({ status: "missing" }); + // Nonempty strings keep their indexed inventory partial without expanding + // it into character-sized AST nodes or a potentially huge key-name array. + expect(staticObjectPropertyNames(object)).toEqual({ + status: text.length > 0 ? "partial" : "complete", + names: ["value"], + }); + } +}); + +it("keeps numeric overwrite order and constant template string spread keys", () => { + const property = t.objectProperty( + t.stringLiteral("0"), + t.identifier("original"), + ); + const spread = t.spreadElement( + t.templateLiteral([t.templateElement({ raw: "x", cooked: "x" }, true)], []), + ); + expect( + staticObjectProperty(t.objectExpression([property, spread]), "0").status, + ).toBe("dynamic"); + expect( + staticObjectProperty(t.objectExpression([spread, property]), "0"), + ).toEqual({ status: "explicit", property, value: property.value }); +}); + +it("keeps literal array indices, holes and unknown iterable suffixes separate from names", () => { + const value = t.objectProperty(t.identifier("value"), t.identifier("sender")); + for (const elements of [ + [], + [null, null], + [t.numericLiteral(1)], + [null, t.numericLiteral(1)], + [null, t.spreadElement(t.identifier("input"))], + ]) { + const object = t.objectExpression([ + value, + t.spreadElement(t.arrayExpression(elements)), + ]); + const read = staticObjectPropertyReader(object); + expect(read("value")).toEqual({ + status: "explicit", + property: value, + value: value.value, + }); + expect(read("get")).toEqual({ status: "missing" }); + expect(read("set")).toEqual({ status: "missing" }); + expect(read("0").status).toBe(elements[0] == null ? "missing" : "dynamic"); + expect(read("1").status).toBe(elements[1] == null ? "missing" : "dynamic"); + expect(read("100").status).toBe( + elements.some((entry) => t.isSpreadElement(entry)) + ? "dynamic" + : "missing", + ); + expect(read("length")).toEqual({ status: "missing" }); + expect(staticObjectPropertyNames(object)).toEqual({ + status: elements.some((entry) => entry != null) ? "partial" : "complete", + names: ["value"], + }); + } +}); + +it("preserves ordinary method data values without executing getter effects", () => { const method = (name: string, computed = false) => t.objectMethod( "method", @@ -33,7 +117,7 @@ it("copies ordinary members without proving requested methods or getter effects" for (const [member, status] of [ [method("helper"), "explicit"], [setter, "explicit"], - [method("stdio"), "dynamic"], + [method("stdio"), "explicit"], [getter, "dynamic"], [method("key", true), "dynamic"], ] as const) { @@ -281,3 +365,62 @@ it("shares static member paths and stops before unadmitted path expansion", () = expect(semanticMemberChain(selected, () => ++admitted < 2)).toBeUndefined(); expect(admitted).toBe(2); }); + +it("retains exact ordinary method data nodes while keeping accessors unknown", () => { + const method = t.objectMethod( + "method", + t.identifier("value"), + [], + t.blockStatement([]), + ); + const getter = t.objectMethod( + "get", + t.identifier("value"), + [], + t.blockStatement([]), + ); + expect(staticObjectProperty(t.objectExpression([method]), "value")).toEqual({ + status: "explicit", + property: method, + value: method, + }); + expect(staticObjectProperty(t.objectExpression([getter]), "value")).toEqual({ + status: "dynamic", + }); +}); + +it("keeps primitive unary results propertyless and typeof results index-only", () => { + const value = t.objectProperty(t.identifier("value"), t.identifier("sender")); + const index = t.objectProperty( + t.stringLiteral("0"), + t.stringLiteral("prior"), + ); + for (const operator of ["+", "-", "~", "!", "void", "delete"] as const) { + const object = t.objectExpression([ + value, + index, + t.spreadElement(t.unaryExpression(operator, t.numericLiteral(1))), + ]); + expect(staticObjectProperty(object, "value")).toEqual({ + status: "explicit", + property: value, + value: value.value, + }); + expect(staticObjectProperty(object, "0")).toEqual({ + status: "explicit", + property: index, + value: index.value, + }); + } + const object = t.objectExpression([ + value, + index, + t.spreadElement(t.unaryExpression("typeof", t.identifier("input"))), + ]); + expect(staticObjectProperty(object, "value")).toEqual({ + status: "explicit", + property: value, + value: value.value, + }); + expect(staticObjectProperty(object, "0")).toEqual({ status: "dynamic" }); +}); diff --git a/src/domain/javascript/javascriptAstValues.ts b/src/domain/javascript/javascriptAstValues.ts index 9d59246c0a..3cade5b062 100644 --- a/src/domain/javascript/javascriptAstValues.ts +++ b/src/domain/javascript/javascriptAstValues.ts @@ -1,6 +1,7 @@ import * as t from "@babel/types"; import type { JavaScriptSemanticPrimitive } from "./javascriptSemanticValueTypes.js"; import { SEMANTIC_EXPRESSION_DEPTH_LIMIT } from "./javascriptSemanticResourceLimits.js"; +import { semanticArrayIndex } from "./javascriptSemanticPropertyPaths.js"; /** Type-only class fields neither initialize own slots nor shadow inherited members. */ export const isRuntimeJavaScriptClassProperty = ( @@ -160,25 +161,109 @@ export const isNullishJavaScriptSyntax = (node: t.Node): boolean => { ); }; +export type ObjectDataPropertyValue = + | t.ObjectProperty["value"] + | t.ObjectMethod; + +/** Preserve original data-value nodes; accessor reads remain unresolved. */ +export const objectDataPropertyValue = ( + property: t.ObjectProperty | t.ObjectMethod, +): ObjectDataPropertyValue | undefined => + t.isObjectProperty(property) + ? property.value + : property.kind === "method" + ? property + : undefined; + +const PROPERTYLESS_UNARY = new Set(["+", "-", "~", "!", "void", "delete"]); +const PROPERTYLESS_BINARY = new Set([ + "-", + "*", + "/", + "%", + "**", + "<<", + ">>", + ">>>", + "|", + "^", + "&", + "==", + "!=", + "===", + "!==", + "<", + "<=", + ">", + ">=", + "in", + "instanceof", +]); + +/** Operator results are primitive even when their value is not statically known. */ +const primitiveOwnKeys = ( + node: t.Node, + isNullish?: (node: t.Node) => boolean, +): "indices" | "none" | null => { + if ( + t.isStringLiteral(node) || + t.isTemplateLiteral(node) || + (t.isUnaryExpression(node) && node.operator === "typeof") || + (t.isBinaryExpression(node) && node.operator === "+") + ) + return "indices"; + return t.isNumericLiteral(node) || + t.isBooleanLiteral(node) || + t.isBigIntLiteral(node) || + t.isNullLiteral(node) || + (t.isUnaryExpression(node) && PROPERTYLESS_UNARY.has(node.operator)) || + t.isUpdateExpression(node) || + (t.isBinaryExpression(node) && PROPERTYLESS_BINARY.has(node.operator)) || + isNullish?.(node) === true + ? "none" + : null; +}; + type StaticObjectProperty = | { readonly status: "missing" | "dynamic" } - | { readonly status: "explicit"; readonly property: t.ObjectProperty }; + | { + readonly status: "explicit"; + readonly property: t.ObjectProperty | t.ObjectMethod; + readonly value: ObjectDataPropertyValue; + }; type StaticObjectMember = | { readonly kind: "unknown" } - | { readonly kind: "prototype-setter" } + | { + readonly kind: "prototype-setter"; + readonly prototype: "null" | "ignored" | "unknown"; + } + | { readonly kind: "primitive-indices"; readonly length: number | null } + | { + readonly kind: "array-indices"; + readonly elements: t.ArrayExpression["elements"]; + readonly firstSpread: number; + } | { readonly kind: "property"; readonly property: t.ObjectProperty | t.ObjectMethod; + readonly key: string | null; }; const UNKNOWN_STATIC_OBJECT_MEMBER: StaticObjectMember = { kind: "unknown" }; /** Object-literal prototype setters are never ordinary own-property writes. */ -export const isObjectPrototypeSetter = (property: t.ObjectProperty): boolean => +export const isObjectPrototypeSetter = ( + property: t.ObjectProperty | t.ObjectMethod, + key: string | null = semanticStaticPropertyKey( + property.key, + property.computed, + ), +): boolean => + t.isObjectProperty(property) && !property.computed && !property.shorthand && - semanticStaticPropertyKey(property.key, false) === "__proto__"; + key === "__proto__"; /** Expand only bounded literal spreads, preserving unknown writes in order. */ const appendStaticObjectMembers = ( @@ -189,13 +274,28 @@ const appendStaticObjectMembers = ( ): void => { for (const property of object.properties) { if (!t.isSpreadElement(property)) { - if (t.isObjectProperty(property) && isObjectPrototypeSetter(property)) { + const key = semanticStaticPropertyKey(property.key, property.computed); + if ( + t.isObjectProperty(property) && + isObjectPrototypeSetter(property, key) + ) { // A top-level setter changes lookup inheritance but is never an own // key; one copied through object spread has no effect on the target. - if (depth === 0) members.push({ kind: "prototype-setter" }); + if (depth === 0) { + const prototype = unwrapJavaScriptExpression(property.value).node; + const primitive = primitiveOwnKeys(prototype, isNullish); + members.push({ + kind: "prototype-setter", + prototype: t.isNullLiteral(prototype) + ? "null" + : primitive !== null + ? "ignored" + : "unknown", + }); + } continue; } - members.push({ kind: "property", property }); + members.push({ kind: "property", property, key }); continue; } const { node: spread, depth: wrappers } = unwrapJavaScriptExpression( @@ -216,12 +316,44 @@ const appendStaticObjectMembers = ( appendStaticObjectMembers(members, spread, nextDepth, isNullish); continue; } - // These primitive boxes have no own keys; strings can own index keys. + if (t.isArrayExpression(spread)) { + let firstSpread = -1; + let hasElements = false; + for (let index = 0; index < spread.elements.length; index++) { + const element = spread.elements[index]; + if (element == null) continue; + hasElements = true; + if (t.isSpreadElement(element)) { + firstSpread = index; + break; + } + } + // Literal arrays own only present indices. A spread can shift later + // indices, but never adds named keys to the array under construction. + if (hasElements) + members.push({ + kind: "array-indices", + elements: spread.elements, + firstSpread, + }); + continue; + } + const primitive = primitiveOwnKeys(spread, isNullish); + if (primitive === "indices") { + const literal = readExactJavaScriptLiteral(spread); + const length = + literal.found && typeof literal.value === "string" + ? literal.value.length + : null; + // Retain indexed writes without expanding strings or synthesizing properties. + if (length !== 0) members.push({ kind: "primitive-indices", length }); + continue; + } if ( - t.isBooleanLiteral(spread) || - t.isNumericLiteral(spread) || - t.isBigIntLiteral(spread) || - isNullish?.(property.argument) === true + primitive === "none" || + t.isFunctionExpression(spread) || + t.isArrowFunctionExpression(spread) || + t.isRegExpLiteral(spread) ) continue; members.push(UNKNOWN_STATIC_OBJECT_MEMBER); @@ -238,31 +370,77 @@ const staticObjectMembers = ( return members; }; +type StaticObjectPropertySource = + | readonly StaticObjectMember[] + | "missing" + | "dynamic"; + +const staticObjectPropertySource = ( + object: t.Node | undefined, + isNullish?: (node: t.Node) => boolean, +): StaticObjectPropertySource => + object === undefined + ? "missing" + : t.isObjectExpression(object) + ? staticObjectMembers(object, 0, isNullish) + : "dynamic"; + +const readStaticObjectProperty = ( + source: StaticObjectPropertySource, + name: string, +): StaticObjectProperty => { + if (typeof source === "string") return { status: source }; + let prototypeMaySupply = false; + for (let index = source.length - 1; index >= 0; index--) { + const member = source[index]!; + if (member.kind === "prototype-setter") { + prototypeMaySupply ||= member.prototype === "unknown"; + continue; + } + if (member.kind === "unknown") return { status: "dynamic" }; + if (member.kind === "primitive-indices") { + const index = semanticArrayIndex(name); + if (index !== null && (member.length === null || index < member.length)) + return { status: "dynamic" }; + continue; + } + if (member.kind === "array-indices") { + const index = semanticArrayIndex(name); + if ( + index !== null && + ((member.firstSpread >= 0 && index >= member.firstSpread) || + (index < member.elements.length && member.elements[index] != null)) + ) + return { status: "dynamic" }; + continue; + } + const { property, key } = member; + if (key === null) return { status: "dynamic" }; + if (key !== name) continue; + const value = objectDataPropertyValue(property); + return value === undefined + ? { status: "dynamic" } + : { status: "explicit", property, value }; + } + return prototypeMaySupply ? { status: "dynamic" } : { status: "missing" }; +}; + +/** Parse ordered members once for related property queries within one boundary. */ +export const staticObjectPropertyReader = ( + object: t.Node | undefined, + isNullish?: (node: t.Node) => boolean, +): ((name: string) => StaticObjectProperty) => { + const source = staticObjectPropertySource(object, isNullish); + return (name) => readStaticObjectProperty(source, name); +}; + /** Find the effective explicit property, preserving unresolved overrides. */ export const staticObjectProperty = ( object: t.Node | undefined, name: string, isNullish?: (node: t.Node) => boolean, -): StaticObjectProperty => { - if (object === undefined) return { status: "missing" }; - if (!t.isObjectExpression(object)) return { status: "dynamic" }; - let prototypeMaySupply = false; - for (const member of staticObjectMembers(object, 0, isNullish).toReversed()) { - if (member.kind === "prototype-setter") { - prototypeMaySupply = true; - continue; - } - if (member.kind === "unknown") return { status: "dynamic" }; - const { property } = member; - const key = semanticStaticPropertyKey(property.key, property.computed); - if (key === null) return { status: "dynamic" }; - if (key !== name) continue; - return t.isObjectProperty(property) - ? { status: "explicit", property } - : { status: "dynamic" }; - } - return prototypeMaySupply ? { status: "dynamic" } : { status: "missing" }; -}; +): StaticObjectProperty => + readStaticObjectProperty(staticObjectPropertySource(object, isNullish), name); export type StaticObjectPropertyNames = | { readonly status: "missing" | "dynamic" } @@ -280,14 +458,17 @@ export const staticObjectPropertyNames = ( let complete = true; for (const member of staticObjectMembers(object, 0, isNullish)) { if (member.kind === "prototype-setter") continue; - if (member.kind === "unknown") { + if ( + member.kind === "unknown" || + member.kind === "primitive-indices" || + member.kind === "array-indices" + ) { + // Indexed spread keys remain unenumerated; never claim a complete name + // inventory or expand the input into a potentially much larger key set. complete = false; continue; } - const key = semanticStaticPropertyKey( - member.property.key, - member.property.computed, - ); + const { key } = member; if (key === null) { complete = false; continue; @@ -305,7 +486,7 @@ export const semanticFinalObjectProperty = ( key: string, ): t.Node | undefined => { const property = staticObjectProperty(node, key); - return property.status === "explicit" ? property.property.value : undefined; + return property.status === "explicit" ? property.value : undefined; }; export const semanticFinalObjectStringProperty = ( diff --git a/src/domain/javascript/javascriptRpcEndpoints.test.ts b/src/domain/javascript/javascriptRpcEndpoints.test.ts index 65d15db1d6..763f78b74a 100644 --- a/src/domain/javascript/javascriptRpcEndpoints.test.ts +++ b/src/domain/javascript/javascriptRpcEndpoints.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { runInNewContext } from "node:vm"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; @@ -787,14 +788,32 @@ it("keeps named spreads unknown without proving nullish aliases", () => { ).toEqual([]); }); -it("skips propertyless literal spreads while retaining string and unknown spreads", () => { - expect( - rpcEndpoints(` +it("preserves named fields across primitive and string spreads while retaining unknown overrides", () => { + const source = ` httpLink({url:"/primitives",...false,...42,...1n,[void effect()]:0}); httpLink({url:"/string",..."ab"}); + httpLink({url:"/array",...[1]}); httpLink({url:"/unknown",...options}); - `), - ).toEqual([ + `; + const observed: string[] = []; + runInNewContext( + source, + { + httpLink: (options: { url: string }) => observed.push(options.url), + effect: () => {}, + options: { url: "/overwritten" }, + }, + { timeout: 1_000 }, + ); + expect(observed).toEqual([ + "/primitives", + "/string", + "/array", + "/overwritten", + ]); + expect(rpcEndpoints(source)).toEqual([ { kind: "network", value: "/primitives", mechanism: "trpc:link:httpLink" }, + { kind: "network", value: "/string", mechanism: "trpc:link:httpLink" }, + { kind: "network", value: "/array", mechanism: "trpc:link:httpLink" }, ]); }); diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts index 771fe5887b..ca6a1cbbd2 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts @@ -6,7 +6,11 @@ import { indexJavaScriptBindingsSteps, indexJavaScriptBindingsWithReceiversSteps, } from "./javascriptSemanticAnalysis.js"; -import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; +import { + completeSemanticSteps, + traverseJavaScriptAst, +} from "./javascriptSemanticTraversal.js"; +import { createGlobalReferenceReader } from "./javascriptSemanticAmbientWrites.js"; import { parseJavaScriptSource } from "./javascriptSourceParser.js"; it("keeps wrapper module aliases distinct from global-object module properties", () => { @@ -240,3 +244,96 @@ it("indexes unknown-key stores of fresh objects in linear stored-value work", () // earlier value from every later store grows at least quadratically. expect(work(64)).toBeLessThan(3 * work(32)); }); + +it.each([ + [ + "before and after a direct replacement", + "inspect(Object.defineProperty);Object.defineProperty=()=>{};inspect(Object.defineProperty);", + ["unmodified", "replacement"], + ], + [ + "an unrelated property", + "Object.assign=()=>{};inspect(Object.defineProperty);", + ["unmodified"], + ], + [ + "a conditional replacement", + "if(flag)Object.defineProperty=()=>{};inspect(Object.defineProperty);", + ["unknown"], + ], + [ + "a replaced parent", + "Object=input;inspect(Object.defineProperty);", + ["unknown"], + ], + [ + "a lexical shadow", + "const Object={defineProperty(){}};inspect(Object.defineProperty);", + ["unrelated"], + ], + [ + "an unproven alias write", + "const copy=Object;copy.defineProperty=()=>{};inspect(Object.defineProperty);", + ["unknown"], + ], + [ + "a later write in a possibly repeated activation", + "function run(){inspect(Object.defineProperty);Object.defineProperty=()=>{};}", + ["unknown"], + ], + [ + "competing unconditional replacements before a read", + "function run(){Object.defineProperty=()=>{};Object.defineProperty=()=>{};inspect(Object.defineProperty);}", + ["replacement"], + ], + [ + "a repeated activation with a later write", + "function run(){inspect(Object.defineProperty);Object.defineProperty=()=>{};}run();run();", + ["unknown"], + ], + [ + "a replaced global namespace", + "globalThis={Object:{}};globalThis.Object.defineProperty=()=>{};inspect(Object.defineProperty);", + ["unknown"], + ], + [ + "a namespaced replacement read directly", + "globalThis.Object.defineProperty=()=>{};inspect(Object.defineProperty);", + ["replacement"], + ], + [ + "a direct replacement read through the namespace", + "Object.defineProperty=()=>{};inspect(globalThis.Object.defineProperty);", + ["replacement"], + ], + [ + "a namespaced global", + "inspect(globalThis.Object.defineProperty);globalThis.Object.defineProperty=()=>{};inspect(globalThis.Object.defineProperty);", + ["unmodified", "replacement"], + ], +])( + "retains ordered global-reference facts for %s", + (_label, source, expected) => { + const parsed = parseJavaScriptSource(source, "app.cjs"); + if (parsed === null) throw new Error("Expected global-reference fixture"); + const state = completeSemanticSteps( + indexJavaScriptBindingsSteps(parsed.program), + ); + const read = createGlobalReferenceReader(state, [ + "Object", + "defineProperty", + ]); + const statuses: string[] = []; + traverseJavaScriptAst(parsed.program, { + enter(node) { + if ( + t.isCallExpression(node) && + t.isIdentifier(node.callee, { name: "inspect" }) && + t.isNode(node.arguments[0]) + ) + statuses.push(read(node.arguments[0]).status); + }, + }); + expect(statuses).toEqual(expected); + }, +); diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts index 4a265a2cc4..fdabee8e07 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -16,12 +16,19 @@ import { type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; import { isImportMetaUrl } from "./javascriptStaticAnalysisHelpers.js"; -import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; +import { + semanticBindingAtRead, + semanticEffectTiming, + semanticDefiniteEffectPrecedesReference, +} from "./javascriptSemanticMutationInitializers.js"; import { semanticIterationSources, type SemanticIterationSource, } from "./javascriptSemanticIterationSources.js"; -import { joinSemanticPropertyPaths } from "./javascriptSemanticPropertyPaths.js"; +import { + joinSemanticPropertyPaths, + semanticPropertyPathKeyMatches, +} from "./javascriptSemanticPropertyPaths.js"; import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js"; import { semanticCopiedReferencePath, @@ -789,6 +796,22 @@ export function* indexJavaScriptAmbientWritesSteps( state.ambientEscapes.retain(["global", null]); } +/** Match lexical global identity; null denotes a direct global root. */ +const globalReferenceNamespace = ( + callee: t.Node, + state: JavaScriptSemanticAnalysisState, + path: readonly [string, ...string[]], +): string | null | undefined => { + const { root, members } = electronMemberPath(callee); + const matches = (expected: readonly string[]) => + members.length === expected.length && + members.every((key, index) => key === expected[index]); + if (isUnshadowedGlobal(root, state, path[0]) && matches(path.slice(1))) + return null; + const namespace = globalObjectName(root, state); + return namespace !== undefined && matches(path) ? namespace : undefined; +}; + /** * Recognize an ambient property path, optionally through a global object, without a visible * source-local overwrite. This is a syntax-local identity fact, not proof that @@ -802,20 +825,118 @@ export const isUnmodifiedGlobalReference = ( const path: readonly [string, ...string[]] = typeof name === "string" ? [name] : name; if (state.ambientWrites.covers(["global", ...path])) return false; - const { root, members } = electronMemberPath(callee); - const matches = (expected: readonly string[]) => - members.length === expected.length && - members.every((key, index) => key === expected[index]); - if (isUnshadowedGlobal(root, state, path[0]) && matches(path.slice(1))) - return true; - const namespace = globalObjectName(root, state); + const namespace = globalReferenceNamespace(callee, state, path); return ( namespace !== undefined && - matches(path) && - !state.ambientWrites.covers(["global", namespace]) + (namespace === null || !state.ambientWrites.covers(["global", namespace])) ); }; +export type GlobalReferenceRead = + | { readonly status: "unmodified" | "unrelated" | "unknown" } + | { readonly status: "replacement"; readonly value: t.Expression }; + +/** Reuse retained write provenance and activation ordering at a selected read. + * The reader retains only matching effects, refreshing when their owner grows. + * Missing timing, possibly repeated activations, conditional replacements and + * parent writes stay unknown. */ +export const createGlobalReferenceReader = ( + state: JavaScriptSemanticAnalysisState, + path: readonly [string, ...string[]], +): ((callee: t.Node) => GlobalReferenceRead) => { + const selected = ["global", ...path]; + let generation = -1; + const byNamespace = new Map< + string | null, + typeof state.ambientWriteEffects + >(); + const matches = ( + effect: (typeof state.ambientWriteEffects)[number], + target: readonly string[], + ) => + effect.path.length <= target.length && + effect.path.every((key, index) => + semanticPropertyPathKeyMatches(key, target[index]!), + ); + return (callee) => { + const namespace = globalReferenceNamespace(callee, state, path); + if (namespace === undefined) return { status: "unrelated" }; + if ( + !state.ambientWrites.covers(selected) && + (namespace === null || !state.ambientWrites.covers(["global", namespace])) + ) + return { status: "unmodified" }; + if (generation !== state.ambientWriteEffects.length) { + generation = state.ambientWriteEffects.length; + byNamespace.clear(); + } + let effects = byNamespace.get(namespace); + if (effects === undefined) { + effects = state.ambientWriteEffects.filter( + (effect) => + matches(effect, selected) || + (namespace !== null && matches(effect, ["global", namespace])), + ); + byNamespace.set(namespace, effects); + } + if (effects.length === 0) return { status: "unknown" }; + const active = effects.filter( + (effect) => semanticEffectTiming(callee, effect.node, state) !== "after", + ); + if (active.length === 0) return { status: "unmodified" }; + if ( + active.some( + (effect) => + effect.path.length !== selected.length || + !effect.path.every((key, index) => key === selected[index]), + ) + ) + return { status: "unknown" }; + let replacement: t.AssignmentExpression | undefined; + for (const effect of active) { + const write = effect.node; + const writeNamespace = t.isAssignmentExpression(write) + ? globalReferenceNamespace(write.left, state, path) + : undefined; + if ( + t.isAssignmentExpression(write, { operator: "=" }) && + writeNamespace !== undefined && + (writeNamespace === null || + !state.ambientWrites.covers(["global", writeNamespace])) && + semanticDefiniteEffectPrecedesReference( + write, + callee, + state.parentsByNode, + ) && + (replacement === undefined || + semanticDefiniteEffectPrecedesReference( + replacement, + write, + state.parentsByNode, + )) + ) + replacement = write; + } + if (replacement === undefined) return { status: "unknown" }; + const confirmed = replacement; + if ( + active.some( + (effect) => + effect.node !== confirmed && + semanticEffectTiming(confirmed, effect.node, state) !== "before" && + (effect.node === undefined || + !semanticDefiniteEffectPrecedesReference( + effect.node, + confirmed, + state.parentsByNode, + )), + ) + ) + return { status: "unknown" }; + return { status: "replacement", value: confirmed.right }; + }; +}; + /** A module URL base is known only while its property has no visible write. */ export const isUnmodifiedImportMetaUrl = ( node: t.Node | undefined, diff --git a/src/domain/javascript/javascriptSemanticChildProcesses.ts b/src/domain/javascript/javascriptSemanticChildProcesses.ts index 10722d67e5..b9517e587b 100644 --- a/src/domain/javascript/javascriptSemanticChildProcesses.ts +++ b/src/domain/javascript/javascriptSemanticChildProcesses.ts @@ -425,7 +425,7 @@ const stdioMode = ( if (lookup.status === "missing") return unsetStdioMode(expression, method, context); if (lookup.status !== "explicit") return "dynamic"; - const value = unwrapJavaScriptExpression(lookup.property.value).node; + const value = unwrapJavaScriptExpression(lookup.value).node; if (context.isNullish(value)) return unsetStdioMode(expression, method, context); if (t.isStringLiteral(value)) return value.value; @@ -449,7 +449,7 @@ const unsetStdioMode = ( const silent = staticObjectProperty(options, "silent", context.isNullish); if (silent.status === "missing") return "default"; if (silent.status !== "explicit") return "dynamic"; - const value = unwrapJavaScriptExpression(silent.property.value).node; + const value = unwrapJavaScriptExpression(silent.value).node; if (context.isNullish(value)) return "default"; const truthy = staticJavaScriptTruthiness(value); if (truthy === undefined) return "dynamic"; diff --git a/src/domain/javascript/javascriptSymbolicBindings.test.ts b/src/domain/javascript/javascriptSymbolicBindings.test.ts index bf9a4a4003..64366ec8e9 100644 --- a/src/domain/javascript/javascriptSymbolicBindings.test.ts +++ b/src/domain/javascript/javascriptSymbolicBindings.test.ts @@ -757,3 +757,181 @@ it.each([ expect(resolver.unresolved).toBe(true); }, ); + +it.each([ + ["unknown spread", `{value:require("electron"),...input}`, "unknown"], + ["unknown descriptor", `input`, "unknown"], + ["negative number spread", `{value:require("electron"),...-1}`, "external"], + ["negative bigint spread", `{value:require("electron"),...-1n}`, "external"], + [ + "ordinary method value", + `{value(){return require("electron")}}`, + "function", + ], + ["null prototype", `{__proto__:null,value:require("electron")}`, "external"], + [ + "ignored primitive prototype", + `{__proto__:0,value:require("electron")}`, + "external", + ], + [ + "function spread", + `{value:require("electron"),...function(){}}`, + "external", + ], + ["regexp spread", `{value:require("electron"),.../x/}`, "external"], + [ + "interpolated string spread", + '{value:require("electron"),...`x${input}`}', + "external", + ], + ["empty array spread", `{value:require("electron"),...[]}`, "external"], + ["array spread", `{value:require("electron"),...[1]}`, "external"], + ["sparse array spread", `{value:require("electron"),...[,1]}`, "external"], + [ + "unknown iterable array spread", + `{value:require("electron"),...[...input]}`, + "external", + ], + [ + "patched defineProperty", + `{get:()=>replacement}`, + "external", + "Object.defineProperty=()=>{};", + ], + [ + "literal-return no-op replacement", + `{enumerable:true}`, + "external", + "Object.defineProperty=(_target,_key)=>0;", + ], + [ + "successive no-op replacements", + `{enumerable:true}`, + "external", + "Object.defineProperty=()=>{};Object.defineProperty=()=>{};", + ], + [ + "literal stub with effectful parameter binding", + `{enumerable:true}`, + "unknown", + "Object.defineProperty=(target=mutate())=>0;", + ], + [ + "mixed getter and spread value", + `{get:()=>0,...{value:require("electron")}}`, + "unknown", + ], + [ + "mixed setter and value", + `{value:require("electron"),set:()=>{}}`, + "unknown", + ], + [ + "erased descriptor assertion", + `({value:require("electron")} as const)`, + "external", + ], + ["string spread", `{value:require("electron"),..."x"}`, "external"], + ["accessor getter", `{get(){return replacement}}`, "unknown"], + ["accessor setter", `{set(value){replacement=value}}`, "unknown"], + ["absent accessor", `{get:undefined}`, "unknown"], + ["unknown key", `{value:require("electron"),[key]:replacement}`, "unknown"], + [ + "getter", + `{value:require("electron"),get value(){return replacement}}`, + "unknown", + ], + [ + "unknown spread may supply accessors", + `{...input,value:require("electron")}`, + "unknown", + ], + [ + "known spread before value", + `{...{enumerable:true},value:require("electron")}`, + "external", + ], + ["null spread", `{value:require("electron"),...null}`, "external"], + ["undefined spread", `{value:require("electron"),...undefined}`, "external"], + ["void spread", `{value:require("electron"),...void 0}`, "external"], + [ + "shadowed undefined", + `{value:require("electron"),...undefined}`, + "unknown", + "const undefined=input;", + ], + ["unchanged value", `{enumerable:true}`, "external"], +])( + "preserves effective descriptor value uncertainty for %s", + (_name, descriptor, kind, setup = "") => { + const value = inspectOne(`(() => { + const modules = { + 1(module,exports) { + ${setup} + exports.api = require("electron"); + Object.defineProperty(exports,"api",${descriptor}); + }, + 2(module,exports,load) { inspect(load(1).api); } + }; + function load(id) {const m={exports:{}};modules[id](m,m.exports,load);return m.exports;} + load(2); + })();`); + expect(value.kind).toBe(kind); + if (kind === "external") + expect(value).toEqual({ kind: "external", path: ["electron"] }); + }, +); + +it("reads a literal prototype only from its actual prototype setter", () => { + expect( + inspectOne('inspect(({ordinary:require("electron")}).__proto__);').kind, + ).toBe("unknown"); + expect( + inspectOne( + 'inspect(({__proto__:require("electron"),ordinary:1}).__proto__);', + ), + ).toEqual({ kind: "external", path: ["electron"] }); +}); + +it.each(["local", "outer"])( + "does not resurrect an export when the intrinsic changes %s after replacement", + (position) => { + const value = inspectOne(`(() => { + const modules = { + 1(module,exports) { + exports.api = require("electron"); + Object.defineProperty(exports,"api",{value:null}); + ${position === "local" ? "Object.defineProperty=()=>{};" : ""} + }, + 2(module,exports,load) {inspect(load(1).api);} + }; + function load(id){const m={exports:{}};modules[id](m,m.exports,load);return m.exports;} + load(2); + ${position === "outer" ? "Object.defineProperty=()=>{};" : ""} + })();`); + expect(value.kind).not.toBe("external"); + }, +); + +it("does not preserve a sender through a possibly mutating intrinsic replacement", () => { + const value = inspectOne(`(() => { + const modules={ + 1(module,exports){exports.api=require("electron");Object.defineProperty=(target,key)=>{target[key]=null;};Object.defineProperty(exports,"api",{value:require("electron")});}, + 2(module,exports,load){inspect(load(1).api);} + }; + function load(id){const m={exports:{}};modules[id](m,m.exports,load);return m.exports;}load(2); + })();`); + expect(value.kind).toBe("unknown"); +}); + +it("keeps a possibly repeated factory's native descriptor value unknown", () => { + const value = inspectOne(`(() => { + const modules={ + 1(module,exports){exports.api=null;Object.defineProperty(exports,"api",{value:require("electron")});Object.defineProperty=()=>{};}, + 2(module,exports,load){inspect(load(1).api);} + }; + function load(id){const m={exports:{}};modules[id](m,m.exports,load);return m.exports;}load(2); + })();`); + expect(value.kind).toBe("unknown"); +}); diff --git a/src/domain/javascript/javascriptSymbolicBindings.ts b/src/domain/javascript/javascriptSymbolicBindings.ts index 613a8dc6b6..01e6614485 100644 --- a/src/domain/javascript/javascriptSymbolicBindings.ts +++ b/src/domain/javascript/javascriptSymbolicBindings.ts @@ -1,11 +1,17 @@ import * as t from "@babel/types"; import { + staticObjectPropertyReader, + readExactJavaScriptLiteral, + isObjectPrototypeSetter, + objectDataPropertyValue, semanticStaticPropertyKey, semanticWriteSiteTarget, semanticWriteTargets, runtimeFunctionParameters, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; +import { createSemanticExpressionShapeResolver } from "./javascriptSemanticExpressionShapes.js"; +import { createGlobalReferenceReader } from "./javascriptSemanticAmbientWrites.js"; import { javascriptBootstrapAt } from "./javascriptBootstrap.js"; import { ELECTRON_MODULE } from "./javascriptElectronMemberWrites.js"; import { @@ -60,14 +66,6 @@ interface SymbolicReadBoundary { type SymbolicNamespace = Extract; const emptyEnvironment: SymbolicEnvironment = { values: new Map() }; -const isObjectPrototypeSetter = ( - property: t.ObjectMethod | t.ObjectProperty, - name: string, -): boolean => - t.isObjectProperty(property) && - !property.computed && - !property.shorthand && - name === "__proto__"; const containsOwnedReturn = (root: t.Node): boolean => { let found = false; @@ -131,6 +129,11 @@ export class JavaScriptSymbolicBindings { ), ) { this.#electronIdentity = electronIdentity; + const shapes = createSemanticExpressionShapeResolver(state); + const defineProperty = createGlobalReferenceReader(state, [ + "Object", + "defineProperty", + ]); this.#indexBindingAliases(); traverseJavaScriptAst(program, { enter: (node) => { @@ -141,29 +144,49 @@ export class JavaScriptSymbolicBindings { enter: (node) => { const target = semanticWriteSiteTarget(node); if (target !== null) this.#indexWriteSite(target, node); - if ( - t.isCallExpression(node) && - t.isMemberExpression(node.callee) && - t.isIdentifier(node.callee.object, { name: "Object" }) && - !resolveSemanticBindingState(state, node.callee.object, "Object") && - semanticStaticPropertyKey( - node.callee.property, - node.callee.computed, - ) === "defineProperty" - ) { + if (t.isCallExpression(node) && t.isMemberExpression(node.callee)) { + const intrinsic = defineProperty(node.callee); + if (intrinsic.status === "unrelated") return; const [target, key, descriptor] = node.arguments; if ( t.isIdentifier(target) && t.isStringLiteral(key) && - t.isObjectExpression(descriptor) + descriptor !== undefined ) { - const value = descriptor.properties.find( - (p) => - t.isObjectProperty(p) && - semanticStaticPropertyKey(p.key, p.computed) === "value", + if (intrinsic.status !== "unmodified") { + const replacement = + intrinsic.status === "replacement" + ? unwrapJavaScriptExpression(intrinsic.value).node + : undefined; + const unchanged = + (t.isFunctionExpression(replacement) || + t.isArrowFunctionExpression(replacement)) && + (t.isBlockStatement(replacement.body) + ? replacement.body.body.length === 0 + : readExactJavaScriptLiteral(replacement.body).found) && + runtimeFunctionParameters(replacement).every( + (parameter) => + t.isIdentifier(parameter) || + (t.isRestElement(parameter) && + t.isIdentifier(parameter.argument)), + ); + if (!unchanged) this.#addWrite(target, [key.value], node, node); + return; + } + const properties = staticObjectPropertyReader( + unwrapJavaScriptExpression(descriptor).node, + shapes.isNullish, ); - if (t.isObjectProperty(value)) + const value = properties("value"); + const hasAccessor = + properties("get").status !== "missing" || + properties("set").status !== "missing"; + if (value.status === "explicit" && !hasAccessor) this.#addWrite(target, [key.value], value.value, node); + else if (value.status !== "missing" || hasAccessor) + // Unknown values, accessor replacements and invalid mixed + // descriptors cannot preserve an earlier data export. + this.#addWrite(target, [key.value], node, node); } } }, @@ -387,7 +410,7 @@ export class JavaScriptSymbolicBindings { (candidate): candidate is t.ObjectMethod | t.ObjectProperty => !t.isSpreadElement(candidate), ) - .find((candidate) => isObjectPrototypeSetter(candidate, key)); + .find((candidate) => isObjectPrototypeSetter(candidate)); if (prototypeSetter !== undefined) return this.#objectPropertyValue( prototypeSetter, @@ -431,12 +454,10 @@ export class JavaScriptSymbolicBindings { property: t.ObjectMethod | t.ObjectProperty, environment: SymbolicEnvironment, ): SymbolicValue { - return t.isObjectMethod(property) && property.kind !== "method" + const value = objectDataPropertyValue(property); + return value === undefined ? symbolicUnknown("accessor property") - : this.evaluate( - t.isObjectProperty(property) ? property.value : property, - environment, - ); + : this.evaluate(value, environment); } members( diff --git a/src/domain/webBundleAnalyzerAst.ts b/src/domain/webBundleAnalyzerAst.ts index 1cfa52f85d..75c7b9af67 100644 --- a/src/domain/webBundleAnalyzerAst.ts +++ b/src/domain/webBundleAnalyzerAst.ts @@ -6,15 +6,16 @@ import { isNullishJavaScriptSyntax, readExactJavaScriptLiteral, staticObjectProperty, + type ObjectDataPropertyValue, } from "./javascript/javascriptAstValues.js"; /** Read a statically known own property value from an object expression. */ export const objectValue = ( object: t.ObjectExpression, name: string, -): t.ObjectProperty["value"] | undefined => { +): ObjectDataPropertyValue | undefined => { const result = staticObjectProperty(object, name, isNullishJavaScriptSyntax); - return result.status === "explicit" ? result.property.value : undefined; + return result.status === "explicit" ? result.value : undefined; }; export const objectString = ( diff --git a/src/domain/webBundleAnalyzerInspection.ts b/src/domain/webBundleAnalyzerInspection.ts index 0f3e900f02..09425f4bc5 100644 --- a/src/domain/webBundleAnalyzerInspection.ts +++ b/src/domain/webBundleAnalyzerInspection.ts @@ -347,7 +347,7 @@ const addWebMcpDeclaration = ( const schemaProperty = camelSchema.status === "missing" || (camelSchema.status === "explicit" && - isNullishJavaScriptSyntax(camelSchema.property.value)) + isNullishJavaScriptSyntax(camelSchema.value)) ? staticObjectProperty( declaration, "input_schema", @@ -361,7 +361,7 @@ const addWebMcpDeclaration = ( if (schemaProperty.status === "missing") { schemaPropertyNamesCoverage = "not_present"; } else if (schemaProperty.status === "explicit") { - const schema = schemaProperty.property.value; + const schema = schemaProperty.value; if (t.isObjectExpression(schema)) { const propertiesProperty = staticObjectProperty( schema, @@ -371,7 +371,7 @@ const addWebMcpDeclaration = ( if (propertiesProperty.status === "missing") { schemaPropertyNamesCoverage = "not_present"; } else if (propertiesProperty.status === "explicit") { - const properties = propertiesProperty.property.value; + const properties = propertiesProperty.value; if (t.isObjectExpression(properties)) { const projection = staticObjectPropertyNames( properties, diff --git a/tests/boundary/cli/electronWrappedCallPaths.test.ts b/tests/boundary/cli/electronWrappedCallPaths.test.ts index e03d61fbd2..58e41d0f11 100644 --- a/tests/boundary/cli/electronWrappedCallPaths.test.ts +++ b/tests/boundary/cli/electronWrappedCallPaths.test.ts @@ -1,6 +1,8 @@ import { expect } from "vitest"; -import { writeFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { runInNewContext } from "node:vm"; +import { stripTypeScriptTypes } from "node:module"; import { z } from "zod"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; @@ -9,6 +11,10 @@ import { cliTest, type TestCli } from "../../support/cli/cliFixture.js"; import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; import { applicationFeatureTraceResultSchema } from "../../../src/domain/javascript/javascriptFeatureTraceSchemas.js"; import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; +import { + analysisViewResultSchema, + javascriptApplicationSummaryViewResultSchema, +} from "../../../src/domain/analysisView/analysisView.js"; const sourcePositionSchema = z.object({ line: z.number(), column: z.number() }); const callPathConditionSchema = z.object({ @@ -869,3 +875,251 @@ cliTest( } }, ); + +const descriptorSender = `function(channel) { return (...args) => send(channel, ...args); }`; +const descriptorNoop = `function() { return () => {}; }`; +const descriptorCases: readonly (readonly [ + string, + string, + boolean, + boolean?, + boolean?, +])[] = [ + ["control", `{value:${descriptorSender}}`, true], + ["accessor-noop", `{get:function(){return ${descriptorNoop}}}`, false], + ["mixed-getter", `{get:()=>0,...{value:${descriptorSender}}}`, false, true], + ["mixed-setter", `{value:${descriptorSender},...{set:()=>{}}}`, false, true], + ["string-spread", `{value:${descriptorSender},..."x"}`, true], + ["negative-number", `{value:${descriptorSender},...-1}`, true], + ["negative-bigint", `{value:${descriptorSender},...-1n}`, true], + [ + "method-value", + "{value" + descriptorSender.slice("function".length) + "}", + true, + ], + ["patch-after-sender", `{value:${descriptorSender}}`, true, false, false], + ["negative-prototype", `{__proto__:-1,value:${descriptorSender}}`, true], + ["bigint-prototype", `{__proto__:-1n,value:${descriptorSender}}`, true], + ["successive-noops", `{enumerable:true}`, true], + ["literal-noop", `{enumerable:true}`, true], + ["replaced-namespace", `{value:${descriptorNoop}}`, false], + ["namespaced-noop", `{value:${descriptorNoop}}`, true], + ["patch-after", `{value:${descriptorNoop}}`, false], + ["outer-patch-after", `{value:${descriptorNoop}}`, false], + ["mutating-intrinsic", `{value:${descriptorSender}}`, false], + ["null-prototype", `{__proto__:null,value:${descriptorSender}}`, true], + ["function-spread", `{value:${descriptorSender},...function(){}}`, true], + ["regexp-spread", `{value:${descriptorSender},.../x/}`, true], + ["template-spread", "{value:" + descriptorSender + ",...`x${1}`}", true], + ["empty-array", `{value:${descriptorSender},...[]}`, true], + ["array", `{value:${descriptorSender},...[1]}`, true], + ["patched-intrinsic", `{get:function(){return ${descriptorNoop}}}`, true], + ["typescript-assertion", `({value:${descriptorSender}} as const)`, true], + [ + "duplicate-noop", + `{value:${descriptorSender},value:${descriptorNoop}}`, + false, + ], + [ + "duplicate-sender", + `{value:${descriptorNoop},value:${descriptorSender}}`, + true, + ], + [ + "spread-sender", + `{value:${descriptorNoop},...{value:${descriptorSender}}}`, + true, + ], + [ + "spread-noop", + `{value:${descriptorSender},...{value:${descriptorNoop}}}`, + false, + ], + [ + "computed-sender", + `{value:${descriptorNoop},["value"]:${descriptorSender}}`, + true, + ], + ["null-spread", `{value:${descriptorSender},...null}`, true], + ["undefined-spread", `{value:${descriptorSender},...undefined}`, true], + ["void-spread", `{value:${descriptorSender},...void 0}`, true], + ["nested-null-spread", `{value:${descriptorSender},...{...null}}`, true], +] as const; + +const analyzeDescriptorFacts = async ({ + cli, + root, + connection, +}: { + cli: TestCli; + root: string; + connection: Awaited> | undefined; +}) => { + type ApplicationResult = z.infer< + typeof javascriptApplicationAnalysisResultSchema + >; + type Observation = Pick< + ApplicationResult["graph"]["nodes"][number]["observations"][number], + "properties" | "evidence" + >; + let statistics: ApplicationResult["statistics"]; + const observations: Observation[] = []; + if (connection === undefined) { + const application = await analyzeJavaScriptDirectory("CLI", cli, root); + statistics = application.statistics; + for (const node of application.graph.nodes) + observations.push(...node.observations); + } else { + const response = await connection.call("analyze_javascript_application", { + input_path: root, + format: "directory", + detail: "summary", + }); + expect(response.isError).not.toBe(true); + const summary = z + .object({ + normalized_result: javascriptApplicationSummaryViewResultSchema, + }) + .parse(response.structuredContent).normalized_result; + statistics = + javascriptApplicationAnalysisResultSchema.shape.statistics.parse( + summary.summary.statistics, + ); + let offset: number | null = 0; + while (offset !== null) { + const response = await connection.call("inspect_analysis_view", { + source: { + kind: "retained-evidence", + evidence_id: summary.parent_evidence_id, + }, + view: { + kind: "javascript-facts", + where: { kinds: ["javascript-expression"] }, + property_keys: ["step_kind", "terminal", "channel", "resolution"], + offset, + limit: 64, + }, + }); + expect(response.isError).not.toBe(true); + const page = z + .object({ normalized_result: analysisViewResultSchema }) + .parse(response.structuredContent).normalized_result; + if (page.kind !== "javascript-facts") + throw new Error("Expected retained JavaScript facts"); + expect(page.parent_evidence_id).toBe(summary.parent_evidence_id); + observations.push(...page.rows); + offset = page.coverage.next_offset; + if (offset === null) { + expect(page.coverage.exhausted).toBe(true); + expect(observations).toHaveLength(page.coverage.total); + } + } + await connection.client.ping(); + } + return { statistics, observations }; +}; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "uses effective defineProperty descriptor values for bundled IPC paths through %s", + async (surface, { cli }) => { + const workspace = await createTestTempDirectory("rea-descriptor-values-"); + const root = join(workspace, "路径-'values"); + const files = Object.fromEntries( + descriptorCases.map(([name, descriptor, sends, throws]) => { + const source = `(() => { + const modules = { + 2(module) { module.exports = require("electron"); }, + 5(module, exports, load) { + const electron = load(2); + ${name === "successive-noops" ? "Object.defineProperty=()=>{};Object.defineProperty=()=>{};" : name === "literal-noop" ? "Object.defineProperty=(_target,_key)=>0;" : name === "replaced-namespace" ? "globalThis={Object:{}};globalThis.Object.defineProperty=()=>{};" : name === "namespaced-noop" ? "globalThis.Object.defineProperty=()=>{};" : name === "patched-intrinsic" ? "Object.defineProperty=()=>{};" : name === "mutating-intrinsic" ? "Object.defineProperty=(target,key)=>{target[key]=" + descriptorNoop + ";};" : ""} + exports.sender = ${["accessor-noop", "successive-noops", "literal-noop", "replaced-namespace", "namespaced-noop", "patched-intrinsic", "patch-after", "outer-patch-after", "mutating-intrinsic"].includes(name) ? descriptorSender : descriptorNoop}; + Object.defineProperty(exports, "sender", ${descriptor}); + ${name.startsWith("patch-after") ? "Object.defineProperty=()=>{};" : ""} + function send(channel, ...args) { electron.ipcRenderer.send(channel, ...args); } + }, + 9(module, exports, load) { + const helper = load(5); + load(2).contextBridge.exposeInMainWorld("${name}", {copy: helper.sender("${name}:copy")}); + } + }; + function load(id) { const m = {exports:{}}; modules[id].call(m.exports,m,m.exports,load); return m.exports; } + load(9); + ${name === "outer-patch-after" ? "Object.defineProperty=()=>{};" : ""} + })();`; + // Node executes only this test-owned fixture with a recording Electron + // substitute: an independent descriptor-semantics oracle, not provider proof. + const channels: string[] = []; + const run = () => + runInNewContext( + name === "typescript-assertion" + ? stripTypeScriptTypes(source) + : source, + { + require: () => ({ + ipcRenderer: { + send: (channel: string) => channels.push(channel), + }, + contextBridge: { + exposeInMainWorld: (_key: string, api: { copy(): void }) => + api.copy(), + }, + }), + }, + { timeout: 1_000 }, + ); + if (throws) expect(run).toThrow(/Invalid property descriptor/u); + else run(); + expect(channels, name).toEqual(sends ? [`${name}:copy`] : []); + return [ + `${name}.${name === "typescript-assertion" ? "cts" : "cjs"}`, + source, + ]; + }), + ); + await writeFixtureFiles(root, files); + const connection = + surface === "stdio MCP" ? await connectLocalToolsMcp() : undefined; + if (connection !== undefined) { + const failure = await connection.call("analyze_javascript_application", { + input_path: join(root, "absent"), + format: "directory", + }); + expect(failure.isError).toBe(true); + await connection.client.ping(); + } + const { statistics, observations } = await analyzeDescriptorFacts({ + cli, + root, + connection, + }); + for (const [name, source] of Object.entries(files)) + expect(await readFile(join(root, name), "utf8")).toBe(source); + expect(statistics).toMatchObject({ + parsed_javascript_files: descriptorCases.length, + parse_failures: 0, + }); + const terminals = observations.filter( + ({ properties }) => + properties.step_kind === "terminal" && + properties.terminal === "electron.ipcRenderer.send", + ); + expect( + terminals.map(({ properties }) => properties.channel).sort(), + ).toEqual( + descriptorCases + // A factory with a later global patch can run repeatedly: its native + // intrinsic is unknown even though this test invokes it only once. + .filter(([, , sends, , admitted = sends]) => admitted) + .map(([name]) => `${name}:copy`) + .sort(), + ); + for (const terminal of terminals) + expect(terminal).toMatchObject({ + properties: { resolution: "candidate" }, + evidence: { + state: "inferred", + authority: "static-relationship-inference", + }, + }); + }, +);