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;