fix(javascript): resolve exported callables through lexical bindings (#1351)

* fix(javascript): resolve exported callables through lexical bindings

* fix(javascript): preserve export scope and hoisted writes

* test(javascript): consolidate export binding assertions

* fix(javascript): account for skipped initializers and loop binding writes

* fix(javascript): preserve parameters copied into body variables
This commit is contained in:
You Wish authored and GitHub committed 2026-10-10 06:39:56 +08:00
1 parent 1c0cc8a675
commit a9f5dad5a8
6 files changed
+929 -96

No files matched your search

@@ -286,6 +286,8 @@ const createState = (program: t.Program): JavaScriptSemanticAnalysisState => {
callables: [],
callableNodesById: new Map(),
moduleLinks: [],
moduleLinkBindings: new WeakMap(),
conditionalInitializers: new WeakSet(),
};
};
@@ -297,9 +299,34 @@ const collectDefinitions = (
currentSemanticScope(state.scopes),
];
const openedScopes = new WeakMap<t.Node, number>();
const assignments: {
readonly node: t.Node;
readonly scope: JavaScriptSemanticScopeState;
}[] = [];
const functionBodies: {
readonly body: t.BlockStatement;
readonly scope: JavaScriptSemanticScopeState;
readonly parameters: t.Function["params"];
}[] = [];
traverseJavaScriptAst(program, {
enter: (node, parent) => {
enter: (node, parent, readAncestors) => {
let parentScope = currentSemanticScope(stack);
if (
parent !== null &&
(t.isObjectMethod(parent) || t.isClassMethod(parent)) &&
parent.computed &&
parent.key === node &&
parentScope.parentScopeId !== null
) {
// A computed method key is evaluated before entering the method's
// parameter and body environments.
const keyScope = state.scopesById.get(parentScope.parentScopeId);
if (keyScope !== undefined) {
stack.push(keyScope);
openedScopes.set(node, 1);
parentScope = keyScope;
}
}
if (
parent !== null &&
t.isWithStatement(parent) &&
@@ -324,6 +351,12 @@ const collectDefinitions = (
if (nested !== undefined) {
stack.push(nested);
openedScopes.set(node, (openedScopes.get(node) ?? 0) + 1);
if (t.isBlockStatement(node) && t.isFunction(parent))
functionBodies.push({
body: node,
scope: nested,
parameters: parent.params,
});
}
const scope = currentSemanticScope(stack);
state.scopeByNode.set(node, scope);
@@ -335,12 +368,95 @@ const collectDefinitions = (
state,
});
bindInnerDeclaration(node, parent, scope, state);
if (
t.isVariableDeclarator(node) &&
node.init != null &&
parent !== null &&
t.isVariableDeclaration(parent, { kind: "var" }) &&
hasConditionalInitializer(readAncestors())
)
state.conditionalInitializers.add(node.init);
if (
t.isAssignmentExpression(node) ||
t.isUpdateExpression(node) ||
t.isForOfStatement(node) ||
t.isForInStatement(node)
)
assignments.push({ node, scope });
},
exit: (node) => {
for (let count = openedScopes.get(node) ?? 0; count > 0; count--)
stack.pop();
},
});
// All declarations must exist before resolving writes, including hoisted
// functions and local declarations that shadow an outer binding.
for (const { node, scope } of assignments) bindAssignment(node, scope, state);
for (const { body, scope, parameters } of functionBodies)
for (const parameter of parameters)
for (const identifier of assignedPatternIdentifiers(
t.isTSParameterProperty(parameter) ? parameter.parameter : parameter,
))
copyParameterToBody({ identifier, body, scope, state });
};
const copyParameterToBody = (input: {
readonly identifier: t.Identifier;
readonly body: t.BlockStatement;
readonly scope: JavaScriptSemanticScopeState;
readonly state: JavaScriptSemanticAnalysisState;
}): void => {
const { identifier, body, scope, state } = input;
const binding = scope.bindings.get(identifier.name);
if (
binding === undefined ||
binding.kind !== "variable" ||
binding.definitions.some(({ kind }) => kind === "function")
)
return;
// A separate body var environment starts with the parameter's value. The
// real parameter identifier retains its scope and source location; entryBody
// records when the copy occurs without inventing an expression in the AST.
binding.initializers.unshift({
node: identifier,
projection: [],
entryBody: body,
});
for (let index = binding.initializers.length - 1; index >= 0; index -= 1) {
const initializer = binding.initializers[index];
if (
initializer !== undefined &&
initializer.entryBody === undefined &&
initializer.projection.length === 0 &&
t.isIdentifier(initializer.node) &&
resolveSemanticBindingState(
state,
initializer.node,
initializer.node.name,
) === binding
)
// Direct self-initialization preserves the prior value, including any
// actual overwrite before it. Keep the source definition/reference facts.
binding.initializers.splice(index, 1);
}
};
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.
for (let index = ancestors.length - 1; index >= 0; index -= 1) {
const node = ancestors[index];
if (t.isFunction(node) || t.isProgram(node) || t.isStaticBlock(node))
return false;
if (
t.isIfStatement(node) ||
t.isLoop(node) ||
t.isSwitchCase(node) ||
t.isTryStatement(node)
)
return true;
}
return false;
};
const bindOuterDeclaration = (
@@ -408,7 +524,14 @@ const bindInnerDeclaration = (
mutable: true,
kind: "catch",
});
else if (t.isAssignmentExpression(node)) {
};
const bindAssignment = (
node: t.Node,
scope: JavaScriptSemanticScopeState,
state: JavaScriptSemanticAnalysisState,
): void => {
if (t.isAssignmentExpression(node)) {
if (t.isIdentifier(node.left))
addAssignment(
node.left,
@@ -426,8 +549,11 @@ const bindInnerDeclaration = (
} else if (t.isUpdateExpression(node) && t.isIdentifier(node.argument))
addAssignment(node.argument, node, scope, state);
else if (t.isForOfStatement(node) || t.isForInStatement(node))
for (const identifier of assignedPatternIdentifiers(node.left))
addAssignment(identifier, node, scope, state);
for (const pattern of t.isVariableDeclaration(node.left)
? node.left.declarations.map(({ id }) => id)
: [node.left])
for (const identifier of assignedPatternIdentifiers(pattern))
addAssignment(identifier, node, scope, state);
};
const assignedPatternIdentifiers = (
@@ -539,12 +665,42 @@ const scopeKind = (
if (t.isStaticBlock(node)) return "static-block";
if (
t.isBlockStatement(node) &&
!(parent !== null && t.isFunction(parent) && parent.body === node)
)
return "block";
parent !== null &&
t.isFunction(parent) &&
parent.body === node
) {
// Parameter expressions cannot see declarations in the function body.
// Retain the outer function scope for parameters and callable ownership;
// body declarations belong to a separate variable environment.
return functionParametersHaveExpressions(parent) ? "function" : undefined;
}
if (t.isBlockStatement(node)) return "block";
return undefined;
};
const functionParametersHaveExpressions = (node: t.Function): boolean => {
const pending: t.Node[] = [...node.params];
while (pending.length > 0) {
const parameter = pending.pop();
if (t.isAssignmentPattern(parameter)) return true;
if (t.isTSParameterProperty(parameter)) pending.push(parameter.parameter);
else if (t.isRestElement(parameter)) pending.push(parameter.argument);
else if (t.isArrayPattern(parameter)) {
for (const element of parameter.elements)
if (element !== null) pending.push(element);
} else if (t.isObjectPattern(parameter)) {
for (const property of parameter.properties) {
if (t.isRestElement(property)) pending.push(property.argument);
else {
if (property.computed) return true;
pending.push(property.value);
}
}
}
}
return false;
};
const bindImports = (
node: t.ImportDeclaration,
scope: JavaScriptSemanticScopeState,