fix(javascript): preserve alias and container uncertainty

This commit is contained in:
morluto committed 2026-10-09 22:46:08 +08:00
1 parent 99f257bbf0
commit 73e98b2516
8 files changed
+374 -43

No files matched your search

@@ -209,6 +209,17 @@ describe("JavaScript semantic analysis: read-modify-write", () => {
"read",
]);
});
it("does not model a compound binding write as a competing replacement", () => {
const ir = analyzeJavaScriptSemantics(`
let count = 1;
count += 2;
`);
expect(topLevelBinding(ir, "count").value).toMatchObject({
status: "unknown",
});
});
});
describe("JavaScript semantic analysis: dataflow 2", () => {
@@ -448,21 +459,36 @@ describe("nonfinite static values", () => {
describe("binding write invalidation", () => {
it.each([
"let value = 1; value++; const observed = value;",
"let value = 1; --value; const observed = value;",
'let value = "old"; [value] = ["new"]; const observed = value;',
'let value = "old"; ({key: value} = {key: "new"}); const observed = value;',
'let value = "old"; for (value of ["new"]) {} const observed = value;',
'let value = "old"; for (value in {new: true}) {} const observed = value;',
])("does not retain an exact initializer across %s", (source) => {
const ir = analyzeJavaScriptSemantics(source);
expect(topLevelBinding(ir, "observed").value.status).toBe("ambiguous");
expect(
topLevelBinding(ir, "value").definitions.some(
({ kind }) => kind === "assignment",
),
).toBe(true);
});
["let value = 1; value++; const observed = value;", "unknown"],
["let value = 1; --value; const observed = value;", "unknown"],
[
'let value = "old"; [value] = ["new"]; const observed = value;',
"ambiguous",
],
[
'let value = "old"; ({key: value} = {key: "new"}); const observed = value;',
"ambiguous",
],
[
'let value = "old"; for (value of ["new"]) {} const observed = value;',
"ambiguous",
],
[
'let value = "old"; for (value in {new: true}) {} const observed = value;',
"ambiguous",
],
] as const)(
"does not retain an exact initializer across %s",
(source, status) => {
const ir = analyzeJavaScriptSemantics(source);
expect(topLevelBinding(ir, "observed").value.status).toBe(status);
expect(
topLevelBinding(ir, "value").definitions.some(
({ kind }) => kind === "assignment",
),
).toBe(true);
},
);
it("does not treat destructuring keys or member owners as assigned bindings", () => {
const ir = analyzeJavaScriptSemantics(`
@@ -76,6 +76,85 @@ describe("JavaScript semantic values after explicit property mutations", () => {
).toEqual({ status: "literal", value: "initial" });
});
it("does not propagate writes through an alias after an unconditional rebind", () => {
expect(
resultValue(`
const shared = { mode: "initial" };
let alias = shared;
alias = { mode: "other" };
alias.mode = "updated";
return shared.mode;
`),
).toEqual({ status: "literal", value: "initial" });
});
it.each([
'if (flag) alias = { mode: "other" }; alias.mode = "updated";',
'flag && (alias = { mode: "other" }); alias.mode = "updated";',
'flag ||= (alias = { mode: "other" }); alias.mode = "updated";',
'alias ||= { mode: "other" }; alias.mode = "updated";',
'alias ??= { mode: "other" }; alias.mode = "updated";',
])(
"keeps possible source aliases unknown across conditional rebinds",
(body) => {
expect(
resultValue(`
const shared = { mode: "initial" };
let alias = shared;
${body}
return shared.mode;
`)?.status,
).toBe("unknown");
},
);
it("keeps mutation under a bare conditional unknown across a rebind", () => {
expect(
resultValue(`
const shared = { mode: "initial" };
let alias = shared;
alias = { mode: "other" };
if (flag) alias.mode = "updated";
return shared.mode;
`)?.status,
).toBe("unknown");
});
it("does not treat a short-circuit assignment RHS as an unconditional value", () => {
expect(
resultValue(`
let value;
value &&= 2;
return value;
`)?.status,
).toBe("unknown");
});
it("keeps possible alias mutations conservative across short-circuit assignment", () => {
expect(
resultValue(`
const shared = { mode: "initial" };
let alias = shared;
alias &&= { mode: "other" };
alias.mode = "updated";
return shared.mode;
`)?.status,
).toBe("unknown");
});
it("propagates short-circuit assignment mutations to the RHS alias candidate", () => {
expect(
resultValue(`
const shared = { mode: "initial" };
const other = { mode: "other" };
let alias = shared;
alias &&= other;
alias.mode = "updated";
return other.mode;
`)?.status,
).toBe("unknown");
});
it("retains ordinary immutable object and array projection", () => {
expect(
resultValue(
@@ -13,8 +13,8 @@ import {
collectSemanticReferences,
immutableSemanticBindings,
immutableSemanticScopes,
semanticStaticPropertyKey,
} from "./javascriptSemanticProjection.js";
import { semanticStaticPropertyKey } from "./javascriptAstValues.js";
import type {
JavaScriptSemanticAnalysisState,
JavaScriptSemanticBindingState,
@@ -35,7 +35,8 @@ import {
resolveSemanticModuleCallables,
} from "./javascriptSemanticReturns.js";
import { collectJavaScriptDerivedSemantics } from "./javascriptSemanticDerivedAnalysis.js";
import { propertyName, range } from "./javascriptStaticAnalysisHelpers.js";
import { range } from "./javascriptStaticAnalysisHelpers.js";
import { propertyName } from "./javascriptAstValues.js";
import { semanticCoverage } from "./javascriptSemanticCoverage.js";
import { semanticResourceLimitsIn } from "./javascriptSemanticResourceLimits.js";
import {
@@ -409,7 +410,16 @@ const bindInnerDeclaration = (
});
else if (t.isAssignmentExpression(node)) {
if (t.isIdentifier(node.left))
addAssignment(node.left, node.right, scope, state);
addAssignment(
node.left,
node.operator === "=" ||
node.operator === "||=" ||
node.operator === "??="
? node.right
: node,
scope,
state,
);
else
for (const identifier of assignedPatternIdentifiers(node.left))
addAssignment(identifier, node, scope, state);
@@ -3,6 +3,7 @@ import * as t from "@babel/types";
import {
resolveSemanticBindingState,
type JavaScriptSemanticAnalysisState,
type JavaScriptSemanticBindingState,
} from "./javascriptSemanticState.js";
import { evaluateSemanticBinding } from "./javascriptSemanticValues.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
@@ -15,10 +16,17 @@ export const collectSemanticMemberMutations = (
program: t.Program,
state: JavaScriptSemanticAnalysisState,
): void => {
const parents = new WeakMap<t.Node, t.Node>();
traverseJavaScriptAst(program, {
enter: (node, parent) => {
if (parent !== null) parents.set(node, parent);
},
});
const markValue = (
node: t.Node,
path: PropertyPath,
bindings: ReadonlySet<string>,
mutation: t.Node,
): void => {
if (t.isIdentifier(node)) {
const binding = resolveSemanticBindingState(state, node, node.name);
@@ -27,11 +35,16 @@ export const collectSemanticMemberMutations = (
if (value.status === "literal" || value.status === "union") return;
binding.mutatedPaths.push(path);
const nested = new Set([...bindings, binding.bindingId]);
for (const initializer of binding.initializers)
for (const initializer of mutationInitializers(
binding,
mutation,
parents,
))
markValue(
initializer.node,
[...initializer.projection, ...path],
nested,
mutation,
);
return;
}
@@ -47,43 +60,130 @@ export const collectSemanticMemberMutations = (
);
current = current.object;
}
markValue(current, [...members.reverse(), ...path], bindings);
markValue(current, [...members.reverse(), ...path], bindings, mutation);
return;
}
for (const value of referencedValues(node, path))
markValue(value.node, value.path, bindings);
markValue(value.node, value.path, bindings, mutation);
};
const markTarget = (node: t.Node): void => {
const markTarget = (node: t.Node, mutation: t.Node): void => {
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node))
markValue(
node.object,
[semanticStaticPropertyKey(node.property, node.computed)],
new Set(),
mutation,
);
else if (t.isRestElement(node)) markTarget(node.argument);
else if (t.isAssignmentPattern(node)) markTarget(node.left);
else if (t.isRestElement(node)) markTarget(node.argument, mutation);
else if (t.isAssignmentPattern(node)) markTarget(node.left, mutation);
else if (t.isArrayPattern(node)) {
for (const element of node.elements)
if (element !== null) markTarget(element);
if (element !== null) markTarget(element, mutation);
} else if (t.isObjectPattern(node)) {
for (const property of node.properties)
markTarget(
t.isRestElement(property) ? property.argument : property.value,
mutation,
);
}
};
traverseJavaScriptAst(program, {
enter: (node) => {
if (t.isAssignmentExpression(node)) markTarget(node.left);
else if (t.isUpdateExpression(node)) markTarget(node.argument);
if (t.isAssignmentExpression(node)) markTarget(node.left, node);
else if (t.isUpdateExpression(node)) markTarget(node.argument, node);
else if (t.isUnaryExpression(node, { operator: "delete" }))
markTarget(node.argument);
markTarget(node.argument, node);
else if (t.isForOfStatement(node) || t.isForInStatement(node))
markTarget(node.left);
markTarget(node.left, node);
},
});
};
interface StatementPosition {
readonly body: readonly t.Statement[];
readonly index: number;
}
const statementPosition = (
statement: t.Statement,
parents: WeakMap<t.Node, t.Node>,
): StatementPosition | null => {
const parent = parents.get(statement);
if (!t.isProgram(parent) && !t.isBlockStatement(parent)) return null;
const index = parent.body.indexOf(statement);
return index < 0 ? null : { body: parent.body, index };
};
const directInitializerPosition = (
node: t.Node,
parents: WeakMap<t.Node, t.Node>,
): StatementPosition | null => {
const parent = parents.get(node);
if (t.isVariableDeclarator(parent) && parent.init === node) {
const declaration = parents.get(parent);
return t.isVariableDeclaration(declaration)
? statementPosition(declaration, parents)
: null;
}
if (
t.isAssignmentExpression(parent) &&
parent.operator === "=" &&
parent.right === node &&
t.isIdentifier(parent.left)
) {
const statement = parents.get(parent);
return t.isExpressionStatement(statement) && statement.expression === parent
? statementPosition(statement, parents)
: null;
}
return null;
};
const directMutationPosition = (
node: t.Node,
parents: WeakMap<t.Node, t.Node>,
): StatementPosition | null => {
if (
!t.isAssignmentExpression(node) &&
!t.isUpdateExpression(node) &&
!t.isUnaryExpression(node, { operator: "delete" })
)
return null;
const statement = parents.get(node);
return t.isExpressionStatement(statement) && statement.expression === node
? statementPosition(statement, parents)
: null;
};
const mutationInitializers = (
binding: JavaScriptSemanticBindingState,
mutation: t.Node,
parents: WeakMap<t.Node, t.Node>,
): JavaScriptSemanticBindingState["initializers"] => {
if (binding.initializers.length < 2) return binding.initializers;
const mutationPosition = directMutationPosition(mutation, parents);
if (mutationPosition === null) return binding.initializers;
const positions = binding.initializers.map(({ node }) =>
directInitializerPosition(node, parents),
);
if (
positions.some(
(position) =>
position === null ||
position.body !== mutationPosition.body ||
position.index >= mutationPosition.index,
)
)
return binding.initializers;
const latestIndex = positions.reduce(
(latest, position) => Math.max(latest, position?.index ?? -1),
-1,
);
return binding.initializers.filter(
(_, index) => positions[index]?.index === latestIndex,
);
};
interface ReferencedValue {
readonly node: t.Node;
readonly path: PropertyPath;
@@ -141,5 +241,7 @@ const referencedValues = (
{ node: node.left, path },
{ node: node.right, path },
];
if (t.isAssignmentExpression(node, { operator: "&&=" }))
return [{ node: node.right, path }];
return [];
};
@@ -4,12 +4,12 @@ import { semanticSlotAtPath } from "./javascriptSemanticSlots.js";
import { invalidateSemanticMutationPath } from "./javascriptSemanticMutationValues.js";
import type { JavaScriptBindingProvenance } from "./javascriptSemanticIr.js";
import type {
JavaScriptBindingProvenance,
JavaScriptSemanticProperty,
JavaScriptSemanticResourceLimit,
JavaScriptSemanticValue,
} from "./javascriptSemanticIr.js";
} from "./javascriptSemanticValueTypes.js";
import {
resolveSemanticBindingState,
type JavaScriptSemanticAnalysisState,
@@ -17,7 +17,10 @@ import {
} from "./javascriptSemanticState.js";
import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js";
import { compareCodePoints } from "../canonicalOrdering.js";
import { semanticStaticPropertyKey } from "./javascriptAstValues.js";
import {
readExactJavaScriptLiteral,
semanticStaticPropertyKey,
} from "./javascriptAstValues.js";
import {
semanticAmbiguousProvenance,
semanticLocalProvenance,
@@ -26,10 +29,8 @@ import {
uniqueSemanticOrigins,
} from "./javascriptSemanticProvenance.js";
import {
MAX_SEMANTIC_PRIMITIVE_CANDIDATES,
semanticPrimitiveCandidates as primitiveCandidates,
semanticPrimitiveSet as primitiveSet,
semanticPrimitiveValue as primitiveValue,
} from "./javascriptSemanticPrimitives.js";
import {
SEMANTIC_EXPRESSION_DEPTH_LIMIT,
@@ -120,6 +121,21 @@ const evaluateBinding = (
status: "unknown",
reason: `Binding ${binding.name} has no constant initializer.`,
};
if (
binding.initializers.some(
({ node }) =>
t.isUpdateExpression(node) ||
(t.isAssignmentExpression(node) &&
node.operator !== "=" &&
node.operator !== "&&=" &&
node.operator !== "||=" &&
node.operator !== "??="),
)
)
return {
status: "unknown",
reason: `Binding ${binding.name} has a compound or update write.`,
};
if (binding.initializers.length > 1)
return {
status: "ambiguous",
@@ -154,7 +170,7 @@ const evaluateExpression = (
): JavaScriptSemanticValue => {
if (context.expressionDepth > SEMANTIC_EXPRESSION_DEPTH_LIMIT)
return semanticResourceLimitUnknown("expression-depth");
const literal = primitiveValue(node);
const literal = readExactJavaScriptLiteral(node);
if (literal.found) return { status: "literal", value: literal.value };
if (t.isIdentifier(node)) {
const binding = resolveSemanticBindingState(context.state, node, node.name);
@@ -408,7 +424,7 @@ const addPrimitiveValues = (
if (isSemanticResourceLimit(rightValue)) return rightValue;
return { status: "unknown", reason: "Non-primitive addition." };
}
if (left.length > MAX_SEMANTIC_PRIMITIVE_CANDIDATES / right.length)
if (left.length > SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT / right.length)
return semanticResourceLimitUnknown("primitive-candidates");
if (exceedsSemanticPrimitiveAdditionByteBudget(left, right))
return semanticResourceLimitUnknown("primitive-bytes");