fix(javascript): expose operands of unmodelled implicit execution (#10631)

* fix(javascript): expose operands of unmodelled implicit execution

The member-mutation collectors now escape the operands of every execution
kind they did not apply: yielded values, JSX element operands, dynamic
import specifiers and options, decorated classes, with-statement scope
objects, iteration inside accessors and syntax the classification does not
describe. Each of these left a literal where the equivalent explicit call
already yields unknown.

Decorators are classified at the class definition that calls them and are
evaluated as definition-time expressions.

* fix(javascript): expose decorated instance members and keep a decorator's outer this

A class's decorator execution now exposes decorated members' functions and initializers, which a decorator can call without the class running them. The getter receiver walk uses the shared javaScriptDefinitionExpression owner, so class and member decorators see the enclosing this like heritage and computed keys.
This commit is contained in:
morluto authored and GitHub committed 2026-11-15 08:37:09 +08:00
1 parent 8c052bdec5
commit f56b417a58
5 files changed
+225 -82

No files matched your search

@@ -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 = <Foo p={state} />; return state.v; }`,
],
[
"a JSX spread attribute",
`export function observe() { const state = { v: 1 }; const r = <Foo {...state} />; 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 <Foo p={this} />; } }; 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 = <Foo p={other} />; 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 });
});
@@ -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"] }],
@@ -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<t.MemberExpression | t.OptionalMemberExpression> = (
const computedMember: Classifier<t.Node> = 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<t.ClassDeclaration | t.ClassExpression> = (
) => [
// 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<t.JSXElement | t.JSXFragment> = (node) => {
@@ -306,8 +338,9 @@ const jsxElement: Classifier<t.JSXElement | t.JSXFragment> = (node) => {
];
};
/** Syntax this table does not describe exposes every operand it holds. */
const unsupported: Classifier<t.Node> = (node) => [
execution("unsupported-syntax", node, []),
execution("unsupported-syntax", node, childNodes(node)),
];
const moduleEvaluation: Classifier<t.Node> = (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,
@@ -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 {
@@ -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<t.Node>();
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<t.Node>();
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;