fix(javascript): retain uncertainty for conditionally skipped writes

Own skipped-write interpretation at definition collection, including conditional branches, loop iterations and optional-chain arguments. Remove the narrower initializer-only representation.

Fixes #2562
This commit is contained in:
morluto committed 2026-10-14 14:47:26 +08:00
1 parent 36c91428fc
commit 410537667e
7 files changed
+172 -33

No files matched your search

@@ -685,7 +685,7 @@ const createState = (
moduleLinks: [],
linkedRequireCalls: new WeakSet(),
moduleLinkBindings: new WeakMap(),
conditionalInitializers: new WeakSet(),
conditionallySkippedExpressions: new WeakSet(),
requiredModuleEffects: new Map(),
ambientBindingWrites: new Map(),
ambientWrites: new SemanticPropertyPathCoverage(),
@@ -704,6 +704,7 @@ function* collectDefinitionsSteps(
const assignments: {
readonly node: t.Node;
readonly scope: JavaScriptSemanticScopeState;
readonly conditionallySkipped: boolean;
}[] = [];
const functionBodies: {
readonly body: t.BlockStatement;
@@ -779,16 +780,23 @@ function* collectDefinitionsSteps(
node.init != null &&
parent !== null &&
t.isVariableDeclaration(parent, { kind: "var" }) &&
hasConditionalInitializer(readAncestors())
writeMayBeSkipped(node, readAncestors())
)
state.conditionalInitializers.add(node.init);
state.conditionallySkippedExpressions.add(node.init);
if (
t.isAssignmentExpression(node) ||
t.isUpdateExpression(node) ||
t.isForOfStatement(node) ||
t.isForInStatement(node)
)
assignments.push({ node, scope });
assignments.push({
node,
scope,
conditionallySkipped:
t.isForOfStatement(node) ||
t.isForInStatement(node) ||
writeMayBeSkipped(node, readAncestors()),
});
},
exit: (node) => {
for (let count = openedScopes.get(node) ?? 0; count > 0; count--)
@@ -797,9 +805,12 @@ function* collectDefinitionsSteps(
});
// All declarations must exist before resolving writes, including hoisted
// functions and local declarations that shadow an outer binding.
for (const [index, { node, scope }] of assignments.entries()) {
for (const [
index,
{ node, scope, conditionallySkipped },
] of assignments.entries()) {
if (index % 64 === 0) yield;
bindAssignment(node, scope, state);
bindAssignment(node, scope, state, conditionallySkipped);
}
for (const [index, { body, scope, parameters }] of functionBodies.entries()) {
if (index % 64 === 0) yield;
@@ -853,29 +864,72 @@ const copyParameterToBody = (input: {
}
};
const hasConditionalInitializer = (ancestors: readonly t.Node[]): boolean => {
// A hoisted var can remain undefined when control flow skips its initializer.
// Conditions outside the owning callable/static block do not govern its body.
const writeMayBeSkipped = (
node: t.Node,
ancestors: readonly t.Node[],
): boolean => {
let child = node;
for (let index = ancestors.length - 1; index >= 0; index -= 1) {
const node = ancestors[index];
if (t.isFunction(node) || t.isProgram(node) || t.isStaticBlock(node))
const parent = ancestors[index];
if (
parent === undefined ||
t.isFunction(parent) ||
t.isProgram(parent) ||
t.isStaticBlock(parent)
)
return false;
if (t.isIfStatement(node) || t.isSwitchCase(node) || t.isTryStatement(node))
return true;
if (t.isLoop(node)) {
const bodyOrTest = ancestors[index + 1];
if (
(t.isForStatement(node) && bodyOrTest === node.init) ||
(t.isDoWhileStatement(node) &&
(bodyOrTest === node.body || bodyOrTest === node.test))
)
continue;
return true;
}
if (isSkippedBranch(parent, child)) return true;
child = parent;
}
return false;
};
const isSkippedBranch = (parent: t.Node, child: t.Node): boolean => {
if (t.isIfStatement(parent))
return child === parent.consequent || child === parent.alternate;
if (t.isSwitchCase(parent)) return true;
if (t.isTryStatement(parent))
return child === parent.block || child === parent.handler;
if (t.isForStatement(parent))
return child === parent.body || child === parent.update;
if (t.isWhileStatement(parent)) return child === parent.body;
if (t.isDoWhileStatement(parent)) return false;
if (t.isForInStatement(parent) || t.isForOfStatement(parent))
return child === parent.left || child === parent.body;
if (t.isLogicalExpression(parent)) return child === parent.right;
if (t.isConditionalExpression(parent))
return child === parent.consequent || child === parent.alternate;
if (t.isOptionalCallExpression(parent) || t.isCallExpression(parent))
return (
parent.arguments.includes(child as t.Expression) &&
optionalCallArgumentsMayBeSkipped(parent)
);
if (t.isOptionalMemberExpression(parent))
return parent.optional && parent.computed && child === parent.property;
return false;
};
const optionalCallArgumentsMayBeSkipped = (
call: t.CallExpression | t.OptionalCallExpression,
): boolean => {
if (t.isOptionalCallExpression(call) && call.optional) return true;
let callee: t.Node = call.callee;
while (true) {
// Parentheses terminate optional-chain short-circuiting. In particular,
// `(foo?.bar)(arg)` evaluates arg before attempting the call.
if (callee.extra?.parenthesized === true) return false;
if (t.isOptionalMemberExpression(callee)) {
if (callee.optional) return true;
callee = callee.object;
} else if (t.isMemberExpression(callee)) callee = callee.object;
else if (t.isOptionalCallExpression(callee)) {
if (callee.optional) return true;
callee = callee.callee;
} else if (t.isCallExpression(callee)) callee = callee.callee;
else return false;
}
};
const bindOuterDeclaration = (
node: t.Node,
scope: JavaScriptSemanticScopeState,
@@ -983,6 +1037,7 @@ const bindAssignment = (
node: t.Node,
scope: JavaScriptSemanticScopeState,
state: JavaScriptSemanticAnalysisState,
conditionallySkipped: boolean,
): void => {
if (t.isAssignmentExpression(node)) {
const left = unwrapJavaScriptExpression(node.left).node;
@@ -996,21 +1051,23 @@ const bindAssignment = (
: node,
scope,
state,
conditionallySkipped,
);
else
for (const identifier of semanticWriteTargets(node.left))
if (t.isIdentifier(identifier))
addAssignment(identifier, node, scope, state);
addAssignment(identifier, node, scope, state, conditionallySkipped);
} else if (t.isUpdateExpression(node)) {
const argument = unwrapJavaScriptExpression(node.argument).node;
if (t.isIdentifier(argument)) addAssignment(argument, node, scope, state);
if (t.isIdentifier(argument))
addAssignment(argument, node, scope, state, conditionallySkipped);
} else if (t.isForOfStatement(node) || t.isForInStatement(node))
for (const pattern of t.isVariableDeclaration(node.left)
? node.left.declarations.map(({ id }) => id)
: [node.left]) {
for (const identifier of semanticWriteTargets(pattern))
if (t.isIdentifier(identifier))
addAssignment(identifier, node, scope, state);
addAssignment(identifier, node, scope, state, conditionallySkipped);
if (t.isForOfStatement(node))
// Keep the loop value unknown, but preserve references yielded into
// each bound slot so writes/escapes can reach their iterable origins.
@@ -1400,6 +1457,7 @@ const addAssignment = (
initializer: t.Node,
scope: JavaScriptSemanticScopeState,
state: JavaScriptSemanticAnalysisState,
conditionallySkipped: boolean,
): void => {
const binding = resolveSemanticBindingFromScope(
scope,
@@ -1417,4 +1475,6 @@ const addAssignment = (
binding.mutable = true;
binding.definitions.push({ kind: "assignment", location: range(identifier) });
binding.initializers.push({ node: initializer, projection: [] });
if (conditionallySkipped)
state.conditionallySkippedExpressions.add(initializer);
};