diff --git a/src/domain/javascript/javascriptSemanticAnalysis.execution_points.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.execution_points.test.ts new file mode 100644 index 0000000000..9acb9a8e82 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.execution_points.test.ts @@ -0,0 +1,99 @@ +import { expect, it } from "vitest"; +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js"; + +const returned = (source: string) => + onlyCallable(analyzeJavaScriptSemantics(source), "observe").returnSites[0] + ?.value; + +// Each form may run code that receives `state`; the equivalent explicit call +// already yields an unknown value. +it.each([ + [ + "a JSX attribute", + `export function observe() { const state = { v: 1 }; const r = ; return state.v; }`, + ], + [ + "a JSX spread attribute", + `export function observe() { const state = { v: 1 }; const r = ; return state.v; }`, + ], + [ + "a yielded value", + `export function* observe() { const state = { v: 1 }; yield state; return state.v; }`, + ], + [ + "a dynamic import specifier", + `export async function observe() { const state = { v: 1, toString() { this.v = 2; return "x"; } }; import(state); return state.v; }`, + ], + [ + "dynamic import options", + `export async function observe() { const state = { v: 1, get with() { this.v = 2; return undefined; } }; import("x", state); return state.v; }`, + ], + [ + "a decorated class holding the value", + `export function observe() { const state = { v: 1 }; const dec = (c) => c; @dec class C { static s = state; } return state.v; }`, + ], + [ + "a decorated instance method returning the value", + `export function observe() { const state = { v: 1 }; const dec = (target, key, descriptor) => { descriptor.value().v = 2; }; class C { @dec m() { return state; } } return state.v; }`, + ], + [ + "a decorated instance field initialized with the value", + `export function observe() { const state = { v: 1 }; const dec = (target, key) => {}; class C { @dec f = () => state; } return state.v; }`, + ], + [ + "a class decorator receiving a getter's receiver", + `export function observe() { const consume = (o) => { o.v = 2; return (c) => c; }; const state = { v: 1, get trigger() { @consume(this) class C {} return 0; } }; const x = state.trigger; return state.v; }`, + ], + [ + "a member decorator receiving a getter's receiver", + `export function observe() { const consume = (o) => { o.v = 2; return () => {}; }; const state = { v: 1, get trigger() { class C { @consume(this) m() {} } return 0; } }; const x = state.trigger; return state.v; }`, + ], + [ + "a with-statement scope object", + `export function observe() { const state = { v: 1, get x() { this.v = 2; return 0; } }; with (state) { x; } return state.v; }`, + ], + [ + "array spread inside a getter", + `export function observe() { const state = { v: 1, *[Symbol.iterator]() { this.v = 2; }, get trigger() { const a = [...this]; return 0; } }; const x = state.trigger; return state.v; }`, + ], + [ + "JSX inside a getter", + `export function observe() { const state = { v: 1, get trigger() { return ; } }; const x = state.trigger; return state.v; }`, + ], + [ + "dynamic import inside a getter", + `export function observe() { const state = { v: 1, toString() { this.v = 2; return "x"; }, get trigger() { import(this); return 0; } }; const x = state.trigger; return state.v; }`, + ], + [ + "a with-statement inside a getter", + `export function observe() { const state = { v: 1, get x() { this.v = 2; return 0; }, get trigger() { with (this) { x; } return 0; } }; const y = state.trigger; return state.v; }`, + ], + [ + "for-in over a Proxy whose traps capture the value", + `export function observe() { const state = { v: 1 }; const handler = { ownKeys() { state.v = 2; return []; } }; const proxy = new Proxy({}, handler); for (const key in proxy) {} return state.v; }`, + ], +])("does not keep a literal across %s", (_, source) => { + expect(returned(source)?.status).toBe("unknown"); +}); + +it.each([ + [ + "JSX that receives another value", + `export function observe() { const state = { v: 1 }; const other = { w: 2 }; const r = ; return state.v; }`, + ], + [ + "a yielded primitive", + `export function* observe() { const state = { v: 1 }; yield 3; return state.v; }`, + ], + [ + "an undecorated class", + `export function observe() { const state = { v: 1 }; class C { static s = state; } return state.v; }`, + ], + [ + "for-in over an ordinary object", + `export function observe() { const state = { v: 1 }; for (const key in state) {} return state.v; }`, + ], +])("keeps the literal across %s", (_, source) => { + expect(returned(source)).toEqual({ status: "literal", value: 1 }); +}); diff --git a/src/domain/javascript/javascriptSemanticExecutionPoints.test.ts b/src/domain/javascript/javascriptSemanticExecutionPoints.test.ts index 800fb9f0a4..338ba6a178 100644 --- a/src/domain/javascript/javascriptSemanticExecutionPoints.test.ts +++ b/src/domain/javascript/javascriptSemanticExecutionPoints.test.ts @@ -102,7 +102,7 @@ it.each([ ["class C extends Base {}", { ClassDeclaration: ["class-definition"] }], [ "@decorate class C {}", - { ClassDeclaration: ["class-definition"], Decorator: ["decorator"] }, + { ClassDeclaration: ["class-definition", "decorator"] }, ], ["import(value);", { ImportExpression: ["dynamic-import"] }], ["with (value) key;", { WithStatement: ["dynamic-scope"] }], diff --git a/src/domain/javascript/javascriptSemanticExecutionPoints.ts b/src/domain/javascript/javascriptSemanticExecutionPoints.ts index 97996d6c7e..7f6a77a2b1 100644 --- a/src/domain/javascript/javascriptSemanticExecutionPoints.ts +++ b/src/domain/javascript/javascriptSemanticExecutionPoints.ts @@ -6,6 +6,7 @@ import { semanticWriteTargets, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; +import { childNodes } from "./javascriptSemanticTraversal.js"; /** * How evaluating one syntax node can run code that the analyzed source does @@ -31,7 +32,8 @@ import { * - `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. + * - `decorator`: a class definition calls its decorators with the class or + * its members. * - `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. @@ -236,6 +238,14 @@ const member: Classifier = ( const computedMember: Classifier = coercion; +/** Decorators on the class and its members, called with the class definition. */ +const classDecorators = (node: t.Class): readonly t.Decorator[] => [ + ...(node.decorators ?? []), + ...node.body.body.flatMap((member) => + "decorators" in member ? (member.decorators ?? []) : [], + ), +]; + /** Members whose computed keys a definition evaluates and converts immediately. */ const computedDefinitionMembers = (node: t.Function | t.Class): t.Node[] => { if (t.isObjectMethod(node)) @@ -248,8 +258,8 @@ const computedDefinitionMembers = (node: t.Function | t.Class): t.Node[] => { }; /** - * Expressions a definition runs immediately: heritage, computed keys, static - * blocks and static field values. Deferred bodies and instance initializers + * Expressions a definition runs immediately: heritage, computed keys, + * decorators, 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 = ( @@ -259,6 +269,7 @@ export const javaScriptDefinitionTimeExpressions = ( ...computedDefinitionMembers(node).flatMap((member) => "key" in member ? [member.key] : [], ), + ...(t.isClass(node) ? classDecorators(node) : []), ...(t.isClass(node) ? node.body.body.flatMap((member): t.Node[] => t.isStaticBlock(member) @@ -285,6 +296,27 @@ const classDefinition: Classifier = ( ) => [ // Evaluating heritage reads `prototype` from the superclass. execution("class-definition", node, [node.superClass]), + // Each decorator is called with the class or one of its members, and a + // member decorator reaches the member's function or initializer, including + // an instance member the class itself does not run. + ...(classDecorators(node).length === 0 + ? [] + : [ + execution("decorator", node, [ + node, + ...node.body.body.flatMap((member): t.Node[] => + !("decorators" in member) || (member.decorators ?? []).length === 0 + ? [] + : t.isClassMethod(member) || t.isClassPrivateMethod(member) + ? [member] + : "value" in member && + member.value !== null && + member.value !== undefined + ? [member.value] + : [], + ), + ]), + ]), ]; const jsxElement: Classifier = (node) => { @@ -306,8 +338,9 @@ const jsxElement: Classifier = (node) => { ]; }; +/** Syntax this table does not describe exposes every operand it holds. */ const unsupported: Classifier = (node) => [ - execution("unsupported-syntax", node, []), + execution("unsupported-syntax", node, childNodes(node)), ]; const moduleEvaluation: Classifier = (node) => [ @@ -414,8 +447,8 @@ const classifiers: { ...node.quasi.expressions, ]), ], - // The decorator expression is the callee; the decorated declaration escapes. - Decorator: (node, parent) => [execution("decorator", node, [parent])], + // Evaluating the decorator expression is ordinary; the class calls it. + Decorator: none, // Accessors, conversion and protocols. MemberExpression: member, OptionalMemberExpression: member, diff --git a/src/domain/javascript/javascriptSemanticGetterResultWalker.ts b/src/domain/javascript/javascriptSemanticGetterResultWalker.ts index d301c90860..0caa1570a5 100644 --- a/src/domain/javascript/javascriptSemanticGetterResultWalker.ts +++ b/src/domain/javascript/javascriptSemanticGetterResultWalker.ts @@ -1,6 +1,6 @@ import * as t from "@babel/types"; import { - javaScriptComputedDefinitionKey, + javaScriptDefinitionExpression, unwrapJavaScriptExpression, semanticStaticPropertyKey, } from "./javascriptAstValues.js"; @@ -78,11 +78,10 @@ const resultBranches = (node: t.Node): readonly t.Node[] | undefined => { return undefined; }; -/** Heritage and computed keys see the `this` around their definition. */ +/** Heritage, decorators and computed keys see the `this` around their definition. */ const evaluatesInOuterThis = (owner: t.Node, child: t.Node): boolean => - t.isClass(owner) - ? child === owner.superClass - : javaScriptComputedDefinitionKey(owner, child); + (t.isClass(owner) && child === owner.superClass) || + javaScriptDefinitionExpression(owner, child); /** Each walk owns local aliases; getter-to-getter traversal belongs to the query worklist. */ export class GetterResultWalker { diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index fc0c845488..d12576008f 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -73,6 +73,7 @@ import { javaScriptDefinitionKeyCoercions, javaScriptDefinitionTimeExpressions, javaScriptExecutions, + type JavaScriptExecution, } from "./javascriptSemanticExecutionPoints.js"; import { semanticKnownArrayIteration, @@ -405,84 +406,91 @@ const accessorEffectOperands = ( const escape = (nodes: readonly t.Node[]): void => { for (const node of nodes) operands.push({ kind: "escape", node }); }; + const writeOnly = new WeakSet(); + const apply = (execution: JavaScriptExecution): void => { + const { node } = execution; + switch (execution.kind) { + case "property-write": + if (!execution.readsFirst) + for (const target of semanticWriteTargets(execution.target)) + writeOnly.add(target); + escape(execution.operands); + return; + 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", + }); + return; + 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); + return; + // Projected getter and `then` reads belong to the read-source owner. + case "destructuring": + case "property-copy": + case "thenable": + // Static module evaluation finishes before this module's code runs. + case "module-evaluation": + // Ordinary objects enumerate without user code; Proxy traps escaped + // when the Proxy was constructed. + case "enumeration": + return; + // Without a narrower proof, code that may run receives its operands. + case "class-definition": + case "coercion": + case "disposal": + case "iteration": + case "suspension": + case "decorator": + case "dynamic-import": + case "dynamic-scope": + case "jsx-element": + case "unsupported-syntax": + escape(execution.operands); + return; + default: + execution satisfies never; + } + }; const pending: { node: t.Node; parent: t.Node | null }[] = [ { node: method.body, parent: method }, ]; - const writeOnly = new WeakSet(); while (pending.length > 0) { const { node, parent } = pending.pop()!; // Defining a nested callable does not invoke it, but the definition itself - // evaluates heritage and computed keys against the enclosing receiver. + // evaluates heritage, decorators and computed keys against the enclosing + // receiver. if (t.isFunction(node) || t.isClass(node)) { + if (t.isClass(node)) + for (const execution of javaScriptExecutions(node, parent)) + apply(execution); escape(javaScriptDefinitionKeyCoercions(node)); for (const child of javaScriptDefinitionTimeExpressions(node).reverse()) pending.push({ node: child, parent: node }); continue; } 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; - } + apply(execution); for (const source of semanticPropertyReadSources(node, parent)) operands.push({ kind: "read", @@ -1909,17 +1917,21 @@ export function* collectSemanticMemberMutationsSteps( 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": + // Static module evaluation finishes before this module's code runs. + case "module-evaluation": + // Ordinary objects enumerate without user code; Proxy traps escaped + // when the Proxy was constructed. case "enumeration": + break; + // Without a narrower proof, code that may run receives its operands. + case "suspension": case "decorator": case "dynamic-import": case "dynamic-scope": case "jsx-element": - case "module-evaluation": case "unsupported-syntax": + for (const operand of execution.operands) + markEscaped(operand, node); break; default: execution satisfies never;