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:
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);
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user