fix(javascript): handle deep AST traversal and addition without stack overflow (#915)

* fix(javascript): avoid recursion and eager ancestry copies in deep ASTs

* fix(javascript): traverse static analysis and bundler ASTs iteratively
This commit is contained in:
morluto authored and GitHub committed 2026-10-07 17:40:05 +08:00
1 parent f6a58f1062
commit 62b37111e7
9 files changed
+203 -40

No files matched your search

@@ -2,6 +2,8 @@ import { createHash } from "node:crypto";
import * as t from "@babel/types";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { compareCodePoints } from "../canonicalOrdering.js";
import { propertyName } from "./javascriptAstValues.js";
import { stringValue } from "./javascriptStaticAnalysisHelpers.js";
@@ -14,9 +16,11 @@ export interface StaticExports {
/** Derive a rename-resistant syntax fingerprint without code execution. */
export const fingerprintJavaScriptAst = (node: t.Node): string => {
const tokens: string[] = [];
t.traverseFast(node, (current) => {
tokens.push(...semanticTokens(current));
return undefined;
traverseJavaScriptAst(node, {
enter: (current) => {
tokens.push(...semanticTokens(current));
return undefined;
},
});
return createHash("sha256").update(JSON.stringify(tokens)).digest("hex");
};
@@ -24,11 +28,13 @@ export const fingerprintJavaScriptAst = (node: t.Node): string => {
/** Collect statically declared CommonJS/bundler export names from one factory. */
export const collectJavaScriptExports = (node: t.Node): StaticExports => {
const exports = new Set<string>();
t.traverseFast(node, (current) => {
if (t.isAssignmentExpression(current))
collectAssignmentExports(current.left, current.right, exports);
if (t.isCallExpression(current)) collectCallExports(current, exports);
return undefined;
traverseJavaScriptAst(node, {
enter: (current) => {
if (t.isAssignmentExpression(current))
collectAssignmentExports(current.left, current.right, exports);
if (t.isCallExpression(current)) collectCallExports(current, exports);
return undefined;
},
});
return {
values: [...exports].sort(compareCodePoints),
@@ -282,6 +282,9 @@ describe("primitive addition values", () => {
['"x" + false', "xfalse"],
['null + "x"', "nullx"],
["3 + 4", 7],
['1 + 2 + "3"', "33"],
['1 + (2 + "3")', "123"],
['(1 + 2) + ("3" + 4)', "334"],
])("recovers %s using primitive coercion", (expression, expected) => {
const ir = analyzeJavaScriptSemantics(`const answer = ${expression};`);
expect(topLevelBinding(ir, "answer").value).toEqual({
@@ -1,7 +1,17 @@
import * as t from "@babel/types";
import { fc, it } from "@fast-check/vitest";
import { describe, expect } from "vitest";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
import {
analyzeJavaScriptSemantics,
analyzeParsedJavaScriptSemantics,
} from "./javascriptSemanticAnalysis.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
import { analyzeParsedJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import {
collectJavaScriptExports,
fingerprintJavaScriptAst,
} from "./javascriptAstFingerprint.js";
import type { JavaScriptSemanticValue } from "./javascriptSemanticIr.js";
import {
onlyCallable,
@@ -9,6 +19,45 @@ import {
} from "./javascriptSemanticAnalysis.fixture.js";
describe("JavaScript semantic analysis: rejection 1", () => {
it.each(["left", "right"])(
"retains semantic facts and values from a deeply %s-chained AST",
(direction) => {
// Build an admitted AST directly: Babel's own recursion limit varies
// with parser optimization and must not determine this regression.
const parsed = parseJavaScriptSource("const result = 1; result;");
const declaration = parsed?.program.body[0];
if (parsed === null || !t.isVariableDeclaration(declaration))
throw new TypeError("Expected a parsed variable declaration");
const declarator = declaration.declarations[0];
if (declarator === undefined)
throw new TypeError("Expected a variable declarator");
let expression: t.Expression = t.numericLiteral(1);
for (let index = 1; index < 12000; index += 1)
expression =
direction === "left"
? t.binaryExpression("+", expression, t.numericLiteral(1))
: t.binaryExpression("+", t.numericLiteral(1), expression);
declarator.init = expression;
const staticAnalysis = analyzeParsedJavaScriptStaticSource("", parsed);
expect(staticAnalysis.parse_status).toBe("complete");
expect(staticAnalysis.visited_ast_nodes).toBe(24006);
expect(collectJavaScriptExports(parsed).values).toEqual([]);
expect(fingerprintJavaScriptAst(parsed)).toMatch(/^[a-f0-9]{64}$/u);
const ir = analyzeParsedJavaScriptSemantics(parsed);
expect(ir.coverage.status).toBe("complete");
const binding = topLevelBinding(ir, "result");
expect(binding.value).toEqual({ status: "literal", value: 12000 });
expect(ir.references).toEqual([
expect.objectContaining({
name: "result",
role: "read",
resolution: "resolved",
bindingId: binding.bindingId,
}),
]);
},
);
it("retains complete finite facts and deeply nested static values", () => {
const names = Array.from(
{ length: 280 },
@@ -100,9 +100,9 @@ export const collectSemanticReferences = (
const output: JavaScriptSemanticReference[] = [];
const seen = new Set<string>();
traverseJavaScriptAst(program, {
enter: (node, parent, ancestors) => {
enter: (node, parent, readAncestors) => {
if (!t.isIdentifier(node) || parent === null) return;
const role = semanticIdentifierRole(node, parent, ancestors);
const role = semanticIdentifierRole(node, parent, readAncestors());
if (role === null) return;
// A compound assignment or update reads before it writes; emit both so
// reference consumers see the read they actually execute.
@@ -0,0 +1,53 @@
import * as t from "@babel/types";
import { expect, it } from "vitest";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
it("preserves source-tree enter/exit order and detached ancestor snapshots", () => {
const parsed = parseJavaScriptSource("const answer = first + second;");
if (parsed === null)
throw new TypeError("Expected a parsed JavaScript fixture");
const visits: string[] = [];
const snapshots: (readonly t.Node[])[] = [];
traverseJavaScriptAst(parsed.program, {
enter: (node, parent, readAncestors) => {
visits.push(`enter:${node.type}`);
const ancestors = readAncestors();
expect(ancestors.at(-1) ?? null).toBe(parent);
if (t.isIdentifier(node)) snapshots.push(ancestors);
},
exit: (node) => visits.push(`exit:${node.type}`),
});
expect(visits).toEqual([
"enter:Program",
"enter:VariableDeclaration",
"enter:VariableDeclarator",
"enter:Identifier",
"exit:Identifier",
"enter:BinaryExpression",
"enter:Identifier",
"exit:Identifier",
"enter:Identifier",
"exit:Identifier",
"exit:BinaryExpression",
"exit:VariableDeclarator",
"exit:VariableDeclaration",
"exit:Program",
]);
expect(snapshots.map((nodes) => nodes.map(({ type }) => type))).toEqual([
["Program", "VariableDeclaration", "VariableDeclarator"],
[
"Program",
"VariableDeclaration",
"VariableDeclarator",
"BinaryExpression",
],
[
"Program",
"VariableDeclaration",
"VariableDeclarator",
"BinaryExpression",
],
]);
});
@@ -3,7 +3,8 @@ import * as t from "@babel/types";
/**
* Visit every Babel AST node in deterministic source-tree order.
*
* `ancestors` runs outermost-first and ends with the direct parent. Callers
* `readAncestors` returns a snapshot during `enter`, outermost-first and
* ending with the direct parent. Callers
* need it whenever meaning depends on where a node sits, not just what its
* immediate parent is — a binding inside `catch ({message})` or `({a} = o)`
* has an ObjectProperty parent, so the parent alone cannot say whether the
@@ -15,24 +16,40 @@ export const traverseJavaScriptAst = (
readonly enter: (
node: t.Node,
parent: t.Node | null,
ancestors: readonly t.Node[],
readAncestors: () => readonly t.Node[],
) => void;
readonly exit?: (node: t.Node, parent: t.Node | null) => void;
},
): void => {
const visit = (
node: t.Node,
parent: t.Node | null,
ancestors: readonly t.Node[],
): void => {
visitor.enter(node, parent, ancestors);
for (const child of childNodes(node))
visit(child, node, [...ancestors, node]);
visitor.exit?.(node, parent);
const ancestors: t.Node[] = [];
const enter = (node: t.Node, parent: t.Node | null): TraversalFrame => {
visitor.enter(node, parent, () => [...ancestors]);
ancestors.push(node);
return { node, parent, children: childNodes(node), nextIndex: 0 };
};
visit(root, null, []);
const pending = [enter(root, null)];
while (pending.length > 0) {
const current = pending.at(-1);
if (current === undefined) break;
const child = current.children[current.nextIndex];
if (child !== undefined) {
current.nextIndex += 1;
pending.push(enter(child, current.node));
} else {
visitor.exit?.(current.node, current.parent);
ancestors.pop();
pending.pop();
}
}
};
interface TraversalFrame {
readonly node: t.Node;
readonly parent: t.Node | null;
readonly children: readonly t.Node[];
nextIndex: number;
}
const childNodes = (node: t.Node): t.Node[] => {
const keys: readonly string[] = t.VISITOR_KEYS[node.type] ?? [];
return keys.flatMap((key) => {
@@ -268,12 +268,38 @@ const evaluateAddition = (
node: t.BinaryExpression,
context: EvaluationContext,
): JavaScriptSemanticValue => {
const left = primitiveCandidates(
evaluateExpression(node.left, nestedContext(context)),
);
const right = primitiveCandidates(
evaluateExpression(node.right, nestedContext(context)),
);
const pending: {
readonly node: t.BinaryExpression;
left: JavaScriptSemanticValue | undefined;
}[] = [];
let current: t.Node = node;
while (true) {
if (t.isBinaryExpression(current, { operator: "+" })) {
pending.push({ node: current, left: undefined });
current = current.left;
continue;
}
let value = evaluateExpression(current, nestedContext(context));
while (true) {
const parent = pending.at(-1);
if (parent === undefined) return value;
if (parent.left === undefined) {
parent.left = value;
current = parent.node.right;
break;
}
value = addPrimitiveValues(parent.left, value);
pending.pop();
}
}
};
const addPrimitiveValues = (
leftValue: JavaScriptSemanticValue,
rightValue: JavaScriptSemanticValue,
): JavaScriptSemanticValue => {
const left = primitiveCandidates(leftValue);
const right = primitiveCandidates(rightValue);
if (left === null || right === null)
return { status: "unknown", reason: "Non-primitive addition." };
const values = left.flatMap((leftValue) =>
@@ -1,5 +1,7 @@
import * as t from "@babel/types";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { inspectElectronStaticNode } from "./electronStaticAnalysis.js";
import {
detectVendors,
@@ -56,10 +58,12 @@ const traverseStaticSource = (
file: ParsedJavaScriptSource,
accumulator: AnalysisAccumulator,
): void => {
t.traverseFast(file, (node) => {
accumulator.visitedNodes += 1;
inspectNode(source, node, accumulator);
return undefined;
traverseJavaScriptAst(file, {
enter: (node) => {
accumulator.visitedNodes += 1;
inspectNode(source, node, accumulator);
return undefined;
},
});
};
@@ -1,5 +1,7 @@
import * as t from "@babel/types";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import {
collectJavaScriptExports,
fingerprintJavaScriptAst,
@@ -251,13 +253,16 @@ const collectBundlerCallArgumentValues = (
): StaticValues => {
const values: string[] = [];
let unknown = 0;
t.traverseFast(factory, (node) => {
if (!t.isCallExpression(node) && !t.isNewExpression(node)) return undefined;
if (calleeName(node.callee) !== callee) return undefined;
const value = argumentValue(node.arguments[0]);
if (value === undefined) unknown += 1;
else values.push(value);
return undefined;
traverseJavaScriptAst(factory, {
enter: (node) => {
if (!t.isCallExpression(node) && !t.isNewExpression(node))
return undefined;
if (calleeName(node.callee) !== callee) return undefined;
const value = argumentValue(node.arguments[0]);
if (value === undefined) unknown += 1;
else values.push(value);
return undefined;
},
});
return uniqueValues(values, unknown);
};