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
This commit is contained in:
morluto authored and GitHub committed 2026-11-14 14:13:07 -05:00
1 parent 4502bd44bb
commit a874fcc4e3
6 files changed
+947 -240

No files matched your search

@@ -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<string>(
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<string, string[]> => {
const kinds = new Map<string, string[]>();
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"] }],
["<Element prop={value} />;", { 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"]),
}),
]),
);
},
);
@@ -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<JavaScriptExecutionKind, "property-write">;
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 extends t.Node> = (
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<t.Node["type"], TypeOnlyNode["type"]>;
const typeOnlyAliases = ["Flow", "TSType", "TSTypeElement"] as const;
const none = (): readonly JavaScriptExecution[] => [];
const execution = (
kind: Exclude<JavaScriptExecutionKind, "property-write">,
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<t.Node> = (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<t.CallExpression | t.OptionalCallExpression> = (
node,
) => [
execution("call", node, [
memberReceiver(node.callee),
...argumentValues(node.arguments),
]),
];
const member: Classifier<t.MemberExpression | t.OptionalMemberExpression> = (
node,
parent,
options,
) => [
execution("property-read", node, [node.object]),
...coercion(node, parent, options),
];
const computedMember: Classifier<t.Node> = 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<t.ClassDeclaration | t.ClassExpression> = (
node,
) => [
// Evaluating heritage reads `prototype` from the superclass.
execution("class-definition", node, [node.superClass]),
];
const jsxElement: Classifier<t.JSXElement | t.JSXFragment> = (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<t.Node> = (node) => [
execution("unsupported-syntax", node, []),
];
const moduleEvaluation: Classifier<t.Node> = (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<t.Node, { type: Type }>
>;
} = {
// 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<Record<string, Classifier<t.Node>>>)[
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;
@@ -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 ||
@@ -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 [];
};
@@ -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<t.Node>();
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.