fix(javascript): preserve initializer execution and lexical ownership

This commit is contained in:
morluto committed 2026-10-24 02:29:44 +08:00
1 parent 5cbcd58550
commit fba4faf88a
8 files changed
+488 -41

No files matched your search

@@ -0,0 +1,213 @@
import { expect, it } from "vitest";
import { analyzeParsedJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
import {
onlyCallable,
onlyBinding,
} from "./javascriptSemanticAnalysis.fixture.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
const analyze = (source: string, path = "fixture.cjs") => {
const file = parseJavaScriptSource(source, path);
if (file === null) throw new Error("Expected parsed fixture");
return analyzeParsedJavaScriptSemantics(file);
};
const returned = (body: string) =>
onlyCallable(analyze(`exports.read = function read() { ${body} };`), "read")
.returnSites[0]?.value;
it.each([
"return value; var value = 1;",
"var snapshot = value; var value = 1; return snapshot;",
])("selects declaration-only bindings at their actual read: %s", (body) => {
expect(returned(body)?.status).toBe("unknown");
});
it.each([
"do { break; var value = 1; } while (false); return value;",
"do { if (flag) break; var value = 1; } while (false); return value;",
"do { continue; var value = 1; } while (false); return value;",
"label: { break label; var value = 1; } return value;",
])(
"does not treat an initializer skipped by abrupt completion as definite: %s",
(body) => {
expect(returned(body)?.status).toBe("unknown");
},
);
it.each([
"var value = 1; return value;",
"do { var value = 1; break; } while (false); return value;",
"do { for (;;) { break; } var value = 1; } while (false); return value;",
"label: { break label; } var value = 1; return value;",
"function unused() { return; } var value = 1; return value;",
])(
"preserves initializers that an earlier local exit does not skip: %s",
(body) => {
expect(returned(body)).toEqual({ status: "literal", value: 1 });
},
);
it.each([
"var value = 1; { function value() {} } exports.read = function read() { return value; };",
"exports.read = function read() { var value = 1; if (flag) { function value() {} } return value; };",
])(
"indexes the legacy block-function write on its outer owner: %s",
(source) => {
expect(
onlyCallable(analyze(source), "read").returnSites[0]?.value.status,
).not.toBe("literal");
},
);
it.each([
[
"fixture.cjs",
'"use strict"; var value = 1; { function value() {} } exports.read = function read() { return value; };',
],
[
"fixture.cjs",
"let value = 1; { function value() {} } exports.read = function read() { return value; };",
],
[
"fixture.mjs",
"var value = 1; { function value() {} } export function read() { return value; }",
],
])(
"does not apply legacy writes across a strict or lexical boundary: %s",
(path, source) => {
expect(
onlyCallable(analyze(source, path), "read").returnSites[0]?.value,
).toEqual({ status: "literal", value: 1 });
},
);
it("assigns a catch-shadowed var initializer to the active lexical binding", () => {
const ir = analyze(`
function target() { return "target"; }
try { throw 0; } catch (handler) { var handler = target; handler(); }
`);
const target = onlyCallable(ir, "target");
expect(ir.callSites).toContainEqual(
expect.objectContaining({
resolution: "exact",
calleeCallableIds: [target.callableId],
}),
);
const hoisted = ir.bindings.find(
({ name, kind }) => name === "handler" && kind === "variable",
);
expect(hoisted?.value.status).toBe("unknown");
});
it("keeps ordinary arguments local while arrows retain the outer binding", () => {
const ir = analyze(`
var arguments = { 0: "OUTER" };
function ordinary() { const selected = arguments[0]; return selected; }
const arrow = () => arguments[0];
`);
expect(onlyBinding(ir, "selected").value.status).toBe("unknown");
expect(onlyCallable(ir, "ordinary").returnSites[0]?.value.status).toBe(
"unknown",
);
expect(onlyCallable(ir, "arrow").returnSites[0]?.value).toEqual({
status: "literal",
value: "OUTER",
});
const outer = onlyBinding(ir, "arguments");
expect(
ir.closureCaptures.some(
({ bindingId, callableId }) =>
bindingId === outer.bindingId &&
callableId === onlyCallable(ir, "ordinary").callableId,
),
).toBe(false);
});
it("does not resolve Promise sources through a future initializer", () => {
const early = analyze(
`
try { task.then(work); } catch (error) {}
const task = Promise.resolve(1);
`,
"fixture.mjs",
);
expect(
early.promiseOperations.find(({ method }) => method === "then"),
).toMatchObject({ sourceResolution: "unresolved", sourcePromiseIds: [] });
const initialized = analyze(
"const task = Promise.resolve(1); task.then(work);",
"fixture.mjs",
);
expect(
initialized.promiseOperations.find(({ method }) => method === "then"),
).toMatchObject({
sourceResolution: "complete",
sourcePromiseIds: [expect.any(String)],
});
});
it.each([
"globalThis.Promise = { resolve() { return 17; } };",
"Promise.resolve = () => 17;",
])(
"requires the selected intrinsic Promise identity after ambient writes: %s",
(write) => {
const ir = analyze(
`${write} const task = Promise.resolve(1);`,
"fixture.mjs",
);
expect(
ir.promiseOperations.filter(({ method }) => method === "resolve"),
).toEqual([]);
expect(
analyze("const task = Promise.resolve(1);", "fixture.mjs")
.promiseOperations,
).toContainEqual(
expect.objectContaining({
method: "resolve",
sourceResolution: "complete",
}),
);
},
);
it("retains caught reference identity without tainting inert throws or later replacements", () => {
const cases = [
["caught.value = 2;", "unknown"],
["mutate(caught);", "unknown"],
["", "literal"],
["caught = {}; caught.value = 2;", "literal"],
] as const;
for (const [body, status] of cases)
expect(
returned(
`const state = { value: 1 }; try { throw state; } catch (caught) { ${body} } return state.value;`,
)?.status,
).toBe(status);
});
it("owns a class declaration's self reference separately from its outer variable", () => {
const ir = analyze(
`
class Service { static create() { return new Service(); } }
const Original = Service;
Service = function Replacement() {};
`,
"fixture.mjs",
);
const bindings = ir.bindings.filter(({ name }) => name === "Service");
expect(bindings).toHaveLength(2);
const create = onlyCallable(ir, "create");
expect(
ir.closureCaptures.some(
({ callableId, bindingId }) =>
callableId === create.callableId &&
bindingId === bindings.find(({ mutable }) => mutable)?.bindingId,
),
).toBe(false);
expect(
ir.callSites.find(
({ callerCallableId }) => callerCallableId === create.callableId,
)?.resolution,
).toBe("exact");
});
@@ -9,6 +9,7 @@ import * as t from "@babel/types";
import {
semanticComputedMethodKey,
semanticBranchMayBeSkipped,
semanticPriorStatementMayExit,
semanticIterationCardinality,
} from "./javascriptSemanticEvaluationOrder.js";
@@ -116,6 +117,7 @@ import type { JavaScriptModuleResolutionMode } from "./javascriptModuleResolutio
interface BindPatternInput {
readonly iterationSources?: JavaScriptSemanticBindingState["initializers"][number]["iterationSources"];
readonly pattern: t.Node;
readonly entryBody?: t.BlockStatement;
readonly initializer: t.Node | null;
readonly scope: JavaScriptSemanticScopeState;
readonly state: JavaScriptSemanticAnalysisState;
@@ -1067,6 +1069,19 @@ function* collectDefinitionsSteps(
node: t.CallExpression;
scopeId: string;
}[] = [];
const displacedVarInitializers: {
node: t.VariableDeclarator;
scope: JavaScriptSemanticScopeState;
owner: JavaScriptSemanticScopeState;
}[] = [];
const thrownValues: {
node: t.ThrowStatement;
ancestors: readonly t.Node[];
}[] = [];
const legacyBlockFunctions: {
node: t.FunctionDeclaration;
scope: JavaScriptSemanticScopeState;
}[] = [];
let needsPrototypeFacts = false;
const functionBodies: {
readonly body: t.BlockStatement;
@@ -1117,6 +1132,21 @@ function* collectDefinitionsSteps(
parentScope = dynamicScope;
}
bindOuterDeclaration(node, parentScope, state);
if (
t.isFunctionDeclaration(node) &&
node.id != null &&
parentScope.kind === "block"
)
legacyBlockFunctions.push({ node, scope: parentScope });
if (
t.isVariableDeclarator(node) &&
node.init != null &&
t.isIdentifier(node.id)
) {
const owner = semanticVariableScope(parentScope, parent, state);
if (owner !== parentScope)
displacedVarInitializers.push({ node, scope: parentScope, owner });
}
const nested = nestedScope(node, parent, parentScope, state);
if (nested !== undefined) {
stack.push(nested);
@@ -1141,6 +1171,8 @@ function* collectDefinitionsSteps(
state,
});
bindInnerDeclaration(node, parent, scope, state);
if (t.isThrowStatement(node))
thrownValues.push({ node, ancestors: readAncestors() });
// Every supported global eval origin starts with one of these decoded
// identifiers, including computed properties and destructured aliases.
if (t.isIdentifier(node) && globalEvalRoots.has(node.name))
@@ -1180,6 +1212,94 @@ function* collectDefinitionsSteps(
stack.pop();
},
});
// A var declaration is instantiated in its variable environment, while its
// initializer assigns through the lexical environment at the source site.
for (const { node, scope, owner } of displacedVarInitializers) {
if (!t.isIdentifier(node.id) || node.init == null) continue;
const selected = resolveSemanticBindingFromScope(
scope,
node.id.name,
state,
);
const declared = owner.bindings.get(node.id.name);
if (
selected === undefined ||
selected === declared ||
declared === undefined
)
continue;
for (let index = declared.initializers.length - 1; index >= 0; index--)
if (declared.initializers[index]?.node === node.init)
declared.initializers.splice(index, 1);
addAssignment(
node.id,
node.init,
scope,
state,
state.conditionallySkippedExpressions.has(node.init),
node,
);
}
for (const { node, ancestors } of thrownValues) {
let child: t.Node = node;
for (let index = ancestors.length - 1; index >= 0; index--) {
const parent = ancestors[index]!;
if (t.isFunction(parent) || t.isClass(parent)) break;
if (
t.isTryStatement(parent) &&
parent.handler != null &&
child === parent.block
) {
const scope = state.scopeByNode.get(parent.handler);
if (scope !== undefined && parent.handler.param != null)
bindPattern({
pattern: parent.handler.param,
initializer: node.argument,
scope,
state,
mutable: true,
referenceOnly: true,
entryBody: parent.handler.body,
});
break;
}
child = parent;
}
}
for (const { node, scope } of legacyBlockFunctions) {
if (
node.id == null ||
semanticStrictnessAt(state.parentsByNode.get(node) ?? node, state) ===
"strict"
)
continue;
let outer =
scope.parentScopeId === null
? undefined
: state.scopesById.get(scope.parentScopeId);
while (outer !== undefined) {
const binding = outer.bindings.get(node.id.name);
if (
binding !== undefined &&
!binding.declarationKinds.has("var") &&
binding.kind !== "parameter"
)
break;
if (
outer.kind === "function" ||
outer.kind === "program" ||
outer.kind === "static-block"
) {
if (binding !== undefined)
addAssignment(node.id, node, outer, state, false, node);
break;
}
outer =
outer.parentScopeId === null
? undefined
: state.scopesById.get(outer.parentScopeId);
}
}
if (needsPrototypeFacts)
yield* indexPrototypeUncertaintySteps(program, state);
// All declarations must exist before resolving writes, including hoisted
@@ -1365,7 +1485,11 @@ const writeMayBeSkipped = (
t.isStaticBlock(parent)
)
return false;
if (semanticBranchMayBeSkipped(parent, child)) return true;
if (
semanticBranchMayBeSkipped(parent, child) ||
semanticPriorStatementMayExit(parent, child)
)
return true;
child = parent;
}
return false;
@@ -1421,7 +1545,7 @@ const bindInnerDeclaration = (
? { declarationKind: parent.kind }
: {}),
});
else if (t.isClassExpression(node) && t.isIdentifier(node.id))
else if (t.isClass(node) && t.isIdentifier(node.id))
addBinding({
state,
scope,
@@ -1588,7 +1712,8 @@ const mappedArgumentsOwner = (
}
if (!t.isIdentifier(value)) return undefined;
if (semanticBindingLookupAt(state, value, "arguments").kind !== "ambient")
const lookup = semanticBindingLookupAt(state, value, "arguments");
if (lookup.kind !== "implicit-arguments" && lookup.kind !== "ambient")
return undefined;
let owner: t.Function | undefined;
for (
@@ -1634,12 +1759,12 @@ const mappedArgumentsOwner = (
return {
scope,
parameters: mapped,
strictness: mappedArgumentsStrictness(owner, state),
strictness: semanticStrictnessAt(owner, state),
};
};
const mappedArgumentsStrictness = (
owner: t.Function,
const semanticStrictnessAt = (
owner: t.Node,
state: JavaScriptSemanticAnalysisState,
): MappedArgumentsOwner["strictness"] => {
for (
@@ -1737,6 +1862,9 @@ const nestedScope = (
location: range(switchOwner ?? node),
bindingsComplete: true,
bindings: new Map(),
...(t.isFunction(node) && !t.isArrowFunctionExpression(node)
? { implicitArguments: true as const }
: {}),
};
state.scopes.push(scope);
state.scopesById.set(scope.scopeId, scope);
@@ -1914,6 +2042,9 @@ const bindPattern = (input: BindPatternInput): void => {
binding.referenceInitializers.push({
node: initializer,
projection,
...(input.entryBody === undefined
? {}
: { entryBody: input.entryBody }),
...(assignmentReference === undefined ? {} : { assignment: true }),
...(copyKind === undefined ? {} : { copyKind }),
...(copyProjectionOffset === undefined
@@ -2136,7 +2267,8 @@ const addAssignment = (
const mutation = writeSite ?? initializer;
if (mutation === null) return;
const lookup = semanticBindingLookupFromScope(scope, identifier.name, state);
if (lookup.kind === "captured") return;
if (lookup.kind === "captured" || lookup.kind === "implicit-arguments")
return;
const binding = "binding" in lookup ? lookup.binding : undefined;
conditionallySkipped ||=
lookup.kind !== "binding" && lookup.kind !== "ambient";
@@ -6,6 +6,8 @@ import {
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import { childNodes } from "./javascriptSemanticTraversal.js";
const preserveGrouping = { stopAtParentheses: true } as const;
export type JavaScriptSemanticIterationCardinality =
@@ -155,3 +157,94 @@ const optionalChainMayShortCircuit = (expression: t.Node): boolean => {
else return false;
}
};
interface AbruptCompletionSummary {
readonly breaks: ReadonlySet<string | null>;
readonly continues: ReadonlySet<string | null>;
}
const emptyAbruptLabels: ReadonlySet<string | null> = new Set();
const emptyAbruptSummary: AbruptCompletionSummary = {
breaks: emptyAbruptLabels,
continues: emptyAbruptLabels,
};
const abruptSummaries = new WeakMap<t.Node, AbruptCompletionSummary>();
const abruptPrefixes = new WeakMap<
t.BlockStatement | t.Program,
ReadonlySet<t.Node>
>();
/** Loop and label exits crossing a statement boundary, excluding nested callables. */
const abruptSummary = (root: t.Node): AbruptCompletionSummary => {
const pending: { node: t.Node; visited: boolean }[] = [
{ node: root, visited: false },
];
while (pending.length > 0) {
const { node, visited } = pending.pop()!;
if (abruptSummaries.has(node)) continue;
const children =
t.isFunction(node) || t.isClass(node)
? []
: childNodes(node).filter(
(child) =>
t.isStatement(child) ||
t.isSwitchCase(child) ||
t.isCatchClause(child),
);
if (!visited) {
pending.push({ node, visited: true });
for (const child of children)
if (!abruptSummaries.has(child))
pending.push({ node: child, visited: false });
continue;
}
let breaks: Set<string | null> | undefined,
continues: Set<string | null> | undefined;
if (t.isBreakStatement(node))
(breaks ??= new Set()).add(node.label?.name ?? null);
if (t.isContinueStatement(node))
(continues ??= new Set()).add(node.label?.name ?? null);
for (const child of children) {
const summary = abruptSummaries.get(child)!;
for (const label of summary.breaks) (breaks ??= new Set()).add(label);
for (const label of summary.continues)
(continues ??= new Set()).add(label);
}
if (t.isLoop(node) || t.isSwitchStatement(node)) breaks?.delete(null);
if (t.isLoop(node)) continues?.delete(null);
if (t.isLabeledStatement(node)) {
breaks?.delete(node.label.name);
continues?.delete(node.label.name);
}
abruptSummaries.set(
node,
(breaks?.size ?? 0) > 0 || (continues?.size ?? 0) > 0
? {
breaks: breaks ?? emptyAbruptLabels,
continues: continues ?? emptyAbruptLabels,
}
: emptyAbruptSummary,
);
}
return abruptSummaries.get(root)!;
};
/** An earlier sibling may leave this body before reaching the selected statement. */
export const semanticPriorStatementMayExit = (
parent: t.Node,
child: t.Node,
): boolean => {
if (!t.isBlockStatement(parent) && !t.isProgram(parent)) return false;
let skipped = abruptPrefixes.get(parent);
if (skipped === undefined) {
const prefixes = new Set<t.Node>();
let exits = false;
for (const statement of parent.body) {
if (exits) prefixes.add(statement);
const summary = abruptSummary(statement);
exits ||= summary.breaks.size > 0 || summary.continues.size > 0;
}
skipped = prefixes;
abruptPrefixes.set(parent, skipped);
}
return skipped.has(child);
};
@@ -53,7 +53,6 @@ export const createSemanticExpressionShapeResolver = (
// Most modules have no named child arguments. Collect source-wide blockers
// only when a query actually needs a named binding or ambient undefined.
let blocked: boolean | undefined;
const blockFunctionNames = new Set<string>();
const namedProofBlocked = (): boolean => {
if (blocked !== undefined) return blocked;
blocked = false;
@@ -62,13 +61,6 @@ export const createSemanticExpressionShapeResolver = (
blocked ||=
isErasedJavaScriptDeclaration(node) ||
isUnmodeledJavaScriptRuntimeScope(node);
if (
javaScriptRuntimeContext(state.program) !== "module" &&
t.isFunctionDeclaration(node) &&
node.id != null &&
!hoistedFunction(node)
)
blockFunctionNames.add(node.id.name);
},
});
return blocked;
@@ -105,13 +97,9 @@ export const createSemanticExpressionShapeResolver = (
)
return "unknown";
}
// Annex B may copy a block function into an outer var without an indexed
// assignment. Keep same-named vars unknown rather than model that copy.
if (
namedProofBlocked() ||
semanticOpaqueBindingReadMayHaveChanged(binding, node, state) ||
(binding.declarationKinds.has("var") &&
blockFunctionNames.has(binding.name))
semanticOpaqueBindingReadMayHaveChanged(binding, node, state)
)
return "unknown";
const initializer = semanticStableInitializer(binding, state);
@@ -13,11 +13,12 @@ import {
} from "./javascriptAstValues.js";
import {
resolveSemanticBindingState,
semanticResolutionBlocked,
type JavaScriptSemanticAnalysisState,
} from "./javascriptSemanticState.js";
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js";
import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js";
import { range } from "./javascriptStaticAnalysisHelpers.js";
import {
assignedSemanticResultBindings,
@@ -140,7 +141,7 @@ const promiseCandidateDetails = (
if (
t.isNewExpression(node) &&
t.isIdentifier(node.callee, { name: "Promise" }) &&
isUnshadowedGlobalPromise(node.callee, state)
isUnmodifiedGlobalReference(node.callee, state, "Promise")
)
return { kind: "constructor", method: "new" };
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
@@ -149,7 +150,7 @@ const promiseCandidateDetails = (
const method = semanticStaticPropertyName(member.property, member.computed);
if (
t.isIdentifier(member.object, { name: "Promise" }) &&
isUnshadowedGlobalPromise(member.object, state)
isUnmodifiedGlobalReference(node.callee, state, ["Promise", method ?? ""])
) {
if (method === "all" || method === "allSettled")
return { kind: "aggregate", method };
@@ -164,13 +165,6 @@ const promiseCandidateDetails = (
return null;
};
const isUnshadowedGlobalPromise = (
node: t.Identifier,
state: JavaScriptSemanticAnalysisState,
): boolean =>
resolveSemanticBindingState(state, node, "Promise") === undefined &&
!semanticResolutionBlocked(state, node, "Promise");
const isPromiseProducer = (
node: t.Node,
state: JavaScriptSemanticAnalysisState,
@@ -447,14 +441,16 @@ const resolvePromiseExpression = (
return { promiseIds: [direct.promiseId], status: "complete" };
if (!t.isIdentifier(node)) return unresolvedPromise();
const binding = resolveSemanticBindingState(state, node, node.name);
if (binding === undefined || seenBindings.has(binding.bindingId))
return unresolvedPromise();
const read = semanticBindingAtRead(binding, node, state.parentsByNode);
if (
binding === undefined ||
binding.mutable ||
binding.initializers.length !== 1 ||
seenBindings.has(binding.bindingId)
read.status !== "ordered" ||
!read.hasDefiniteInitializer ||
read.binding.initializers.length !== 1
)
return unresolvedPromise();
const initializer = binding.initializers[0];
const initializer = read.binding.initializers[0];
if (initializer === undefined || initializer.projection.length > 0)
return unresolvedPromise();
const nestedSeen = new Set(seenBindings);
@@ -117,6 +117,8 @@ export interface JavaScriptSemanticScopeState {
readonly bindings: Map<string, JavaScriptSemanticBindingState>;
/** Object environment introduced by `with`, active only for its body. */
readonly dynamicWithStatement?: t.WithStatement;
/** Ordinary functions own `arguments`; arrow functions inherit it. */
readonly implicitArguments?: true;
}
/** Shared state for semantic collection and evaluation. */
@@ -301,7 +303,14 @@ type SemanticBindingLookup =
readonly kind: "binding" | "uncertain-binding";
readonly binding: JavaScriptSemanticBindingState;
}
| { readonly kind: "ambient" | "uncertain-ambient" | "captured" | "unknown" };
| {
readonly kind:
| "ambient"
| "uncertain-ambient"
| "captured"
| "unknown"
| "implicit-arguments";
};
/** Retain both a dynamic lookup's uncertainty and its possible outer write owner. */
export const semanticBindingLookupFromScope = (
@@ -314,6 +323,8 @@ export const semanticBindingLookupFromScope = (
const binding = scope.bindings.get(name);
if (binding !== undefined)
return { kind: uncertain ? "uncertain-binding" : "binding", binding };
if (name === "arguments" && scope.implicitArguments === true)
return { kind: "implicit-arguments" };
if (!scope.bindingsComplete) {
if (scope.dynamicWithStatement === undefined) return { kind: "unknown" };
const lookup = semanticWithRequireLookup(
@@ -658,12 +658,16 @@ const evaluateExpressionValue = (
const binding = resolveSemanticBindingState(context.state, node, node.name);
if (binding === undefined)
return { status: "unknown", reason: `Unbound identifier ${node.name}.` };
const read =
const read = semanticBindingAtRead(
binding,
node,
context.state.parentsByNode,
);
if (
read.status === "unresolved" &&
binding.mutable &&
binding.definitions.some(({ kind }) => kind === "assignment")
? semanticBindingAtRead(binding, node, context.state.parentsByNode)
: undefined;
if (read?.status === "unresolved") {
) {
const value = evaluateBinding(binding, nestedContext(context));
if (
value.status === "unknown" ||
@@ -676,7 +680,7 @@ const evaluateExpressionValue = (
reason: `Binding ${binding.name} has unresolved ordering at this read.`,
};
}
const atRead = read?.binding ?? binding;
const atRead = read.binding;
if (
semanticOpaqueBindingReadMayHaveChanged(
binding,