diff --git a/src/domain/javascript/javascriptAstFingerprint.ts b/src/domain/javascript/javascriptAstFingerprint.ts index 569a4895d5..00bbaefba0 100644 --- a/src/domain/javascript/javascriptAstFingerprint.ts +++ b/src/domain/javascript/javascriptAstFingerprint.ts @@ -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(); - 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), diff --git a/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts index fa9ab25969..384b8fe671 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts @@ -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({ diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejection.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejection.test.ts index 5f6c05f3a1..0df194a05f 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejection.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejection.test.ts @@ -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 }, diff --git a/src/domain/javascript/javascriptSemanticProjection.ts b/src/domain/javascript/javascriptSemanticProjection.ts index 7df1464b68..578c53a139 100644 --- a/src/domain/javascript/javascriptSemanticProjection.ts +++ b/src/domain/javascript/javascriptSemanticProjection.ts @@ -100,9 +100,9 @@ export const collectSemanticReferences = ( const output: JavaScriptSemanticReference[] = []; const seen = new Set(); 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. diff --git a/src/domain/javascript/javascriptSemanticTraversal.test.ts b/src/domain/javascript/javascriptSemanticTraversal.test.ts new file mode 100644 index 0000000000..1d012c1df2 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticTraversal.test.ts @@ -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", + ], + ]); +}); diff --git a/src/domain/javascript/javascriptSemanticTraversal.ts b/src/domain/javascript/javascriptSemanticTraversal.ts index e782055323..d3a99f2707 100644 --- a/src/domain/javascript/javascriptSemanticTraversal.ts +++ b/src/domain/javascript/javascriptSemanticTraversal.ts @@ -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) => { diff --git a/src/domain/javascript/javascriptSemanticValues.ts b/src/domain/javascript/javascriptSemanticValues.ts index 8a55b74d45..78fc72c1c6 100644 --- a/src/domain/javascript/javascriptSemanticValues.ts +++ b/src/domain/javascript/javascriptSemanticValues.ts @@ -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) => diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index c1e0a56be5..72a462ba87 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -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; + }, }); }; diff --git a/src/domain/javascript/javascriptStaticAnalysisBundler.ts b/src/domain/javascript/javascriptStaticAnalysisBundler.ts index d5fb295580..3e964f332f 100644 --- a/src/domain/javascript/javascriptStaticAnalysisBundler.ts +++ b/src/domain/javascript/javascriptStaticAnalysisBundler.ts @@ -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); };