From a874fcc4e340ab27543fef63ab4494b8fd296047 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 15 Nov 2026 03:13:07 +0800 Subject: [PATCH] refactor(javascript): classify implicit execution in one owner (#10605) * refactor(javascript): classify implicit execution in one owner Add javascriptSemanticExecutionPoints, a total classification of every runtime Babel node type into the ways its evaluation can run code without call syntax. The member-mutation escape walk, the accessor operand collector and the catch-source reachability check consult it instead of their own syntax lists. Kinds those collectors have never applied are now named in exhaustive switches rather than absent. A guard test fails when the installed @babel/types defines a node type the table does not classify, and unknown node types classify as unsupported syntax rather than inert. * test(hopper): retry cleanup after confirmed application exit * fix(javascript): classify enumeration and catch binding effects * fix(javascript): retain catch uncertainty for identifier reads --- .../javascriptSemanticExecutionPoints.test.ts | 140 +++++ .../javascriptSemanticExecutionPoints.ts | 576 ++++++++++++++++++ .../javascriptSemanticIterationSources.ts | 21 +- .../javascriptSemanticLanguageInvocations.ts | 43 -- .../javascriptSemanticMemberMutations.ts | 382 ++++++------ .../hopperClient.handshakeRecovery.test.ts | 25 +- 6 files changed, 947 insertions(+), 240 deletions(-) create mode 100644 src/domain/javascript/javascriptSemanticExecutionPoints.test.ts create mode 100644 src/domain/javascript/javascriptSemanticExecutionPoints.ts delete mode 100644 src/domain/javascript/javascriptSemanticLanguageInvocations.ts diff --git a/src/domain/javascript/javascriptSemanticExecutionPoints.test.ts b/src/domain/javascript/javascriptSemanticExecutionPoints.test.ts new file mode 100644 index 0000000000..800fb9f0a4 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticExecutionPoints.test.ts @@ -0,0 +1,140 @@ +import * as t from "@babel/types"; +import { describe, expect, it } from "vitest"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { + javaScriptClassifiedNodeTypes, + javaScriptExecutions, + javaScriptTypeOnlyAliases, +} from "./javascriptSemanticExecutionPoints.js"; +import { childNodes } from "./javascriptSemanticTraversal.js"; +import { semanticPropertyReadSources } from "./javascriptSemanticIterationSources.js"; + +const installedNodeTypes = [ + ...new Set([...Object.keys(t.VISITOR_KEYS), ...Object.keys(t.NODE_FIELDS)]), +].sort(); + +const typeOnly = new Set( + javaScriptTypeOnlyAliases.flatMap( + (alias) => t.FLIPPED_ALIAS_KEYS[alias] ?? [], + ), +); + +describe("JavaScript execution classification", () => { + it("classifies every installed Babel node type", () => { + const classified = new Set(javaScriptClassifiedNodeTypes()); + const unclassified = installedNodeTypes.filter( + (type) => + !classified.has(type) && + (!typeOnly.has(type) || type === "EnumDeclaration"), + ); + expect(unclassified).toEqual([]); + }); + + it("names no node type the installed Babel version lacks", () => { + const known = new Set([ + ...installedNodeTypes, + ...Object.keys(t.DEPRECATED_KEYS), + ]); + expect( + javaScriptClassifiedNodeTypes().filter((type) => !known.has(type)), + ).toEqual([]); + }); + + it("treats a node type it does not know as code that may run", () => { + const future = { type: "FutureExpression" } as unknown as t.Node; + expect(javaScriptExecutions(future, null)).toMatchObject([ + { kind: "unsupported-syntax", node: future }, + ]); + }); +}); + +const parsed = (source: string): t.Program => { + const file = parseJavaScriptSource(source, "classification.jsx"); + if (file === null) throw new Error(`Fixture did not parse: ${source}`); + return file.program; +}; + +/** Kinds per node type for every node in one parsed program. */ +const kindsByType = (source: string): Map => { + const kinds = new Map(); + const pending: { node: t.Node; parent: t.Node | null }[] = [ + { node: parsed(source), parent: null }, + ]; + while (pending.length > 0) { + const { node, parent } = pending.pop()!; + for (const { kind } of javaScriptExecutions(node, parent)) + kinds.set(node.type, [...(kinds.get(node.type) ?? []), kind]); + for (const child of childNodes(node)) + pending.push({ node: child, parent: node }); + } + return kinds; +}; + +it.each([ + ["value === other; !value; typeof value; void value; value && other;", {}], + ["value + other;", { BinaryExpression: ["coercion"] }], + ["`${value}`;", { TemplateLiteral: ["coercion"] }], + ["tag`${value}`;", { TaggedTemplateExpression: ["tagged-template"] }], + ["value.key;", { MemberExpression: ["property-read"] }], + [ + "value.key = 1;", + { + AssignmentExpression: ["property-write"], + MemberExpression: ["property-read"], + }, + ], + ["binding = value;", {}], + ["const { key } = value;", { VariableDeclarator: ["destructuring"] }], + ["const [first] = value;", { VariableDeclarator: ["iteration"] }], + ["const copy = [...value];", { SpreadElement: ["iteration"] }], + ["const copy = { ...value };", { SpreadElement: ["property-copy"] }], + ["for (const item of value);", { ForOfStatement: ["iteration"] }], + ["for (const key in value);", { ForInStatement: ["enumeration"] }], + [ + "try { throw source; } catch ({ value }) {}", + { CatchClause: ["destructuring"] }, + ], + ["try { throw source; } catch ([value]) {}", { CatchClause: ["iteration"] }], + ["async function f() { await value; }", { AwaitExpression: ["thenable"] }], + ["function* f() { yield value; }", { YieldExpression: ["suspension"] }], + ["function* f() { yield* value; }", { YieldExpression: ["iteration"] }], + ["{ using resource = value; }", { VariableDeclarator: ["disposal"] }], + ["class C extends Base {}", { ClassDeclaration: ["class-definition"] }], + [ + "@decorate class C {}", + { ClassDeclaration: ["class-definition"], Decorator: ["decorator"] }, + ], + ["import(value);", { ImportExpression: ["dynamic-import"] }], + ["with (value) key;", { WithStatement: ["dynamic-scope"] }], + [";", { JSXElement: ["jsx-element"] }], + ['import "side-effect";', { ImportDeclaration: ["module-evaluation"] }], + ["new Factory(value);", { NewExpression: ["construction"] }], + ["call(value);", { CallExpression: ["call"] }], +])("classifies %s", (source, expected) => { + expect(Object.fromEntries(kindsByType(source))).toEqual(expected); +}); + +it("exposes the enumerated object", () => { + const node = parsed("for (const key in value);").body[0]; + if (!t.isForInStatement(node)) throw new Error("Expected for-in statement"); + expect(javaScriptExecutions(node, null)).toContainEqual( + expect.objectContaining({ kind: "enumeration", operands: [node.right] }), + ); +}); + +it.each(["!trigger", "typeof trigger", "void trigger", "trigger === 1"])( + "retains catch property reads when %s may invoke a global accessor", + (expression) => { + const statement = parsed(`try { ${expression}; } catch ({ value }) {}`) + .body[0]; + if (!t.isTryStatement(statement) || statement.handler == null) + throw new Error("Expected catch clause"); + expect(semanticPropertyReadSources(statement.handler, statement)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + projection: expect.arrayContaining(["value"]), + }), + ]), + ); + }, +); diff --git a/src/domain/javascript/javascriptSemanticExecutionPoints.ts b/src/domain/javascript/javascriptSemanticExecutionPoints.ts new file mode 100644 index 0000000000..97996d6c7e --- /dev/null +++ b/src/domain/javascript/javascriptSemanticExecutionPoints.ts @@ -0,0 +1,576 @@ +import * as t from "@babel/types"; +import { + javaScriptComputedDefinitionKey, + semanticWriteSiteReadsFirst, + semanticWriteSiteTarget, + semanticWriteTargets, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; + +/** + * How evaluating one syntax node can run code that the analyzed source does + * not spell as a call. Each kind names the language mechanism; operands are + * the values that mechanism exposes to the code it may run. + * + * - `call`, `construction`, `tagged-template`: explicit invocation. Direct + * `eval` is a `call`; its rebinding authority belongs to the opaque-effect + * owner because it depends on scope resolution, not syntax. + * - `property-read`, `property-write`: accessors (and Proxy traps) on member + * reads and on member or destructuring stores. Member nodes that are only + * written are still listed as reads; the enclosing `property-write` + * identifies them through its target. + * - `destructuring`, `property-copy`: getters run by object patterns and by + * object spread/rest. Projections belong to `semanticPropertyReadSources`. + * - `iteration`: the iterator protocol (for-of, array spread, spread + * arguments, array patterns, `yield*`). + * - `enumeration`: for-in enumeration may invoke Proxy traps. + * - `thenable`: `await` reads and calls `then` on its operand. + * - `suspension`: `yield` hands its operand to whoever resumes the generator. + * - `coercion`: user-defined primitive conversion and operator hooks + * (`Symbol.toPrimitive`, `valueOf`, `toString`, `Symbol.hasInstance`). + * - `disposal`: `using` declarations call `Symbol.dispose` at scope exit. + * - `class-definition`: heritage, computed keys, static blocks and static + * field values run when a class is defined. + * - `decorator`: decorators are called with the decorated value. + * - `dynamic-import`: `import()` converts its specifier and reads its options. + * - `dynamic-scope`: identifier lookups inside `with` read object properties. + * - `jsx-element`: JSX compiles to a call into the configured runtime. + * - `module-evaluation`: static imports and re-exports evaluate other modules + * before this module body runs. + * - `unsupported-syntax`: proposal or flow-runtime syntax the parser does not + * enable; it may run code in ways this table does not describe. + */ +export type JavaScriptExecutionKind = + | "call" + | "construction" + | "tagged-template" + | "property-read" + | "property-write" + | "destructuring" + | "property-copy" + | "iteration" + | "enumeration" + | "thenable" + | "suspension" + | "coercion" + | "disposal" + | "class-definition" + | "decorator" + | "dynamic-import" + | "dynamic-scope" + | "jsx-element" + | "module-evaluation" + | "unsupported-syntax"; + +export type JavaScriptExecution = + | { + readonly kind: "property-write"; + readonly node: t.Node; + readonly operands: readonly t.Node[]; + /** The direct or destructuring target, from the write-site owner. */ + readonly target: t.Node; + /** Whether the site reads the previous target value before writing. */ + readonly readsFirst: boolean; + } + | { + readonly kind: Exclude; + readonly node: t.Node; + readonly operands: readonly t.Node[]; + }; + +export interface JavaScriptExecutionOptions { + /** + * Proof that an operand is an object. Loose equality between two objects + * compares identity and performs no conversion. + */ + readonly isObject?: (operand: t.Node) => boolean; +} + +type Classifier = ( + node: Node, + parent: t.Node | null, + options: JavaScriptExecutionOptions, +) => readonly JavaScriptExecution[]; + +/** Syntax with no runtime representation: TypeScript and Flow type nodes. */ +type TypeOnlyNode = Exclude< + t.Flow | t.TSType | t.TSTypeElement, + t.EnumDeclaration +>; +type ClassifiedType = Exclude; + +const typeOnlyAliases = ["Flow", "TSType", "TSTypeElement"] as const; + +const none = (): readonly JavaScriptExecution[] => []; + +const execution = ( + kind: Exclude, + node: t.Node, + operands: readonly (t.Node | null | undefined)[], +): JavaScriptExecution => ({ + kind, + node, + operands: operands.filter((operand): operand is t.Node => operand != null), +}); + +const memberReceiver = (callee: t.Node): t.Node | undefined => { + const expression = unwrapJavaScriptExpression(callee).node; + return t.isMemberExpression(expression) || + t.isOptionalMemberExpression(expression) + ? expression.object + : undefined; +}; + +const argumentValues = ( + values: readonly (t.Node | null)[], +): readonly t.Node[] => + values.flatMap((value) => + value === null ? [] : [t.isSpreadElement(value) ? value.argument : value], + ); + +/** Member stores may run setters on their receivers; binding stores cannot. */ +const writeSite = (node: t.Node): readonly JavaScriptExecution[] => { + const target = semanticWriteSiteTarget(node); + if (target === null) return []; + const receivers = [...semanticWriteTargets(target)].flatMap((leaf) => + t.isIdentifier(leaf) ? [] : [leaf.object], + ); + return receivers.length === 0 + ? [] + : [ + { + kind: "property-write", + node, + target, + readsFirst: semanticWriteSiteReadsFirst(node), + operands: receivers, + }, + ]; +}; + +/** Pattern-owning sites evaluate getters or the iterator of their source. */ +const patternSource = ( + node: t.Node, + pattern: t.Node, + source: t.Node | null | undefined, +): readonly JavaScriptExecution[] => + t.isObjectPattern(pattern) + ? [execution("destructuring", node, [source])] + : t.isArrayPattern(pattern) + ? [execution("iteration", node, [source])] + : []; + +/** User-defined primitive conversion and operator hooks for one node. */ +const coercionOperands = ( + node: t.Node, + parent: t.Node | null, + isObject: (operand: t.Node) => boolean, +): readonly t.Node[] => { + if (t.isBinaryExpression(node)) { + if (["===", "!=="].includes(node.operator)) return []; + if (node.operator === "instanceof") return [node.left, node.right]; + if ( + ["==", "!="].includes(node.operator) && + ((t.isThisExpression(node.left) && t.isThisExpression(node.right)) || + (isObject(node.left) && isObject(node.right))) + ) + return []; + return node.operator === "in" ? [node.left] : [node.left, node.right]; + } + if (t.isUnaryExpression(node) && ["+", "-", "~"].includes(node.operator)) + return [node.argument]; + if (t.isUpdateExpression(node)) return [node.argument]; + if ( + t.isAssignmentExpression(node) && + !["=", "&&=", "||=", "??="].includes(node.operator) + ) + return [node.left, node.right]; + if ( + (t.isMemberExpression(node) || + t.isOptionalMemberExpression(node) || + t.isObjectProperty(node) || + t.isObjectMethod(node) || + t.isClassProperty(node) || + t.isClassMethod(node) || + t.isClassAccessorProperty(node)) && + node.computed + ) + return [ + t.isMemberExpression(node) || t.isOptionalMemberExpression(node) + ? node.property + : node.key, + ]; + if (t.isTemplateLiteral(node) && !t.isTaggedTemplateExpression(parent)) + return node.expressions; + return []; +}; + +const coercion: Classifier = (node, parent, options) => { + const operands = coercionOperands( + node, + parent, + options.isObject ?? (() => false), + ); + return operands.length === 0 ? [] : [execution("coercion", node, operands)]; +}; + +/** Spread arguments are iterated before the call; consumers that model the + * iterator protocol read them from the invocation node. */ +const call: Classifier = ( + node, +) => [ + execution("call", node, [ + memberReceiver(node.callee), + ...argumentValues(node.arguments), + ]), +]; + +const member: Classifier = ( + node, + parent, + options, +) => [ + execution("property-read", node, [node.object]), + ...coercion(node, parent, options), +]; + +const computedMember: Classifier = coercion; + +/** Members whose computed keys a definition evaluates and converts immediately. */ +const computedDefinitionMembers = (node: t.Function | t.Class): t.Node[] => { + if (t.isObjectMethod(node)) + return javaScriptComputedDefinitionKey(node, node.key) ? [node] : []; + if (!t.isClass(node)) return []; + return node.body.body.filter( + (member) => + "key" in member && javaScriptComputedDefinitionKey(member, member.key), + ); +}; + +/** + * Expressions a definition runs immediately: heritage, computed keys, static + * blocks and static field values. Deferred bodies and instance initializers + * stay out; `this` inside static work is the class, not the enclosing receiver. + */ +export const javaScriptDefinitionTimeExpressions = ( + node: t.Function | t.Class, +): t.Node[] => [ + ...(t.isClass(node) && node.superClass != null ? [node.superClass] : []), + ...computedDefinitionMembers(node).flatMap((member) => + "key" in member ? [member.key] : [], + ), + ...(t.isClass(node) + ? node.body.body.flatMap((member): t.Node[] => + t.isStaticBlock(member) + ? [member] + : (t.isClassProperty(member) || t.isClassPrivateProperty(member)) && + member.static && + member.value != null + ? [member.value] + : [], + ) + : []), +]; + +/** Computed definition keys converted when a function or class is defined. */ +export const javaScriptDefinitionKeyCoercions = ( + node: t.Function | t.Class, +): readonly t.Node[] => + computedDefinitionMembers(node).flatMap((member) => + coercionOperands(member, node, () => false), + ); + +const classDefinition: Classifier = ( + node, +) => [ + // Evaluating heritage reads `prototype` from the superclass. + execution("class-definition", node, [node.superClass]), +]; + +const jsxElement: Classifier = (node) => { + const operands: t.Node[] = []; + const attributes = t.isJSXElement(node) ? node.openingElement.attributes : []; + for (const attribute of attributes) + if (t.isJSXSpreadAttribute(attribute)) operands.push(attribute.argument); + else if (t.isJSXExpressionContainer(attribute.value)) + operands.push(attribute.value.expression); + for (const child of node.children) + if (t.isJSXExpressionContainer(child) || t.isJSXSpreadChild(child)) + operands.push(child.expression); + return [ + execution( + "jsx-element", + node, + operands.filter((operand) => !t.isJSXEmptyExpression(operand)), + ), + ]; +}; + +const unsupported: Classifier = (node) => [ + execution("unsupported-syntax", node, []), +]; + +const moduleEvaluation: Classifier = (node) => [ + execution("module-evaluation", node, []), +]; + +/** + * One classifier per runtime Babel node type. The mapped type makes a new + * Babel node type a compile error; the guard test checks the installed + * runtime definitions as well. + */ +const classifiers: { + readonly [Type in ClassifiedType]: Classifier< + Extract + >; +} = { + // Programs, statements and declarations without implicit work. + File: none, + Program: none, + InterpreterDirective: none, + Directive: none, + DirectiveLiteral: none, + BlockStatement: none, + BreakStatement: none, + ContinueStatement: none, + DebuggerStatement: none, + DoWhileStatement: none, + EmptyStatement: none, + ExpressionStatement: none, + ForStatement: none, + IfStatement: none, + LabeledStatement: none, + ReturnStatement: none, + // Strict-equality case matching performs no conversion. + SwitchStatement: none, + SwitchCase: none, + ThrowStatement: none, + TryStatement: none, + CatchClause: (node) => + node.param == null ? [] : patternSource(node, node.param, undefined), + WhileStatement: none, + VariableDeclaration: none, + StaticBlock: none, + ClassBody: none, + // Literals and plain references. + StringLiteral: none, + NumericLiteral: none, + NullLiteral: none, + BooleanLiteral: none, + RegExpLiteral: none, + BigIntLiteral: none, + TemplateElement: none, + // A binding lookup itself is plain; `with` lookups are `dynamic-scope`. + Identifier: none, + ThisExpression: none, + Super: none, + MetaProperty: none, + PrivateName: none, + // Expressions whose own evaluation is plain; operands classify separately. + ArrayExpression: none, + ObjectExpression: none, + ConditionalExpression: none, + LogicalExpression: none, + SequenceExpression: none, + ParenthesizedExpression: none, + ArrowFunctionExpression: none, + FunctionDeclaration: none, + FunctionExpression: none, + ClassPrivateMethod: none, + ClassPrivateProperty: none, + // Pattern leaves; their sources are classified at the owning site. + AssignmentPattern: none, + ArrayPattern: none, + ObjectPattern: none, + RestElement: (node, parent) => + t.isObjectPattern(parent) ? [execution("property-copy", node, [])] : [], + VoidPattern: none, + // Module syntax. + ImportDeclaration: moduleEvaluation, + ExportAllDeclaration: moduleEvaluation, + ExportNamedDeclaration: (node, parent, options) => + node.source == null ? none() : moduleEvaluation(node, parent, options), + ExportDefaultDeclaration: none, + ExportSpecifier: none, + ExportDefaultSpecifier: none, + ExportNamespaceSpecifier: none, + ImportDefaultSpecifier: none, + ImportNamespaceSpecifier: none, + ImportSpecifier: none, + ImportAttribute: none, + Import: none, + ImportExpression: (node) => [ + execution("dynamic-import", node, [node.source, node.options]), + ], + // Explicit invocation. + CallExpression: call, + OptionalCallExpression: call, + NewExpression: (node) => [ + execution("construction", node, argumentValues(node.arguments)), + ], + TaggedTemplateExpression: (node) => [ + execution("tagged-template", node, [ + memberReceiver(node.tag), + ...node.quasi.expressions, + ]), + ], + // The decorator expression is the callee; the decorated declaration escapes. + Decorator: (node, parent) => [execution("decorator", node, [parent])], + // Accessors, conversion and protocols. + MemberExpression: member, + OptionalMemberExpression: member, + AssignmentExpression: (node, parent, options) => [ + ...writeSite(node), + ...coercion(node, parent, options), + ...patternSource(node, node.left, node.right), + ], + UpdateExpression: (node, parent, options) => [ + ...writeSite(node), + ...coercion(node, parent, options), + ], + UnaryExpression: (node, parent, options) => + node.operator === "delete" + ? writeSite(node) + : coercion(node, parent, options), + BinaryExpression: coercion, + TemplateLiteral: coercion, + ObjectProperty: computedMember, + ObjectMethod: computedMember, + ClassMethod: computedMember, + ClassProperty: computedMember, + ClassAccessorProperty: computedMember, + ForInStatement: (node) => [ + ...writeSite(node), + execution("enumeration", node, [node.right]), + ], + ForOfStatement: (node) => [ + ...writeSite(node), + execution(node.await ? "thenable" : "iteration", node, [node.right]), + ], + SpreadElement: (node, parent) => + t.isObjectExpression(parent) + ? [execution("property-copy", node, [node.argument])] + : t.isArrayExpression(parent) + ? [execution("iteration", node, [node.argument])] + : // Call and construction arguments: classified by the invocation. + [], + VariableDeclarator: (node, parent) => [ + ...(t.isVariableDeclaration(parent) && + (parent.kind === "using" || parent.kind === "await using") + ? [execution("disposal", node, [node.init ?? node.id])] + : []), + ...patternSource(node, node.id, node.init), + ], + AwaitExpression: (node) => [execution("thenable", node, [node.argument])], + YieldExpression: (node) => [ + execution(node.delegate ? "iteration" : "suspension", node, [ + node.argument, + ]), + ], + ClassDeclaration: classDefinition, + ClassExpression: classDefinition, + WithStatement: (node) => [execution("dynamic-scope", node, [node.object])], + // JSX: the element is the runtime call; its parts are its operands. + JSXElement: jsxElement, + JSXFragment: jsxElement, + JSXAttribute: none, + JSXClosingElement: none, + JSXEmptyExpression: none, + JSXExpressionContainer: none, + JSXSpreadChild: none, + JSXIdentifier: none, + JSXMemberExpression: none, + JSXNamespacedName: none, + JSXOpeningElement: none, + JSXSpreadAttribute: none, + JSXText: none, + JSXOpeningFragment: none, + JSXClosingFragment: none, + // TypeScript declarations and erased wrappers. + TSAsExpression: none, + TSSatisfiesExpression: none, + TSTypeAssertion: none, + TSNonNullExpression: none, + TSInstantiationExpression: none, + TSDeclareFunction: none, + TSDeclareMethod: none, + TSInterfaceDeclaration: none, + TSInterfaceBody: none, + TSTypeAliasDeclaration: none, + TSTypeAnnotation: none, + TSTypeParameterInstantiation: none, + TSTypeParameterDeclaration: none, + TSTypeParameter: none, + TSQualifiedName: none, + TSNamedTupleMember: none, + TSNamespaceExportDeclaration: none, + TSExportAssignment: none, + TSExternalModuleReference: none, + TSEnumDeclaration: none, + TSEnumBody: none, + TSEnumMember: none, + TSModuleDeclaration: none, + TSModuleBlock: none, + // `import x = require(...)` evaluates a module; `import x = N.y` reads N.y. + TSImportEqualsDeclaration: (node) => [ + t.isTSExternalModuleReference(node.moduleReference) + ? execution("module-evaluation", node, []) + : execution("property-read", node, []), + ], + // Assignment semantics store the parameter on `this`. + TSParameterProperty: (node) => [ + { + kind: "property-write", + node, + target: node.parameter, + readsFirst: false, + operands: [], + }, + ], + // Deprecated Babel 6 spellings the parser no longer produces. + NumberLiteral: none, + RegexLiteral: none, + RestProperty: (node) => [execution("property-copy", node, [])], + SpreadProperty: (node) => [execution("property-copy", node, [node.argument])], + // Flow enums compile to a runtime call; the parser enables no Flow syntax. + EnumDeclaration: unsupported, + // Proposals the parser does not enable. + Placeholder: unsupported, + V8IntrinsicIdentifier: unsupported, + ArgumentPlaceholder: unsupported, + BindExpression: unsupported, + DoExpression: unsupported, + ModuleExpression: unsupported, + TopicReference: unsupported, +}; + +const isTypeOnly = (node: t.Node): node is TypeOnlyNode => + !t.isEnumDeclaration(node) && + typeOnlyAliases.some((alias) => t.is(alias, node)); + +/** + * The single owner for "may evaluating this node run code it does not spell + * as a call". An empty result is a proof that the node's own evaluation runs + * no user code; operands still classify separately. Unknown node types are + * classified as unsupported syntax rather than as inert. + */ +export const javaScriptExecutions = ( + node: t.Node, + parent: t.Node | null, + options: JavaScriptExecutionOptions = {}, +): readonly JavaScriptExecution[] => { + if (isTypeOnly(node)) return []; + const classify = (classifiers as Partial>>)[ + node.type + ]; + return classify === undefined + ? unsupported(node, parent, options) + : classify(node, parent, options); +}; + +/** Node types with an explicit classifier, for the coverage guard. */ +export const javaScriptClassifiedNodeTypes = (): readonly string[] => + Object.keys(classifiers); + +/** Babel aliases whose members have no runtime representation. */ +export const javaScriptTypeOnlyAliases = typeOnlyAliases; diff --git a/src/domain/javascript/javascriptSemanticIterationSources.ts b/src/domain/javascript/javascriptSemanticIterationSources.ts index 6b9d4c9052..cac03bf3b1 100644 --- a/src/domain/javascript/javascriptSemanticIterationSources.ts +++ b/src/domain/javascript/javascriptSemanticIterationSources.ts @@ -14,6 +14,7 @@ import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { semanticCopiedReferencePath } from "./javascriptSemanticCopiedReferencePaths.js"; import { semanticMethodDefinitionExpression } from "./javascriptSemanticEvaluationOrder.js"; +import { javaScriptExecutions } from "./javascriptSemanticExecutionPoints.js"; import { childNodes } from "./javascriptSemanticTraversal.js"; import type { IterationSourceValue, @@ -294,21 +295,13 @@ const guardedBodyMayCall = (body: t.BlockStatement): boolean => { for (const child of children) pending.push({ node: child, parent: node, finish: false }); } else { + // Without scope proof an identifier may resolve through a global accessor, + // including when its enclosing operator itself performs no coercion. const direct = - t.isCallExpression(node) || - t.isOptionalCallExpression(node) || - t.isNewExpression(node) || - t.isTaggedTemplateExpression(node) || - t.isMemberExpression(node) || - t.isOptionalMemberExpression(node) || - t.isBinaryExpression(node) || - t.isUnaryExpression(node) || - t.isUpdateExpression(node) || - t.isSpreadElement(node) || - t.isForOfStatement(node) || - t.isAwaitExpression(node) || - ((t.isVariableDeclarator(node) || t.isAssignmentExpression(node)) && - semanticSourceFacts(node, parent).propertyReads.length > 0); + javaScriptExecutions(node, parent).length > 0 || + (t.isIdentifier(node) && + parent !== null && + t.isReferenced(node, parent)); guardedExecutions.set( node, direct || diff --git a/src/domain/javascript/javascriptSemanticLanguageInvocations.ts b/src/domain/javascript/javascriptSemanticLanguageInvocations.ts deleted file mode 100644 index 6c26a5d2ae..0000000000 --- a/src/domain/javascript/javascriptSemanticLanguageInvocations.ts +++ /dev/null @@ -1,43 +0,0 @@ -import * as t from "@babel/types"; - -/** Operands exposed to user-defined primitive conversion by language operations. */ -export const semanticCoercionOperands = ( - node: t.Node, - isObject: (operand: t.Node) => boolean = () => false, -): readonly t.Node[] => { - if (t.isBinaryExpression(node)) { - if (["===", "!=="].includes(node.operator)) return []; - if (node.operator === "instanceof") return [node.left, node.right]; - if ( - ["==", "!="].includes(node.operator) && - ((t.isThisExpression(node.left) && t.isThisExpression(node.right)) || - (isObject(node.left) && isObject(node.right))) - ) - return []; - return node.operator === "in" ? [node.left] : [node.left, node.right]; - } - if (t.isUnaryExpression(node) && ["+", "-", "~"].includes(node.operator)) - return [node.argument]; - if (t.isUpdateExpression(node)) return [node.argument]; - if ( - t.isAssignmentExpression(node) && - !["=", "&&=", "||=", "??="].includes(node.operator) - ) - return [node.left, node.right]; - if ( - (t.isMemberExpression(node) || - t.isOptionalMemberExpression(node) || - t.isObjectProperty(node) || - t.isObjectMethod(node) || - t.isClassProperty(node) || - t.isClassMethod(node) || - t.isClassAccessorProperty(node)) && - node.computed - ) - return [ - t.isMemberExpression(node) || t.isOptionalMemberExpression(node) - ? node.property - : node.key, - ]; - return []; -}; diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index a8172a74ef..fc0c845488 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -69,7 +69,11 @@ import { semanticCaptureOrderingIdentity, semanticScopeExitEffect, } from "./javascriptSemanticMutationInitializers.js"; -import { semanticCoercionOperands } from "./javascriptSemanticLanguageInvocations.js"; +import { + javaScriptDefinitionKeyCoercions, + javaScriptDefinitionTimeExpressions, + javaScriptExecutions, +} from "./javascriptSemanticExecutionPoints.js"; import { semanticKnownArrayIteration, semanticReferenceSourcesInactive, @@ -89,10 +93,7 @@ import { childNodes, } from "./javascriptSemanticTraversal.js"; import { - javaScriptComputedDefinitionKey, semanticStaticPropertyKey, - semanticWriteSiteTarget, - semanticWriteSiteReadsFirst, semanticWriteTargets, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; @@ -394,40 +395,6 @@ const accessorOperands = new WeakMap< readonly AccessorEffectOperand[] >(); -/** Members whose computed keys a definition evaluates and converts immediately. */ -const computedDefinitionMembers = (node: t.Function | t.Class): t.Node[] => { - if (t.isObjectMethod(node)) - return javaScriptComputedDefinitionKey(node, node.key) ? [node] : []; - if (!t.isClass(node)) return []; - return node.body.body.filter( - (member) => - "key" in member && javaScriptComputedDefinitionKey(member, member.key), - ); -}; - -/** - * Expressions a definition runs immediately: heritage, computed keys, static - * blocks and static field values. Deferred bodies and instance initializers - * stay out; `this` inside static work is the class, not the enclosing receiver. - */ -const definitionTimeExpressions = (node: t.Function | t.Class): t.Node[] => [ - ...(t.isClass(node) && node.superClass != null ? [node.superClass] : []), - ...computedDefinitionMembers(node).flatMap((member) => - "key" in member ? [member.key] : [], - ), - ...(t.isClass(node) - ? node.body.body.flatMap((member): t.Node[] => - t.isStaticBlock(member) - ? [member] - : (t.isClassProperty(member) || t.isClassPrivateProperty(member)) && - member.static && - member.value != null - ? [member.value] - : [], - ) - : []), -]; - /** Collect invocation operands once; receiver interpretation belongs to the getter owner. */ const accessorEffectOperands = ( method: t.Function, @@ -435,6 +402,9 @@ const accessorEffectOperands = ( const cached = accessorOperands.get(method); if (cached !== undefined) return cached; const operands: AccessorEffectOperand[] = []; + const escape = (nodes: readonly t.Node[]): void => { + for (const node of nodes) operands.push({ kind: "escape", node }); + }; const pending: { node: t.Node; parent: t.Node | null }[] = [ { node: method.body, parent: method }, ]; @@ -444,66 +414,75 @@ const accessorEffectOperands = ( // Defining a nested callable does not invoke it, but the definition itself // evaluates heritage and computed keys against the enclosing receiver. if (t.isFunction(node) || t.isClass(node)) { - // Defining a computed member also converts its key to a property name. - for (const member of computedDefinitionMembers(node)) - for (const operand of semanticCoercionOperands(member)) - operands.push({ kind: "escape", node: operand }); - for (const child of definitionTimeExpressions(node).reverse()) + escape(javaScriptDefinitionKeyCoercions(node)); + for (const child of javaScriptDefinitionTimeExpressions(node).reverse()) pending.push({ node: child, parent: node }); continue; } - const write = semanticWriteSiteTarget(node); - if (write !== null) { - for (const target of semanticWriteTargets(write)) { - if (!semanticWriteSiteReadsFirst(node)) writeOnly.add(target); - if (!t.isIdentifier(target)) - operands.push({ kind: "escape", node: target.object }); + for (const execution of javaScriptExecutions(node, parent)) + switch (execution.kind) { + case "property-write": + if (!execution.readsFirst) + for (const target of semanticWriteTargets(execution.target)) + writeOnly.add(target); + escape(execution.operands); + break; + case "property-read": + if ( + (t.isMemberExpression(node) || + t.isOptionalMemberExpression(node)) && + !writeOnly.has(node) + ) + operands.push({ + kind: "read", + node: node.object, + path: [semanticStaticPropertyKey(node.property, node.computed)], + lookup: "prototype", + }); + break; + case "call": + case "construction": + case "tagged-template": + if (t.isTaggedTemplateExpression(node)) + operands.push({ kind: "invoke", node: node.tag }); + else if ( + t.isCallExpression(node) || + t.isOptionalCallExpression(node) || + t.isNewExpression(node) + ) + operands.push({ + kind: "invoke", + node: node.callee, + ...(t.isNewExpression(node) ? {} : { invocation: node }), + }); + escape(execution.operands); + break; + case "coercion": + case "disposal": + escape(execution.operands); + break; + // Projected getter and `then` reads belong to the read-source owner. + case "destructuring": + case "property-copy": + case "thenable": + // Definitions are handled above without entering deferred bodies. + case "class-definition": + break; + // Not applied yet: these may run code with their operands, but this + // collector has never modelled them. Listed so the gap is explicit. + case "iteration": + case "enumeration": + case "suspension": + case "decorator": + case "dynamic-import": + case "dynamic-scope": + case "jsx-element": + case "module-evaluation": + case "unsupported-syntax": + break; + default: + execution satisfies never; } - } - if ( - (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) && - !writeOnly.has(node) - ) - operands.push({ - kind: "read", - node: node.object, - path: [semanticStaticPropertyKey(node.property, node.computed)], - lookup: "prototype", - }); - if ( - t.isCallExpression(node) || - t.isOptionalCallExpression(node) || - t.isNewExpression(node) - ) { - const callee = unwrapJavaScriptExpression(node.callee).node; - operands.push({ - kind: "invoke", - node: node.callee, - ...(t.isNewExpression(node) ? {} : { invocation: node }), - }); - if ( - !t.isNewExpression(node) && - (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) - ) - operands.push({ kind: "escape", node: callee.object }); - for (const argument of node.arguments) - operands.push({ - kind: "escape", - node: t.isSpreadElement(argument) ? argument.argument : argument, - }); - } else if (t.isTaggedTemplateExpression(node)) { - operands.push({ kind: "invoke", node: node.tag }); - const tag = unwrapJavaScriptExpression(node.tag).node; - if (t.isMemberExpression(tag) || t.isOptionalMemberExpression(tag)) - operands.push({ kind: "escape", node: tag.object }); - for (const expression of node.quasi.expressions) - operands.push({ kind: "escape", node: expression }); - } - for (const operand of semanticCoercionOperands(node)) - operands.push({ kind: "escape", node: operand }); - if (t.isTemplateLiteral(node) && !t.isTaggedTemplateExpression(parent)) - for (const expression of node.expressions) - operands.push({ kind: "escape", node: expression }); for (const source of semanticPropertyReadSources(node, parent)) operands.push({ kind: "read", @@ -513,12 +492,6 @@ const accessorEffectOperands = ( ...(source.invoke ? { invoke: true } : {}), guards: source, }); - if ( - t.isVariableDeclarator(node) && - t.isVariableDeclaration(parent) && - (parent.kind === "using" || parent.kind === "await using") - ) - operands.push({ kind: "escape", node: node.init ?? node.id }); for (const child of childNodes(node).reverse()) pending.push({ node: child, parent: node }); } @@ -1778,13 +1751,72 @@ export function* collectSemanticMemberMutationsSteps( sourcesByTarget.get(target) ?? [], ); }; + /** Disposal runs when the declaration's lexical scope exits. */ + const markDisposal = ( + node: t.Node, + readAncestors: () => readonly t.Node[], + ): void => { + if (!t.isVariableDeclarator(node)) return; + const scope = readAncestors().findLast( + (ancestor) => + t.isBlockStatement(ancestor) || + t.isProgram(ancestor) || + t.isStaticBlock(ancestor) || + t.isSwitchStatement(ancestor) || + t.isLoop(ancestor), + ); + if (scope === undefined) return; + const exit = semanticScopeExitEffect(scope); + if (t.isIdentifier(node.id)) + markValue( + node.id, + [], + new Set(), + "escape", + exit, + node.id, + node.init ?? undefined, + ); + else if (node.init != null) markEscaped(node.init, exit); + }; + const markInvocation = ( + node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, + ): void => { + for (const source of returnedReferences(node.callee).references) + markEscaped(source.node, node, source.projection); + for (const argument of node.arguments) + if (t.isSpreadElement(argument)) + deferIterable({ + node: argument.argument, + projection: [], + path: [null], + fallbackPath: [], + bindings: new Set(), + effect: "escape", + mutation: node, + originAt: node, + }); + else markEscaped(argument, node); + // Constructing a member does not pass its container as `this`. + if (!t.isNewExpression(node)) { + const mapLookup = isIntrinsicMapLookup(node, state); + if (mapLookup === undefined) deferredMapReceivers.add(node); + else if (!mapLookup) markReceiver(node.callee, node); + } + }; const writeOnlyMembers = new WeakSet(); yield* traverseJavaScriptValueAstSteps(program, state.erasedSyntaxNodes, { enter: (node, parent, readAncestors) => { - const writeTarget = semanticWriteSiteTarget(node); - if (writeTarget !== null && !semanticWriteSiteReadsFirst(node)) - for (const target of semanticWriteTargets(writeTarget)) - writeOnlyMembers.add(target); + const executions = javaScriptExecutions(node, parent, { + isObject: (operand) => { + const value = evaluateSemanticExpression(operand, state, node); + return value.status === "object" || value.status === "array"; + }, + }); + for (const execution of executions) + if (execution.kind === "property-write" && !execution.readsFirst) + for (const target of semanticWriteTargets(execution.target)) + writeOnlyMembers.add(target); for (const source of semanticIterationSources(node, parent)) { // Local stores retain their sources on member writes. Only the ambient // identity owner can establish that storing itself exposes a reference. @@ -1816,42 +1848,6 @@ export function* collectSemanticMemberMutationsSteps( iterationOnly: source.kind === "iteration", }); } - for (const operand of semanticCoercionOperands(node, (operand) => { - const value = evaluateSemanticExpression(operand, state, node); - return value.status === "object" || value.status === "array"; - })) - if (!semanticKnownSymbolKey(operand, state)) markEscaped(operand, node); - if (t.isTemplateLiteral(node) && !t.isTaggedTemplateExpression(parent)) - for (const expression of node.expressions) - markEscaped(expression, node); - if ( - t.isVariableDeclarator(node) && - t.isVariableDeclaration(parent) && - (parent.kind === "using" || parent.kind === "await using") - ) { - const scope = readAncestors().findLast( - (ancestor) => - t.isBlockStatement(ancestor) || - t.isProgram(ancestor) || - t.isStaticBlock(ancestor) || - t.isSwitchStatement(ancestor) || - t.isLoop(ancestor), - ); - if (scope !== undefined) { - const exit = semanticScopeExitEffect(scope); - if (t.isIdentifier(node.id)) - markValue( - node.id, - [], - new Set(), - "escape", - exit, - node.id, - node.init ?? undefined, - ); - else if (node.init != null) markEscaped(node.init, exit); - } - } for (const source of semanticPropertyReadSources(node, parent, state)) { if (semanticReferenceSourcesInactive(source, state)) continue; for (const effect of propertyReadEffects( @@ -1862,48 +1858,72 @@ export function* collectSemanticMemberMutationsSteps( )) markEscaped(effect.node, source.mutation, effect.path); } - if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { - // The write owner classifies direct and destructuring stores once. - if (!writeOnlyMembers.has(node)) - for (const effect of propertyReadEffects(node.object, [ - semanticStaticPropertyKey(node.property, node.computed), - ])) - markEscaped(effect.node, node, effect.path); - } - if (writeTarget !== null) markTarget(writeTarget, node); - else if ( - t.isCallExpression(node) || - t.isOptionalCallExpression(node) || - t.isNewExpression(node) - ) { - for (const source of returnedReferences(node.callee).references) - markEscaped(source.node, node, source.projection); - for (const argument of node.arguments) - if (t.isSpreadElement(argument)) - deferIterable({ - node: argument.argument, - projection: [], - path: [null], - fallbackPath: [], - bindings: new Set(), - effect: "escape", - mutation: node, - originAt: node, - }); - else markEscaped(argument, node); - // Constructing a member does not pass its container as `this`. - if (!t.isNewExpression(node)) { - const mapLookup = isIntrinsicMapLookup(node, state); - if (mapLookup === undefined) deferredMapReceivers.add(node); - else if (!mapLookup) markReceiver(node.callee, node); + for (const execution of executions) + switch (execution.kind) { + case "coercion": + for (const operand of execution.operands) + if (!semanticKnownSymbolKey(operand, state)) + markEscaped(operand, node); + break; + case "disposal": + markDisposal(node, readAncestors); + break; + case "property-read": + // The write owner classifies direct and destructuring stores once. + if ( + (t.isMemberExpression(node) || + t.isOptionalMemberExpression(node)) && + !writeOnlyMembers.has(node) + ) + for (const effect of propertyReadEffects(node.object, [ + semanticStaticPropertyKey(node.property, node.computed), + ])) + markEscaped(effect.node, node, effect.path); + break; + case "property-write": + markTarget(execution.target, node); + break; + case "call": + case "construction": + if ( + t.isCallExpression(node) || + t.isOptionalCallExpression(node) || + t.isNewExpression(node) + ) + markInvocation(node); + break; + case "tagged-template": + if (t.isTaggedTemplateExpression(node)) { + for (const source of returnedReferences(node.tag).references) + markEscaped(source.node, node, source.projection); + markReceiver(node.tag, node); + for (const expression of node.quasi.expressions) + markEscaped(expression, node); + } + break; + // Projected getter, iterator and `then` reads belong to the + // read-source and iteration owners above. + case "destructuring": + case "property-copy": + case "iteration": + case "thenable": + // Definition-time code is traversed as ordinary code. + case "class-definition": + break; + // Not applied yet: these may run code with their operands, but this + // collector has never modelled them. Listed so the gap is explicit. + case "suspension": + case "enumeration": + case "decorator": + case "dynamic-import": + case "dynamic-scope": + case "jsx-element": + case "module-evaluation": + case "unsupported-syntax": + break; + default: + execution satisfies never; } - } else if (t.isTaggedTemplateExpression(node)) { - for (const source of returnedReferences(node.tag).references) - markEscaped(source.node, node, source.projection); - markReceiver(node.tag, node); - for (const expression of node.quasi.expressions) - markEscaped(expression, node); - } }, }); // A later effect can make a previously defined destructuring source uncertain. diff --git a/tests/boundary/providers/hopper/hopperClient.handshakeRecovery.test.ts b/tests/boundary/providers/hopper/hopperClient.handshakeRecovery.test.ts index 9434662c70..7c90371324 100644 --- a/tests/boundary/providers/hopper/hopperClient.handshakeRecovery.test.ts +++ b/tests/boundary/providers/hopper/hopperClient.handshakeRecovery.test.ts @@ -254,6 +254,27 @@ it("preserves launcher diagnostics after a health deadline and reauthenticates f it("recovers an application exit after the first valid handshake occurs during cleanup", async () => { const { client, launcher } = setup("matching", "gated", "exit-without-ack"); + const finishClose = async ( + closed: Awaited>, + index: number, + ) => { + if (!closed.ok) { + expect(projectAnalysisError(closed.error)).toMatchObject({ + code: "cleanup_incomplete", + details: { + diagnostics: { + authenticated_application: "readable", + shutdown_request: { method: "shutdown_document", status: "failed" }, + }, + }, + }); + const application = launcher.fixture.processes[index]; + if (application === undefined) throw new Error("Fixture did not launch"); + await waitFor(() => expect(application.exitCode).toBe(0)); + closed = await client.close(); + } + expect(closed.ok ? null : projectAnalysisError(closed.error)).toBeNull(); + }; const starting = client.start(); const first = await launcher.fixture.waitForRequest("health"); const started = await starting; @@ -286,7 +307,7 @@ it("recovers an application exit after the first valid handshake occurs during c }); await launcher.fixture.release(next); const closed = await closing; - if (!closed.ok) throw closed.error; + await finishClose(closed, 0); expect(launcher.fixture.requests.map(({ method }) => method)).toEqual([ "health", "health", @@ -313,5 +334,5 @@ it("recovers an application exit after the first valid handshake occurs during c if (!observed.ok) throw observed.error; expect(observed.value).toBe("fixture"); const reclosed = await client.close(); - if (!reclosed.ok) throw reclosed.error; + await finishClose(reclosed, 1); });