diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index e4a419bc97..5b8de26ff0 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -259,6 +259,13 @@ Direct return expressions, including expression-bodied arrows, are evaluated through an execution-free value lattice. Literal object fields and direct return sites are represented in the result. Calls, dynamic spreads, computed keys, and parser recovery remain partial or unknown. +Rebound values are evaluated at their read position: saving an incoming parameter +before a later assignment preserves caller-dependent uncertainty, while a proven +preceding assignment can supply a constant. Unresolved ordering remains unknown. +Supported reads within object and array literals follow left-to-right evaluation; +an unproved relative order remains unknown. A later write in a separate conditional +block can still leave an earlier capture unknown because ordering across those +blocks is not proven. Objects and arrays passed to calls, constructors, or tagged templates, used as method receivers, or stored through property targets are not assumed unchanged afterward. Property stores conservatively retain reference escape diff --git a/src/domain/javascript/javascriptAstValues.ts b/src/domain/javascript/javascriptAstValues.ts index cd2b5d754f..54edede262 100644 --- a/src/domain/javascript/javascriptAstValues.ts +++ b/src/domain/javascript/javascriptAstValues.ts @@ -16,6 +16,7 @@ export const runtimeFunctionParameters = ( */ export const unwrapJavaScriptExpression = ( node: t.Node, + options?: { readonly stopAtParentheses: boolean }, ): { readonly node: t.Node; readonly depth: number } => { let current = node; let depth = 0; @@ -28,6 +29,14 @@ export const unwrapJavaScriptExpression = ( t.isTSInstantiationExpression(current) || t.isTypeCastExpression(current) ) { + // Optional-chain grouping affects execution even when expression identity + // is transparent. Keep every grouping boundary available to that consumer. + if ( + options?.stopAtParentheses === true && + (t.isParenthesizedExpression(current) || + current.extra?.parenthesized === true) + ) + break; current = current.expression; depth += 1; } diff --git a/src/domain/javascript/javascriptSemanticAnalysis.scope_depth.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.scope_depth.test.ts index e954166ccf..89eaf31cf5 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.scope_depth.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.scope_depth.test.ts @@ -33,19 +33,37 @@ const ancestryReads = ( } }; -it("bounds ancestry reads for repeated deep lexical references", () => { +it.each(["const a=1;", "let a; a=1;"])( + "bounds ancestry reads for repeated deep lexical references after %s", + (declaration) => { + const reads = (depth: number): number => + ancestryReads( + `${declaration} module.exports.value=${"a+(".repeat(depth)}a${")".repeat(depth)};`, + (ir) => { + expect(ir.coverage).toEqual({ status: "complete", omittedCount: 0 }); + expect(onlyBinding(ir, "a").value).toEqual({ + status: "literal", + value: 1, + }); + }, + ); + + const smaller = reads(80); + const larger = reads(160); + expect(smaller).toBeGreaterThan(0); + expect(larger).toBeLessThan(3 * smaller); + }, +); + +it("bounds ancestry reads after a write in the same expression", () => { const reads = (depth: number): number => ancestryReads( - `const a=1; module.exports.value=${"a+(".repeat(depth)}a${")".repeat(depth)};`, + `let a=0; module.exports.value={set:(a=1),value:${"a+(".repeat(depth)}a${")".repeat(depth)}};`, (ir) => { expect(ir.coverage).toEqual({ status: "complete", omittedCount: 0 }); - expect(onlyBinding(ir, "a").value).toEqual({ - status: "literal", - value: 1, - }); + expect(onlyBinding(ir, "a").value.status).toBe("ambiguous"); }, ); - const smaller = reads(80); const larger = reads(160); expect(smaller).toBeGreaterThan(0); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 3749f2f732..ee98affa79 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -1,5 +1,7 @@ import * as t from "@babel/types"; +import { semanticBranchMayBeSkipped } from "./javascriptSemanticEvaluationOrder.js"; + import { failedJavaScriptSemanticIr, type JavaScriptModuleOrigin, @@ -900,58 +902,12 @@ const writeMayBeSkipped = ( t.isStaticBlock(parent) ) return false; - if (isSkippedBranch(parent, child)) return true; + if (semanticBranchMayBeSkipped(parent, child)) return true; child = parent; } return false; }; -const isSkippedBranch = (parent: t.Node, child: t.Node): boolean => { - if (t.isIfStatement(parent)) - return child === parent.consequent || child === parent.alternate; - if (t.isSwitchCase(parent)) return true; - if (t.isTryStatement(parent)) - return child === parent.block || child === parent.handler; - if (t.isForStatement(parent)) - return child === parent.body || child === parent.update; - if (t.isWhileStatement(parent)) return child === parent.body; - if (t.isDoWhileStatement(parent)) return false; - if (t.isForInStatement(parent) || t.isForOfStatement(parent)) - return child === parent.left || child === parent.body; - if (t.isLogicalExpression(parent)) return child === parent.right; - if (t.isConditionalExpression(parent)) - return child === parent.consequent || child === parent.alternate; - if (t.isOptionalCallExpression(parent) || t.isCallExpression(parent)) - return ( - parent.arguments.includes(child as t.Expression) && - optionalCallArgumentsMayBeSkipped(parent) - ); - if (t.isOptionalMemberExpression(parent)) - return parent.optional && parent.computed && child === parent.property; - return false; -}; - -const optionalCallArgumentsMayBeSkipped = ( - call: t.CallExpression | t.OptionalCallExpression, -): boolean => { - if (t.isOptionalCallExpression(call) && call.optional) return true; - let callee: t.Node = call.callee; - while (true) { - // Parentheses terminate optional-chain short-circuiting. In particular, - // `(foo?.bar)(arg)` evaluates arg before attempting the call. - if (callee.extra?.parenthesized === true) return false; - if (t.isOptionalMemberExpression(callee)) { - if (callee.optional) return true; - callee = callee.object; - } else if (t.isMemberExpression(callee)) callee = callee.object; - else if (t.isOptionalCallExpression(callee)) { - if (callee.optional) return true; - callee = callee.callee; - } else if (t.isCallExpression(callee)) callee = callee.callee; - else return false; - } -}; - const bindOuterDeclaration = ( node: t.Node, scope: JavaScriptSemanticScopeState, diff --git a/src/domain/javascript/javascriptSemanticAnalysis.unions.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.unions.test.ts index 21ef6c2105..cfd7da8795 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.unions.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.unions.test.ts @@ -404,8 +404,8 @@ describe("semantic primitive allocation and binding reuse", () => { }); expect( ir.callables.find(({ name }) => name === "readsMutation")?.returnSites[0] - ?.value.status, - ).toBe("ambiguous"); + ?.value, + ).toEqual({ status: "literal", value: "after" }); }); it("retains a large derived string while its aggregate JSON estimate fits", () => { diff --git a/src/domain/javascript/javascriptSemanticEvaluationOrder.ts b/src/domain/javascript/javascriptSemanticEvaluationOrder.ts new file mode 100644 index 0000000000..82adfdb318 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticEvaluationOrder.ts @@ -0,0 +1,82 @@ +import * as t from "@babel/types"; + +import { unwrapJavaScriptExpression } from "./javascriptAstValues.js"; + +const preserveGrouping = { stopAtParentheses: true } as const; + +/** Instance values run on construction; their computed keys run at definition. */ +export const delayedInstanceFieldValue = ( + parent: t.Node | undefined, + child: t.Node | undefined, +): boolean => + (t.isClassProperty(parent) || + t.isClassPrivateProperty(parent) || + t.isClassAccessorProperty(parent)) && + !parent.static && + parent.value === child; + +/** Shared branch facts for binding writes and their possible execution positions. */ +export const semanticBranchMayBeSkipped = ( + parent: t.Node, + child: t.Node, +): boolean => { + if (delayedInstanceFieldValue(parent, child)) return true; + if (t.isIfStatement(parent)) + return child === parent.consequent || child === parent.alternate; + if (t.isSwitchCase(parent)) return true; + if (t.isTryStatement(parent)) + return child === parent.block || child === parent.handler; + if (t.isForStatement(parent)) + return child === parent.body || child === parent.update; + if (t.isWhileStatement(parent)) return child === parent.body; + if (t.isDoWhileStatement(parent)) return child === parent.test; + if (t.isForInStatement(parent) || t.isForOfStatement(parent)) + return child === parent.left || child === parent.body; + if (t.isLogicalExpression(parent)) return child === parent.right; + if (t.isAssignmentPattern(parent)) return child === parent.right; + if (t.isAssignmentExpression(parent)) + return ( + ["&&=", "||=", "??="].includes(parent.operator) && child === parent.right + ); + if (t.isConditionalExpression(parent)) + return child === parent.consequent || child === parent.alternate; + if (t.isOptionalCallExpression(parent) || t.isCallExpression(parent)) + return ( + parent.arguments.includes(child as t.Expression) && + ((t.isOptionalCallExpression(parent) && parent.optional) || + optionalChainMayShortCircuit(parent.callee)) + ); + if (t.isOptionalMemberExpression(parent)) + return ( + parent.computed && + child === parent.property && + (parent.optional || optionalChainMayShortCircuit(parent.object)) + ); + return false; +}; + +const optionalChainMayShortCircuit = (expression: t.Node): boolean => { + let current = expression; + while (true) { + // Parentheses terminate optional-chain short-circuiting. In particular, + // `(foo?.bar)(arg)` evaluates arg before attempting the call. + if (current.extra?.parenthesized === true) return false; + const unwrapped = unwrapJavaScriptExpression( + current, + preserveGrouping, + ).node; + if (unwrapped !== current) { + current = unwrapped; + continue; + } + if (t.isOptionalMemberExpression(current)) { + if (current.optional) return true; + current = current.object; + } else if (t.isMemberExpression(current)) current = current.object; + else if (t.isOptionalCallExpression(current)) { + if (current.optional) return true; + current = current.callee; + } else if (t.isCallExpression(current)) current = current.callee; + else return false; + } +}; diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.ts index fba3882261..524facbf89 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.ts @@ -1,5 +1,10 @@ import * as t from "@babel/types"; +import { + delayedInstanceFieldValue, + semanticBranchMayBeSkipped, +} from "./javascriptSemanticEvaluationOrder.js"; + import { semanticStaticPropertyKey, unwrapJavaScriptExpression, @@ -12,6 +17,23 @@ interface StatementPosition { readonly declaratorIndex: number; } +interface BindingReadPosition { + readonly position: StatementPosition | null; + readonly repeating: boolean; +} + +interface ExpressionSiblings { + readonly common: t.Node; + readonly earlier: t.Node; + readonly later: t.Node; +} + +interface ExpressionAncestry { + readonly source: t.Node; + readonly ancestors: ReadonlyMap; + readonly siblings: WeakMap; +} + /** * Initializer positions depend only on a node and its parent map, which one * analysis builds once and never changes. Different mutations and initializers @@ -22,6 +44,14 @@ interface InitializerPositionCache { readonly direct: WeakMap; readonly mutation: WeakMap; readonly reference: WeakMap; + readonly read: WeakMap; + readonly evaluationChildren: WeakMap< + t.Node, + ReadonlyMap | null + >; + // One source context bounds retained ancestry facts to O(AST), even when + // an expression contains many distinct writes. Reuse its descendant paths. + expressionAncestry: ExpressionAncestry | undefined; } const initializerPositionCaches = new WeakMap< @@ -38,6 +68,9 @@ const initializerPositionCache = ( direct: new WeakMap(), mutation: new WeakMap(), reference: new WeakMap(), + read: new WeakMap(), + evaluationChildren: new WeakMap(), + expressionAncestry: undefined, }; initializerPositionCaches.set(parents, created); return created; @@ -85,14 +118,7 @@ export const semanticTopLevelEffectFollowsCapture = ( let child: t.Node | undefined; while (node !== undefined) { if (t.isFunction(node) || t.isLoop(node)) return null; - if ( - (t.isClassProperty(node) || - t.isClassPrivateProperty(node) || - t.isClassAccessorProperty(node)) && - !node.static && - node.value === child - ) - return null; + if (delayedInstanceFieldValue(node, child)) return null; if (t.isVariableDeclarator(node)) declarator = node; if (selected === null && t.isStatement(node)) selected = statementPosition(node, parents, declarator); @@ -112,59 +138,132 @@ export const semanticTopLevelEffectFollowsCapture = ( before.declaratorIndex !== after.declaratorIndex ) return false; - return expressionEvaluationPrecedes(capture, effect, parents); + return expressionEvaluationOrder(capture, effect, parents) === "before"; }; -/** Prove ordering only where JavaScript evaluates sibling expressions left-to-right. */ -const expressionEvaluationPrecedes = ( +/** Memoize descendant paths toward the currently compared source expression. */ +const expressionSiblings = ( + earlier: t.Node, + later: t.Node, + parents: WeakMap, +): ExpressionSiblings | null => { + const cache = initializerPositionCache(parents); + let context = cache.expressionAncestry; + if (context?.source !== earlier) { + const ancestors = new Map(); + let child: t.Node | undefined; + for ( + let node: t.Node | undefined = earlier; + node !== undefined; + node = parents.get(node) + ) { + ancestors.set(node, child); + child = node; + } + context = { source: earlier, ancestors, siblings: new WeakMap() }; + cache.expressionAncestry = context; + } + const path: t.Node[] = []; + let result: ExpressionSiblings | null = null; + for ( + let node: t.Node | undefined = later; + node !== undefined; + node = parents.get(node) + ) { + if (context.ancestors.has(node)) { + const sourceChild = context.ancestors.get(node); + const readChild = path.at(-1); + if (sourceChild !== undefined && readChild !== undefined) + result = { common: node, earlier: sourceChild, later: readChild }; + break; + } + const cached = context.siblings.get(node); + if (cached !== undefined) { + result = cached; + break; + } + path.push(node); + } + for (const node of path) context.siblings.set(node, result); + return result; +}; + +/** Index existing child arrays once; wide literals/calls need no repeated copies. */ +const expressionChildIndices = ( + node: t.Node, + parents: WeakMap, +): ReadonlyMap | null => { + const cache = initializerPositionCache(parents).evaluationChildren; + const cached = cache.get(node); + if (cached !== undefined) return cached; + const children = ((): readonly (t.Node | null)[] | null => { + if (t.isSequenceExpression(node)) return node.expressions; + if (t.isArrayExpression(node)) return node.elements; + if (t.isObjectExpression(node)) return node.properties; + if (t.isObjectProperty(node)) + return node.computed ? [node.key, node.value] : [node.value]; + if ( + t.isCallExpression(node) || + t.isOptionalCallExpression(node) || + t.isNewExpression(node) + ) + return node.arguments; + if (t.isBinaryExpression(node) || t.isLogicalExpression(node)) + return [node.left, node.right]; + if (t.isConditionalExpression(node)) return [node.test]; + return null; + })(); + if (children === null) { + cache.set(node, null); + return null; + } + const indices = new Map(); + let index = 0; + if ( + t.isCallExpression(node) || + t.isOptionalCallExpression(node) || + t.isNewExpression(node) + ) + indices.set(node.callee, index++); + for (const child of children) { + if (child !== null) indices.set(child, index); + index++; + } + cache.set(node, indices); + return indices; +}; + +/** Unproved ordering stays distinct from proof that a write follows the read. */ +const expressionEvaluationOrder = ( earlier: t.Node, later: t.Node | undefined, parents: WeakMap, -): boolean => { - if (later === undefined) return false; - const earlierAncestors = new Set(); - let current: t.Node | undefined = earlier; - while (current !== undefined) { - earlierAncestors.add(current); - current = parents.get(current); - } - const laterPath: t.Node[] = []; - current = later; - while (current !== undefined && !earlierAncestors.has(current)) { - laterPath.push(current); - current = parents.get(current); - } - const common = current; - if (common === undefined || common === earlier || common === later) - return false; - let earlierChild: t.Node = earlier; - while (parents.get(earlierChild) !== common) { - const parent = parents.get(earlierChild); - if (parent === undefined) return false; - earlierChild = parent; - } - const laterChild = laterPath.at(-1); - if (laterChild === undefined) return false; - const orderedChildren = ((): readonly t.Node[] | null => { - if (t.isSequenceExpression(common)) return common.expressions; - if (t.isCallExpression(common) || t.isOptionalCallExpression(common)) - return [common.callee, ...common.arguments]; - if (t.isNewExpression(common)) return [common.callee, ...common.arguments]; - if (t.isBinaryExpression(common) || t.isLogicalExpression(common)) - return [common.left, common.right]; - if (t.isConditionalExpression(common)) return [common.test]; - return null; - })(); - if (orderedChildren === null) return false; - const earlierIndex = orderedChildren.indexOf(earlierChild); - const laterIndex = orderedChildren.indexOf(laterChild); - return earlierIndex >= 0 && laterIndex > earlierIndex; +): "before" | "after" | "unresolved" => { + if (later === undefined) return "unresolved"; + const siblings = expressionSiblings(earlier, later, parents); + if (siblings === null) return "unresolved"; + const children = expressionChildIndices(siblings.common, parents); + const earlierIndex = children?.get(siblings.earlier); + const laterIndex = children?.get(siblings.later); + if ( + earlierIndex === undefined || + laterIndex === undefined || + earlierIndex === laterIndex + ) + return "unresolved"; + return earlierIndex < laterIndex ? "before" : "after"; }; const computeDirectInitializerPosition = ( node: t.Node, parents: WeakMap, ): StatementPosition | null => { + if ( + (t.isAssignmentExpression(node) && + t.isIdentifier(unwrapJavaScriptExpression(node.left).node)) || + t.isUpdateExpression(node) + ) + return directMutationPosition(node, parents); const parent = parents.get(node); if (t.isVariableDeclarator(parent) && parent.init === node) { const declaration = parents.get(parent); @@ -174,14 +273,10 @@ const computeDirectInitializerPosition = ( } if ( t.isAssignmentExpression(parent) && - parent.operator === "=" && parent.right === node && t.isIdentifier(parent.left) ) { - const statement = parents.get(parent); - return t.isExpressionStatement(statement) && statement.expression === parent - ? statementPosition(statement, parents) - : null; + return directMutationPosition(parent, parents); } return null; }; @@ -231,8 +326,7 @@ const computeDirectMutationPosition = ( if ( parent === undefined || t.isFunction(parent) || - (t.isLogicalExpression(parent) && parent.right === current) || - (t.isConditionalExpression(parent) && parent.test !== current) + semanticBranchMayBeSkipped(parent, current) ) return null; if (t.isStatement(parent)) return statementPosition(parent, parents); @@ -268,7 +362,15 @@ const computeReferenceInitializerPosition = ( const direct = directInitializerPosition(current, parents); if (direct !== null) return direct; const parent = parents.get(current); - if (parent === undefined || t.isStatement(parent) || t.isFunction(parent)) + if (t.isFunction(parent)) + return t.isBlockStatement(parent.body) + ? { body: parent.body.body, index: -1, declaratorIndex: -1 } + : null; + if ( + parent === undefined || + t.isStatement(parent) || + delayedInstanceFieldValue(parent, current) + ) return null; if (t.isVariableDeclarator(parent)) { const declaration = parents.get(parent); @@ -292,6 +394,19 @@ const referenceInitializerPosition = ( return computed; }; +/** Parameter-to-body copies and ordinary expressions share one position owner. */ +const initializerPosition = ( + initializer: JavaScriptSemanticBindingState["initializers"][number], + parents: WeakMap, + reference = false, +): StatementPosition | null => { + if (initializer.entryBody !== undefined) + return { body: initializer.entryBody.body, index: -1, declaratorIndex: -1 }; + return reference + ? referenceInitializerPosition(initializer.node, parents) + : directInitializerPosition(initializer.node, parents); +}; + /** Resolve origins at their use or capture, never at a later transitive effect. */ export const semanticMutationInitializers = ( binding: JavaScriptSemanticBindingState, @@ -308,18 +423,11 @@ export const semanticMutationInitializers = ( const origins = [ ...binding.initializers.map((initializer) => ({ initializer, - position: - initializer.entryBody === undefined - ? directInitializerPosition(initializer.node, parents) - : { - body: initializer.entryBody.body, - index: -1, - declaratorIndex: -1, - }, + position: initializerPosition(initializer, parents), })), ...binding.referenceInitializers.map((initializer) => ({ initializer, - position: referenceInitializerPosition(initializer.node, parents), + position: initializerPosition(initializer, parents, true), })), ]; if ( @@ -329,27 +437,14 @@ export const semanticMutationInitializers = ( ) ) return binding; - const latest = origins.reduce( - (latest, { position }) => - position !== null && - (latest === null || positionPrecedes(latest, position)) - ? position - : latest, - null, - ); - const retained = new Set( - origins - .filter( - ({ position }) => - position?.index === latest?.index && - position?.declaratorIndex === latest?.declaratorIndex, - ) - .map(({ initializer }) => initializer), + const { retained, replacesEntry } = retainedInitializerOrigins( + origins, + parents, ); return { // A proven earlier assignment replaces even an import-equals value. A // capture before or inside an uncertain assignment retains the full binding. - directOrigins: origins.length === 0 ? binding.directOrigins : [], + directOrigins: replacesEntry ? [] : binding.directOrigins, initializers: binding.initializers.filter((initializer) => retained.has(initializer), ), @@ -359,96 +454,159 @@ export const semanticMutationInitializers = ( }; }; -/** Select a local alias at its read, before any later straight-line reassignment. */ -export const semanticReadInitializers = ( - binding: JavaScriptSemanticBindingState, +/** Reuse immutable ancestry facts across reads nested in the same expression. */ +const bindingReadPosition = ( read: t.Node, parents: WeakMap, -): Pick< - JavaScriptSemanticBindingState, - "initializers" | "referenceInitializers" -> => { - let position: StatementPosition | null = null; +): BindingReadPosition => { + const cache = initializerPositionCache(parents).read; + const path: t.Node[] = []; let current = read; - for ( - let parent = parents.get(current); - parent; - parent = parents.get(current) - ) { - if (t.isLoop(parent)) return binding; - if (t.isFunction(parent)) break; - if (position === null) { - if (t.isVariableDeclarator(parent)) { - const declaration = parents.get(parent); - if (t.isVariableDeclaration(declaration)) - position = statementPosition(declaration, parents, parent); - } else if (t.isStatement(parent)) - position = statementPosition(parent, parents); + let result: BindingReadPosition; + while (true) { + const cached = cache.get(current); + if (cached !== undefined) { + result = cached; + break; + } + path.push(current); + const parent = parents.get(current); + if (t.isLoop(parent)) { + result = { position: null, repeating: true }; + break; + } + if ( + parent === undefined || + t.isFunction(parent) || + delayedInstanceFieldValue(parent, current) + ) { + result = { position: null, repeating: false }; + break; } current = parent; } - return selectInitializers(binding, position, parents, true); + for (let index = path.length - 1; index >= 0; index--) { + const node = path[index]; + if (node === undefined) continue; + const parent = parents.get(node); + let position: StatementPosition | null = null; + if (t.isVariableDeclarator(parent)) { + const declaration = parents.get(parent); + if (t.isVariableDeclaration(declaration)) + position = statementPosition(declaration, parents, parent); + } else if ( + t.isStatement(parent) && + !t.isLoop(parent) && + !t.isFunction(parent) + ) + position = statementPosition(parent, parents); + if (position !== null && position !== result.position) + result = { ...result, position }; + cache.set(node, result); + } + return result; +}; + +/** Select a local alias at its read, before any later straight-line reassignment. */ +type SemanticBindingRead = { + readonly status: "ordered" | "unresolved"; + /** Unresolved reads retain all possible origins for conservative alias effects. */ + readonly binding: JavaScriptSemanticBindingState; +}; + +export const semanticBindingAtRead = ( + binding: JavaScriptSemanticBindingState, + read: t.Node, + parents: WeakMap, +): SemanticBindingRead => { + if ( + binding.initializers.length === 0 && + binding.referenceInitializers.length === 0 + ) + return { status: "ordered", binding }; + const initializer = binding.initializers[0]; + const reference = binding.referenceInitializers[0]; + const sourcePosition = + initializer === undefined + ? reference === undefined + ? null + : initializerPosition(reference, parents, true) + : initializerPosition(initializer, parents); + const { position, repeating } = bindingReadPosition(read, parents); + if ( + !repeating && + binding.initializers.length + binding.referenceInitializers.length === 1 && + sourcePosition !== null && + position !== null && + positionPrecedes(sourcePosition, position) + ) + return { status: "ordered", binding }; + const uncertainOrder = + repeating || + sourcePosition === null || + position === null || + sourcePosition.body !== position.body; + if (uncertainOrder) return { status: "unresolved", binding }; + const selected = selectInitializers(binding, position, read, parents); + if (selected === undefined) return { status: "unresolved", binding }; + if (selected === binding) return { status: "ordered", binding }; + return { + status: "ordered", + binding: { + ...binding, + ...selected, + // Later assignment definitions do not describe the value captured here. + definitions: binding.definitions.filter( + ({ kind }) => kind !== "assignment", + ), + }, + }; }; const selectInitializers = ( binding: JavaScriptSemanticBindingState, position: StatementPosition | null, + read: t.Node, parents: WeakMap, - atRead = false, -): Pick< - JavaScriptSemanticBindingState, - "initializers" | "referenceInitializers" -> => { - if (position === null) return binding; +): + | Pick< + JavaScriptSemanticBindingState, + "initializers" | "referenceInitializers" + > + | undefined => { + if (position === null) return undefined; const origins = [ ...binding.initializers.map((initializer) => ({ initializer, - position: - initializer.entryBody === undefined - ? directInitializerPosition(initializer.node, parents) - : { - body: initializer.entryBody.body, - index: -1, - declaratorIndex: -1, - }, + position: initializerPosition(initializer, parents), })), ...binding.referenceInitializers.map((initializer) => ({ initializer, - position: referenceInitializerPosition(initializer.node, parents), + position: initializerPosition(initializer, parents, true), })), ]; - if ( - origins.some( - ({ position: origin }) => - origin === null || - origin.body !== position.body || - (!atRead && !positionPrecedes(origin, position)), - ) - ) - return binding; - const preceding = atRead - ? origins.filter( - ({ position: origin }) => - origin !== null && positionPrecedes(origin, position), + const preceding: typeof origins = []; + for (const origin of origins) { + const source = origin.position; + if (source === null || source.body !== position.body) return undefined; + if (positionPrecedes(source, position)) preceding.push(origin); + else if (!positionPrecedes(position, source)) { + if ( + source.index !== position.index || + source.declaratorIndex !== position.declaratorIndex ) - : origins; - const latest = preceding.reduce( - (latest, { position }) => - position !== null && - (latest === null || positionPrecedes(latest, position)) - ? position - : latest, - null, - ); - const retained = new Set( - preceding - .filter( - ({ position }) => - position?.index === latest?.index && - position?.declaratorIndex === latest?.declaratorIndex, - ) - .map(({ initializer }) => initializer), - ); + return undefined; + const order = expressionEvaluationOrder( + origin.initializer.node, + read, + parents, + ); + // Failure to prove precedence is not proof that the write happens later. + if (order === "unresolved") return undefined; + if (order === "before") preceding.push(origin); + } + } + const { retained } = retainedInitializerOrigins(preceding, parents); // Preserve the owner's identity when selection removes no facts. Consumers // can then reuse the binding's dependency-aware provenance memo rather than // treating every equivalent read as a new temporal snapshot. @@ -469,6 +627,56 @@ const selectInitializers = ( }; }; +/** Logical writes retain the preceding value as a possible alias. */ +const logicalInitializer = ( + node: t.Node, + parents: WeakMap, +): boolean => { + const parent = parents.get(node); + const assignment = t.isAssignmentExpression(node) + ? node + : t.isAssignmentExpression(parent) && parent.right === node + ? parent + : undefined; + return ( + t.isAssignmentExpression(assignment) && + ["&&=", "||=", "??="].includes(assignment.operator) + ); +}; + +/** Certain writes replace prior origins; short-circuit writes retain both. */ +const retainedInitializerOrigins = ( + origins: readonly { + readonly initializer: JavaScriptSemanticBindingState["initializers"][number]; + readonly position: StatementPosition | null; + }[], + parents: WeakMap, +) => { + const latest = origins.reduce( + (latest, { initializer, position }) => + position !== null && + !logicalInitializer(initializer.node, parents) && + (latest === null || positionPrecedes(latest, position)) + ? position + : latest, + null, + ); + return { + replacesEntry: latest !== null, + retained: new Set( + origins + .filter( + ({ position }) => + latest === null || + (position?.index === latest.index && + position?.declaratorIndex === latest.declaratorIndex) || + (position !== null && positionPrecedes(latest, position)), + ) + .map(({ initializer }) => initializer), + ), + }; +}; + /** Locate a capture only in an unconditional declaration or literal initializer. */ export const semanticCapturePosition = ( node: t.Node, @@ -500,7 +708,7 @@ export const semanticBindingPrecedesCapture = ( const initializer = binding.initializers[0]; if (binding.initializers.length !== 1 || initializer === undefined) return false; - const sourcePosition = directInitializerPosition(initializer.node, parents); + const sourcePosition = initializerPosition(initializer, parents); const capturePosition = semanticCapturePosition(capture, parents); return ( sourcePosition !== null && diff --git a/src/domain/javascript/javascriptSemanticObjectReferences.ts b/src/domain/javascript/javascriptSemanticObjectReferences.ts index 111417b5a7..d16294fb50 100644 --- a/src/domain/javascript/javascriptSemanticObjectReferences.ts +++ b/src/domain/javascript/javascriptSemanticObjectReferences.ts @@ -4,7 +4,7 @@ import { type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js"; -import { semanticReadInitializers } from "./javascriptSemanticMutationInitializers.js"; +import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; import { semanticReferenceSourcesInactive } from "./javascriptSemanticReferenceSources.js"; import { semanticStaticPropertyKey, @@ -589,7 +589,8 @@ const getterResultReferences = ( pathLength: remaining.length, parent: current.aliases, }; - const origins = semanticReadInitializers( + // Unknown read ordering retains possible aliases for effect reachability. + const { binding: origins } = semanticBindingAtRead( binding, expression, state.parentsByNode, diff --git a/src/domain/javascript/javascriptSemanticReturns.ts b/src/domain/javascript/javascriptSemanticReturns.ts index 50adf38ef1..61a90203a0 100644 --- a/src/domain/javascript/javascriptSemanticReturns.ts +++ b/src/domain/javascript/javascriptSemanticReturns.ts @@ -28,7 +28,7 @@ import { evaluateSemanticExpression } from "./javascriptSemanticValues.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; import { semanticReturnCoverage } from "./javascriptSemanticCoverage.js"; import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js"; -import { semanticReadInitializers } from "./javascriptSemanticMutationInitializers.js"; +import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; interface ReturnExpression { readonly node: t.Node | null; @@ -161,7 +161,7 @@ const callableIdsForBinding = ( const selected = read === undefined ? binding - : semanticReadInitializers(binding, read, state.parentsByNode); + : semanticBindingAtRead(binding, read, state.parentsByNode).binding; const initializer = selected.initializers[0]; if ( initializer === undefined || diff --git a/src/domain/javascript/javascriptSemanticValues.memo.test.ts b/src/domain/javascript/javascriptSemanticValues.memo.test.ts index 5a9395cdbf..cfa9a7f6e4 100644 --- a/src/domain/javascript/javascriptSemanticValues.memo.test.ts +++ b/src/domain/javascript/javascriptSemanticValues.memo.test.ts @@ -5,6 +5,7 @@ import { indexJavaScriptBindingsSteps, } from "./javascriptSemanticAnalysis.js"; import { SemanticDependencyProofs } from "./javascriptSemanticDependencies.js"; +import { SEMANTIC_EXPRESSION_DEPTH_LIMIT } from "./javascriptSemanticResourceLimits.js"; import type { JavaScriptSemanticValue } from "./javascriptSemanticValueTypes.js"; import { clearSemanticValueCaches, @@ -24,15 +25,168 @@ const property = ( return result; }; +it("reuses dependency-aware read snapshots across a reconvergent value chain", () => { + const measure = (depth: number) => { + const source = `let a0=1;${Array.from( + { length: depth }, + (_, index) => `let a${index + 1}=a${index}+a${index};`, + ).join( + "", + )}const saved=a${depth};${Array.from({ length: depth + 1 }, (_, index) => `a${index}=0;`).join("")}`; + const parsed = parseJavaScriptSource(source, "saved-chain.js"); + if (parsed === null) throw new Error("Expected parsed source"); + const steps = indexJavaScriptBindingsSteps(parsed.program); + let step = steps.next(); + while (step.done !== true) step = steps.next(); + const state = step.value; + const saved = [...state.bindingsById.values()].find( + ({ name }) => name === "saved", + ); + if (saved === undefined) throw new Error("Missing saved binding"); + const binding = vi.spyOn(SemanticDependencyProofs.prototype, "binding"); + try { + expect(evaluateSemanticBinding(saved, state)).toEqual({ + status: "literal", + value: 2 ** depth, + }); + return binding.mock.calls.length; + } finally { + binding.mockRestore(); + } + }; + const small = measure(8); + const large = measure(12); + expect(small).toBeGreaterThan(0); + expect(large).toBeLessThan(3 * small); +}); + +it("preserves the remaining depth budget when reusing a read snapshot", () => { + const depth = 12; + const nesting = SEMANTIC_EXPRESSION_DEPTH_LIMIT - depth; + const source = `let a0=1;${Array.from({ length: depth }, (_, index) => `let a${index + 1}=a${index}+a${index};`).join("")} +const root={shallow:a${depth},deep:${"[".repeat(nesting)}a${depth}${"]".repeat(nesting)},after:a${depth}}; +${Array.from({ length: depth + 1 }, (_, index) => `a${index}=0;`).join("")}`; + const parsed = parseJavaScriptSource(source, "snapshot-depth.js"); + if (parsed === null) throw new Error("Expected parsed source"); + const steps = indexJavaScriptBindingsSteps(parsed.program); + let step = steps.next(); + while (step.done !== true) step = steps.next(); + const state = step.value; + const root = [...state.bindingsById.values()].find( + ({ name }) => name === "root", + ); + if (root === undefined) throw new Error("Missing root binding"); + const result = evaluateSemanticBinding(root, state); + for (const key of ["shallow", "after"]) + expect(property(result, key)).toEqual({ + status: "literal", + value: 2 ** depth, + }); + let leaf = property(result, "deep"); + while (leaf.status === "array") { + const item = leaf.items[0]; + if (item === undefined) throw new Error("Missing nested item"); + leaf = item.value; + } + expect(leaf).toMatchObject({ + status: "unknown", + resourceLimit: "expression-depth", + }); +}); + +it("bounds reconvergent read work when the expression depth budget is exhausted", () => { + const depth = SEMANTIC_EXPRESSION_DEPTH_LIMIT; + const source = `let a0=1;${Array.from({ length: depth }, (_, index) => `let a${index + 1}=a${index}+a${index};`).join("")} +const saved=a${depth};${Array.from({ length: depth + 1 }, (_, index) => `a${index}=0;`).join("")}`; + const parsed = parseJavaScriptSource(source, "snapshot-limit.js"); + if (parsed === null) throw new Error("Expected parsed source"); + const steps = indexJavaScriptBindingsSteps(parsed.program); + let step = steps.next(); + while (step.done !== true) step = steps.next(); + const state = step.value; + const saved = [...state.bindingsById.values()].find( + ({ name }) => name === "saved", + ); + if (saved === undefined) throw new Error("Missing saved binding"); + const original = SemanticDependencyProofs.prototype.binding; + let visits = 0; + const binding = vi + .spyOn(SemanticDependencyProofs.prototype, "binding") + .mockImplementation(function (this: SemanticDependencyProofs, name) { + if (++visits > 16 * depth) + throw new Error("Unbounded snapshot dependency evaluation"); + return original.call(this, name); + }); + try { + expect(evaluateSemanticBinding(saved, state)).toMatchObject({ + status: "unknown", + resourceLimit: "expression-depth", + }); + } finally { + binding.mockRestore(); + } +}); + it.each([false, true])( - "recomputes a shared cyclic value under a different active ancestor (prewarmed: %s)", + "isolates captured parameter values from the assigned binding cache (prewarmed: %s)", (prewarmed) => { + const parsed = parseJavaScriptSource( + `export function read(x) { + const earlier = x; + x = 2; + const later = x; + return { earlier, later }; + }`, + "captured-parameter.js", + ); + if (parsed === null) throw new Error("Expected parsed source"); + const steps = indexJavaScriptBindingsSteps(parsed.program); + let step = steps.next(); + while (step.done !== true) step = steps.next(); + const state = step.value; + const bindings = new Map( + [...state.bindingsById.values()].map((binding) => [ + binding.name, + binding, + ]), + ); + const assigned = bindings.get("x"); + const earlier = bindings.get("earlier"); + const later = bindings.get("later"); + if (assigned === undefined || earlier === undefined || later === undefined) + throw new Error("Missing capture bindings"); + if (prewarmed) + expect(evaluateSemanticBinding(assigned, state)).toEqual({ + status: "literal", + value: 2, + }); + expect(evaluateSemanticBinding(earlier, state).status).toBe("unknown"); + expect(evaluateSemanticBinding(later, state)).toEqual({ + status: "literal", + value: 2, + }); + expect(evaluateSemanticBinding(assigned, state)).toEqual({ + status: "literal", + value: 2, + }); + }, +); + +it.each([ + [false, false], + [true, false], + [false, true], + [true, true], +])( + "recomputes a shared cyclic value under a different active ancestor (prewarmed: %s, rebound: %s)", + (prewarmed, rebound) => { const parsed = parseJavaScriptSource( ` -const a = { shared: common, own: "a" }; -const b = { shared: common, own: "b" }; +${rebound ? "let" : "const"} a = { shared: common, own: "a" }; +${rebound ? "let" : "const"} b = { shared: common, own: "b" }; const common = { a, b }; const result = { first: a, second: b }; +${rebound ? "a=0; b=0;" : ""} `, "cycles.js", ); @@ -65,34 +219,43 @@ const result = { first: a, second: b }; }, ); -it("invalidates retained alias values at the producing mutation boundary", () => { - const parsed = parseJavaScriptSource( - "const target = { value: 1 }; const alias = target; const root = { alias };", - "mutation.js", - ); - if (parsed === null) throw new Error("Expected parsed source"); - const steps = indexJavaScriptBindingsSteps(parsed.program); - let step = steps.next(); - while (step.done !== true) step = steps.next(); - const state = step.value; - const target = [...state.bindingsById.values()].find( - ({ name }) => name === "target", - ); - const root = [...state.bindingsById.values()].find( - ({ name }) => name === "root", - ); - if (target === undefined || root === undefined) - throw new Error("Missing binding"); - expect( - property(property(evaluateSemanticBinding(root, state), "alias"), "value"), - ).toEqual({ status: "literal", value: 1 }); - target.memberWrites.push({ path: ["value"], node: undefined }); - clearSemanticValueCaches(state); - expect( - property(property(evaluateSemanticBinding(root, state), "alias"), "value") - .status, - ).toBe("unknown"); -}); +it.each([ + ["const", ""], + ["let", "target={value:2};"], +])( + "invalidates retained %s alias values at the producing mutation boundary", + (kind, later) => { + const parsed = parseJavaScriptSource( + `${kind} target = { value: 1 }; const alias = target; const root = { alias }; ${later}`, + "mutation.js", + ); + if (parsed === null) throw new Error("Expected parsed source"); + const steps = indexJavaScriptBindingsSteps(parsed.program); + let step = steps.next(); + while (step.done !== true) step = steps.next(); + const state = step.value; + const target = [...state.bindingsById.values()].find( + ({ name }) => name === "target", + ); + const root = [...state.bindingsById.values()].find( + ({ name }) => name === "root", + ); + if (target === undefined || root === undefined) + throw new Error("Missing binding"); + expect( + property( + property(evaluateSemanticBinding(root, state), "alias"), + "value", + ), + ).toEqual({ status: "literal", value: 1 }); + target.memberWrites.push({ path: ["value"], node: undefined }); + clearSemanticValueCaches(state); + expect( + property(property(evaluateSemanticBinding(root, state), "alias"), "value") + .status, + ).toBe("unknown"); + }, +); const balancedOr = (names: string[]): string => { if (names.length === 1) return names[0]!; diff --git a/src/domain/javascript/javascriptSemanticValues.ts b/src/domain/javascript/javascriptSemanticValues.ts index 0b30984c79..6c83210434 100644 --- a/src/domain/javascript/javascriptSemanticValues.ts +++ b/src/domain/javascript/javascriptSemanticValues.ts @@ -9,7 +9,7 @@ import { semanticCapturePosition, semanticEscapeFollowsCapture, semanticBindingPrecedesCapture, - semanticReadInitializers, + semanticBindingAtRead, } from "./javascriptSemanticMutationInitializers.js"; import { semanticSlotAtPath } from "./javascriptSemanticSlots.js"; @@ -63,8 +63,19 @@ interface MemoizedEvaluation { readonly dependencies: SemanticDependencyProof; } -/** Provenance lives for one query; live values belong to the effects-aware state. */ +interface ReadSnapshotValue extends MemoizedEvaluation { + readonly depthDependent: boolean; +} + +type ReadSnapshotValues = WeakMap< + JavaScriptSemanticBindingState["initializers"][number], + Map> +>; + +/** Read/provenance memos live for one query; whole values follow state effects. */ interface EvaluationMemo { + snapshotValues?: ReadSnapshotValues; + capturedSnapshotValues?: WeakMap; readonly provenance: Map< string, Map> @@ -291,9 +302,50 @@ const collectEvaluationDependencies = ( } }; +/** + * A value snapshot consumes its sole initializer plus its source binding's + * effects. Key the parsed initializer fact (including projection), binding ID + * and capture point, rather than an ephemeral snapshot object or the whole ID. + * Query ownership prevents reuse across effect changes. Exact expression-depth + * keys also bound failed speculative evaluations without skipping their budget. + */ +const readSnapshotValuesFor = ( + binding: JavaScriptSemanticBindingState, + context: EvaluationContext, +): Map | undefined => { + const initializer = binding.initializers[0]; + if (binding.initializers.length !== 1 || initializer === undefined) + return undefined; + let values: ReadSnapshotValues; + if (context.capturePoint === undefined) + values = context.memo.snapshotValues ??= new WeakMap(); + else { + const captures = (context.memo.capturedSnapshotValues ??= new WeakMap()); + let captured = captures.get(context.capturePoint); + if (captured === undefined) { + captured = new WeakMap(); + captures.set(context.capturePoint, captured); + } + values = captured; + } + let entries = values.get(initializer); + if (entries === undefined) { + entries = new Map(); + values.set(initializer, entries); + } + let depths = entries.get(binding.bindingId); + if (depths === undefined) { + depths = new Map(); + entries.set(binding.bindingId, depths); + } + return depths; +}; + const evaluateBinding = ( binding: JavaScriptSemanticBindingState, context: EvaluationContext, + // Temporal read facts use a query-owned cache separate from whole bindings. + wholeBinding = true, ): JavaScriptSemanticValue => { recordBindingDependency(binding, context); const cycleHead = context.bindings.get(binding.bindingId); @@ -302,6 +354,7 @@ const evaluateBinding = ( return { status: "cycle", reason: `Alias cycle at ${binding.name}.` }; } if ( + wholeBinding && context.capturePoint !== undefined && !semanticBindingPrecedesCapture( binding, @@ -315,14 +368,28 @@ const evaluateBinding = ( primitiveBindingValues: primitiveBindingValuesFor(context.state), }); } - const cached = context.primitiveBindingValues.get(binding.bindingId); + const cached = wholeBinding + ? context.primitiveBindingValues.get(binding.bindingId) + : undefined; if (cached !== undefined) return cached; // Each closure that aliases an outer binding evaluates it both live and at // its capture point, so re-deriving it doubles the work at every level of // nesting. A deeper reader derives it again: the retained value would carry // more nesting than the remaining depth budget allows it to observe. + const snapshots = wholeBinding + ? undefined + : readSnapshotValuesFor(binding, context); + const snapshot = snapshots?.get(context.expressionDepth); + const snapshotValue = readMemoizedEvaluation(snapshot, context); + if (snapshotValue !== undefined) { + // A result limited by this budget must not become caller-independent when + // reused inside another binding evaluated at the same remaining depth. + if (snapshot?.depthDependent === true) + context.dependence.lowest = EVERY_CALLER; + return snapshotValue; + } const independent = - context.capturePoint === undefined + wholeBinding && context.capturePoint === undefined ? callerIndependentBindingValuesFor(context.state) : undefined; const retained = independent?.get(binding.bindingId); @@ -336,7 +403,9 @@ const evaluateBinding = ( if (binding.initializers.length === 0) return { status: "unknown", - reason: `Binding ${binding.name} has no constant initializer.`, + reason: wholeBinding + ? `Binding ${binding.name} has no constant initializer.` + : `Binding ${binding.name} has no proven initializer at this read.`, }; if ( binding.initializers.some( @@ -372,6 +441,7 @@ const evaluateBinding = ( context.bindings.set(binding.bindingId, position); let value: JavaScriptSemanticValue; let callerIndependent = false; + let depthDependent = false; let evaluated: MemoizedEvaluation; try { evaluated = collectEvaluationDependencies(binding, context, () => { @@ -380,7 +450,7 @@ const evaluateBinding = ( evaluateExpression(initializer.node, nested), initializer.projection, ); - if (!isPrimitive(value)) { + if (canCapturePrimitive(value)) { const captureContext = primitiveCaptureContext( initializer.node, nested, @@ -399,6 +469,7 @@ const evaluateBinding = ( // A cycle among bindings this one reads is part of its value. A cycle // through this binding is cut elsewhere when a caller holds another member. callerIndependent = context.dependence.lowest > position; + depthDependent = context.dependence.lowest === EVERY_CALLER; } finally { context.bindings.delete(binding.bindingId); context.dependence.lowest = callerIndependent @@ -435,15 +506,31 @@ const evaluateBinding = ( dependencies: evaluated.dependencies, expressionDepth: context.expressionDepth, }); + // A cycle through an active caller is not reusable. Depth-dependent partial + // values are reusable at this exact budget, with their dependence preserved. + if ( + snapshots !== undefined && + (callerIndependent || depthDependent) && + !context.memo.dependencies.intersects( + evaluated.dependencies, + context.bindings, + ) + ) + snapshots.set(context.expressionDepth, { + value: projected, + dependencies: evaluated.dependencies, + depthDependent, + }); // Point-local memoization also bounds failed speculative captures. Only a // primitive result is allowed back into ordinary value evaluation. if ( - context.capturePoint !== undefined || - projected.status === "literal" || - projected.status === "union" || - (isSemanticResourceLimit(projected) && - (projected.resourceLimit === "primitive-bytes" || - projected.resourceLimit === "primitive-candidates")) + wholeBinding && + (context.capturePoint !== undefined || + projected.status === "literal" || + projected.status === "union" || + (isSemanticResourceLimit(projected) && + (projected.resourceLimit === "primitive-bytes" || + projected.resourceLimit === "primitive-candidates"))) ) context.primitiveBindingValues.set(binding.bindingId, projected); return projected; @@ -484,9 +571,28 @@ const evaluateExpressionValue = ( if (literal.found) return primitiveSet([literal.value]); if (t.isIdentifier(node)) { const binding = resolveSemanticBindingState(context.state, node, node.name); - return binding === undefined - ? { status: "unknown", reason: `Unbound identifier ${node.name}.` } - : evaluateBinding(binding, nestedContext(context)); + if (binding === undefined) + return { status: "unknown", reason: `Unbound identifier ${node.name}.` }; + const read = + 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" || + value.status === "ambiguous" || + value.status === "cycle" + ) + return value; + return { + status: "unknown", + reason: `Binding ${binding.name} has unresolved ordering at this read.`, + }; + } + const atRead = read?.binding ?? binding; + return evaluateBinding(atRead, nestedContext(context), atRead === binding); } if (t.isTemplateLiteral(node)) return evaluateTemplate(node, context); if (t.isConditionalExpression(node)) @@ -1021,26 +1127,17 @@ const provenanceForBindingAtRead = ( read: t.Identifier, context: EvaluationContext, ): JavaScriptBindingProvenance => { - const selected = semanticReadInitializers( + const { binding: snapshot } = semanticBindingAtRead( binding, read, context.state.parentsByNode, ); - if (selected.initializers === binding.initializers) - return provenanceForBinding(binding, context); + if (snapshot === binding) return provenanceForBinding(binding, context); if (context.bindings.has(binding.bindingId)) return semanticUnresolvedProvenance( "cycle", `Alias cycle at ${binding.name}.`, ); - const snapshot: JavaScriptSemanticBindingState = { - ...binding, - ...selected, - // Assignment facts after the read cannot change the captured value. - definitions: binding.definitions.filter( - ({ kind }) => kind !== "assignment", - ), - }; context.bindings.set(binding.bindingId, context.bindings.size); try { return provenanceForBindingValue(snapshot, context); @@ -1091,6 +1188,13 @@ const nestedContext = (context: EvaluationContext): EvaluationContext => ({ const isPrimitive = (value: JavaScriptSemanticValue): boolean => value.status === "literal" || value.status === "union"; +/** Capturing cannot restore an exhausted expression-depth budget. */ +const canCapturePrimitive = (value: JavaScriptSemanticValue): boolean => + !isPrimitive(value) && + !( + isSemanticResourceLimit(value) && value.resourceLimit === "expression-depth" + ); + const primitiveCaptureContext = ( node: t.Node, context: EvaluationContext, @@ -1131,7 +1235,7 @@ const evaluateCapturedExpression = ( const cached = values?.get(node); if (cached !== undefined) return cached; let value = evaluateExpression(node, context); - if (!isPrimitive(value)) { + if (canCapturePrimitive(value)) { const captureContext = primitiveCaptureContext(node, context); if (captureContext !== undefined) { const captured = evaluateExpression(node, captureContext); diff --git a/tests/acceptance/applications/javascriptCapturedParameters.test.ts b/tests/acceptance/applications/javascriptCapturedParameters.test.ts new file mode 100644 index 0000000000..a12fd38bdd --- /dev/null +++ b/tests/acceptance/applications/javascriptCapturedParameters.test.ts @@ -0,0 +1,464 @@ +import { execFile } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { expect } from "vitest"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; +import { verifyJavaScriptReturnShapes } from "../../fixtures/javascriptReturnShapes.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const source = ` +export function captured(x) { const saved=x; x=2; return {type:"ok",value:saved}; } +export function assigned(x) { x=2; const saved=x; return {value:saved}; } +export function between(x) { const before=x; x=2; const after=x; return {before,after}; } +export function intermediate(x) { x=1; const saved=x; x=2; return {value:saved}; } +export function nested(x) { let saved; { saved=x; } x=2; return {value:saved}; } +export function alias(x) { const first=x; const saved=first; x=2; return {value:saved}; } +export function defaulted(x=1) { const saved=x; x=2; return {value:saved}; } +export function destructured({x}) { const saved=x; x=2; return {value:saved}; } +export function local() { let value="before"; value="after"; return {value}; } +`; + +cliTest( + "preserves incoming values and proven assignments through CLI and stdio MCP", + async ({ cli }) => { + const oracle = await promisify(execFile)( + process.execPath, + [ + "--input-type=module", + "-e", + `${source} +console.log(JSON.stringify([captured(5),assigned(5),between(5),intermediate(5),nested(5),alias(5),defaulted(5),destructured({x:5}),local()]));`, + ], + { timeout: 10_000 }, + ); + expect(JSON.parse(oracle.stdout)).toEqual([ + { type: "ok", value: 5 }, + { value: 2 }, + { before: 5, after: 2 }, + { value: 1 }, + { value: 5 }, + { value: 5 }, + { value: 5 }, + { value: 5 }, + { value: "after" }, + ]); + await verifyJavaScriptReturnShapes(cli, source, (fields) => { + for (const name of [ + "captured", + "nested", + "alias", + "defaulted", + "destructured", + ]) + expect(fields(name)).toContainEqual( + expect.objectContaining({ path: "/value", state: "unknown" }), + ); + expect(fields("captured")).toContainEqual( + expect.objectContaining({ + path: "/type", + state: "literal", + value: "ok", + }), + ); + expect(fields("assigned")).toContainEqual( + expect.objectContaining({ path: "/value", state: "literal", value: 2 }), + ); + expect(fields("local")).toContainEqual( + expect.objectContaining({ + path: "/value", + state: "literal", + value: "after", + }), + ); + expect(fields("intermediate")).toContainEqual( + expect.objectContaining({ path: "/value", state: "literal", value: 1 }), + ); + expect(fields("between")).toContainEqual( + expect.objectContaining({ path: "/before", state: "unknown" }), + ); + expect(fields("between")).toContainEqual( + expect.objectContaining({ path: "/after", state: "literal", value: 2 }), + ); + }); + }, + 120_000, +); + +cliTest( + "distinguishes delayed instance values from immediate static fields and computed keys", + async ({ cli }) => { + const fieldsSource = ` +export function delayed() { let x=0; class C { field=(x=1) } return {value:x}; } +export function incoming(x) { class C { field=(x=1) } return {value:x}; } +export function delayedPrivate() { let x=0; class C { #field=(x=1) } return {value:x}; } +export function staticField() { let x=0; class C { static field=(x=1) } return {value:x}; } +export function staticPrivate() { let x=0; class C { static #field=(x=1) } return {value:x}; } +export function computedKey() { let x=0; class C { [x=1]=7 } return {value:x}; } +`; + const oracle = await promisify(execFile)( + process.execPath, + [ + "--input-type=module", + "-e", + `${fieldsSource} +console.log(JSON.stringify([delayed(),incoming(5),delayedPrivate(),staticField(),staticPrivate(),computedKey()]));`, + ], + { timeout: 10_000 }, + ); + expect(JSON.parse(oracle.stdout)).toEqual([ + { value: 0 }, + { value: 5 }, + { value: 0 }, + { value: 1 }, + { value: 1 }, + { value: 1 }, + ]); + await verifyJavaScriptReturnShapes(cli, fieldsSource, (fields) => { + for (const name of ["delayed", "incoming", "delayedPrivate"]) + expect(fields(name)).toContainEqual( + expect.objectContaining({ path: "/value", state: "unknown" }), + ); + for (const name of ["staticField", "staticPrivate", "computedKey"]) + expect(fields(name)).toContainEqual( + expect.objectContaining({ + path: "/value", + state: "literal", + value: 1, + }), + ); + }); + }, + 120_000, +); + +cliTest( + "retains possible aliases through skipped optional-chain and logical assignments", + async ({ cli }) => { + const cases = [ + ["optionalCall", "receiver?.method(alias=replacement)"], + ["optionalCallee", "receiver?.method?.(alias=replacement)"], + ["optionalKey", "receiver?.[alias=replacement]"], + ["continuedKey", "receiver?.field[alias=replacement]"], + ["logicalWrite", "flag ||= (alias=replacement)"], + ["optionalTypedCall", "receiver?.method!(alias=replacement)"], + ["optionalTypedKey", "receiver?.field![alias=replacement]"], + ["groupedCall", "(receiver?.method)(alias=replacement)"], + ["ordinaryCall", "receiver.method(alias=replacement)"], + ["groupedTypedCall", "(receiver?.method!)(alias=replacement)"], + [ + "assertedGroupedCall", + "(receiver?.method as ((value: unknown) => void))!(alias=replacement)", + ], + ] as const; + const aliasesSource = cases + .map( + ([name, expression]) => `export function ${name}(receiver,flag) { +const original={member:1}; let alias=original; const replacement={member:2}; +${expression}; alias.member=3; return {value:original.member}; +}`, + ) + .join("\n"); + const oracle = await promisify(execFile)( + process.execPath, + [ + "--input-type=module-typescript", + "-e", + `${aliasesSource} +const receiver={method() {},field:{}}; +console.log(JSON.stringify([optionalCall(null),optionalCall(receiver),optionalCallee(null),optionalCallee(receiver),optionalKey(null),optionalKey(receiver),continuedKey(null),continuedKey(receiver),logicalWrite(null,true),logicalWrite(null,false),groupedCall(receiver),ordinaryCall(receiver),optionalTypedCall(null),optionalTypedCall(receiver),optionalTypedKey(null),optionalTypedKey(receiver),groupedTypedCall(receiver),assertedGroupedCall(receiver)]));`, + ], + { timeout: 10_000 }, + ); + expect(JSON.parse(oracle.stdout)).toEqual( + [3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 1, 1, 3, 1, 3, 1, 1, 1].map((value) => ({ + value, + })), + ); + await verifyJavaScriptReturnShapes( + cli, + aliasesSource, + (fields) => { + for (const [name] of cases.slice(0, 7)) + expect(fields(name)).toContainEqual( + expect.objectContaining({ path: "/value", state: "unknown" }), + ); + for (const [name] of cases.slice(7)) + expect(fields(name)).toContainEqual( + expect.objectContaining({ + path: "/value", + state: "literal", + value: 1, + }), + ); + }, + "app.ts", + ); + }, + 120_000, +); + +cliTest( + "orders object and array reads while keeping unsupported expression order unknown", + async ({ cli }) => { + const orderedSource = ` +export function objectOrder() { let x=0; return {set:(x=1),value:x}; } +export function bothSides() { let x=0; return {before:x,set:(x=1),after:x}; } +export function arrayOrder() { let x=0; return [x,(x=1),x]; } +export function computedOrder() { let x=0; return {[x=1]:0,value:x}; } +export function templateOrder() { let x=0; return {value:\`\${x=1}\${x}\`}; } +`; + const oracle = await promisify(execFile)( + process.execPath, + [ + "--input-type=module", + "-e", + `${orderedSource} +console.log(JSON.stringify([objectOrder(),bothSides(),arrayOrder(),computedOrder(),templateOrder()]));`, + ], + { timeout: 10_000 }, + ); + expect(JSON.parse(oracle.stdout)).toEqual([ + { set: 1, value: 1 }, + { before: 0, set: 1, after: 1 }, + [0, 1, 1], + { "1": 0, value: 1 }, + { value: "11" }, + ]); + await verifyJavaScriptReturnShapes(cli, orderedSource, (fields) => { + for (const name of ["objectOrder", "computedOrder"]) + expect(fields(name)).toContainEqual( + expect.objectContaining({ + path: "/value", + state: "literal", + value: 1, + }), + ); + expect(fields("bothSides")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: "/before", + state: "literal", + value: 0, + }), + expect.objectContaining({ + path: "/after", + state: "literal", + value: 1, + }), + ]), + ); + expect(fields("arrayOrder")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: "/0", state: "literal", value: 0 }), + expect.objectContaining({ path: "/2", state: "literal", value: 1 }), + ]), + ); + expect(fields("templateOrder")).toContainEqual( + expect.objectContaining({ path: "/value", state: "unknown" }), + ); + }); + }, + 120_000, +); + +cliTest( + "shares captured dependency chains while separating values at escape capture points", + async ({ cli }) => { + const depth = 30; + const source = `export function read(){let a0=1; +${Array.from({ length: depth }, (_, index) => `let a${index + 1}=a${index}+a${index};`).join("\n")} +const saved=a${depth}; +${Array.from({ length: depth + 1 }, (_, index) => `a${index}=0;`).join("\n")} +return {value:saved};} +export function captured(external) { +let shared={value:1}; +const before=shared.value; external(shared); const after=shared.value; +shared={value:2}; return {before,after}; +}`; + const oracle = await promisify(execFile)( + process.execPath, + [ + "--input-type=module", + "-e", + `${source} +console.log(JSON.stringify([read(),captured(object=>{object.value=3;})]));`, + ], + { timeout: 10_000 }, + ); + expect(JSON.parse(oracle.stdout)).toEqual([ + { value: 2 ** depth }, + { before: 1, after: 3 }, + ]); + await verifyJavaScriptReturnShapes(cli, source, (fields) => { + expect(fields("read")).toContainEqual( + expect.objectContaining({ + path: "/value", + state: "literal", + value: 2 ** depth, + }), + ); + expect(fields("captured")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: "/before", + state: "literal", + value: 1, + }), + expect.objectContaining({ path: "/after", state: "unknown" }), + ]), + ); + }); + }, + 120_000, +); + +cliTest( + "keeps defaults and do-while tests conditional while preserving definite evaluation", + async ({ cli }) => { + const defaultsSource = ` +export function objectPresent(){let x=0;const {a=(x=1)}={a:2};return {value:x,selected:a};} +export function objectMissing(){let x=0;const {a=(x=1)}={};return {value:x};} +export function arrayPresent(){let x=0;const [a=(x=1)]=[2];return {value:x};} +export function arrayMissing(){let x=0;const [a=(x=1)]=[];return {value:x};} +export function assignmentDefault(){let x=0,a;({a=(x=1)}={a:2});return {value:x};} +export function nestedDefault(){let x=0;const {a:{b=(x=1)}}={a:{b:2}};return {value:x};} +export function computedKey(){let x=0;const {[x=1]:a=9}={1:2};return {value:x};} +export function initializer(){let x=0;const {a}=(x=1,{a:2});return {value:x};} +export function nestedLeftKey(){let x=0;const {a:{[x=1]:b}={}}={a:{1:2}};return {value:x};} +export function doBreak(){let x=0;do{break;}while((x=1,false));return {value:x};} +export function doMaybe(flag){let x=0;do{if(flag)break;}while((x=1,false));return {value:x};} +export function doBody(){do{var x=1;}while(false);return {value:x};} +`; + const oracle = await promisify(execFile)( + process.execPath, + [ + "--input-type=module", + "-e", + `${defaultsSource} +console.log(JSON.stringify([objectPresent(),objectMissing(),arrayPresent(),arrayMissing(),assignmentDefault(),nestedDefault(),computedKey(),initializer(),nestedLeftKey(),doBreak(),doMaybe(true),doMaybe(false),doBody()]));`, + ], + { timeout: 10_000 }, + ); + expect(JSON.parse(oracle.stdout)).toEqual([ + { value: 0, selected: 2 }, + ...[1, 0, 1, 0, 0, 1, 1, 1, 0, 0, 1, 1].map((value) => ({ value })), + ]); + await verifyJavaScriptReturnShapes(cli, defaultsSource, (fields) => { + for (const name of [ + "objectPresent", + "objectMissing", + "arrayPresent", + "arrayMissing", + "assignmentDefault", + "nestedDefault", + "doBreak", + "doMaybe", + ]) + expect(fields(name)).toContainEqual( + expect.objectContaining({ path: "/value", state: "unknown" }), + ); + expect(fields("objectPresent")).toContainEqual( + expect.objectContaining({ + path: "/selected", + state: "literal", + value: 2, + }), + ); + for (const name of [ + "computedKey", + "initializer", + "nestedLeftKey", + "doBody", + ]) + expect(fields(name)).toContainEqual( + expect.objectContaining({ + path: "/value", + state: "literal", + value: 1, + }), + ); + }); + }, + 120_000, +); + +cliTest( + "keeps a captured parameter unknown when comparing export shapes through CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-captured-comparison-"); + const evidence = []; + for (const [side, body] of [ + ["left", 'const saved=x; x=2; return {type:"ok",value:saved};'], + ["right", 'return {type:"ok",value:2};'], + ] as const) { + const directory = join(root, side); + await mkdir(directory); + await writeFile( + join(directory, "main.mjs"), + `export function read(x) { ${body} }`, + ); + const response = await cli.run({ + arguments: ["analyze-javascript-application", directory, "--json"], + }); + expect(response.exitCode, response.stderr).toBe(0); + evidence.push(parseEvidence(response.json)); + } + const [left, right] = evidence; + if (left === undefined || right === undefined) + throw new Error("Missing source Evidence"); + const input = { + left, + right, + left_module_path: "main.mjs", + left_export_name: "read", + right_module_path: "main.mjs", + right_export_name: "read", + }; + const path = join(root, "comparison.json"); + await writeFile(path, JSON.stringify(input)); + const response = await cli.run({ + arguments: ["compare-javascript-export-shapes", path, "--json"], + }); + expect(response.exitCode, response.stderr).toBe(0); + const comparison = parseEvidence(response.json); + expect(comparison.evidence_links).toEqual([ + left.evidence_id, + right.evidence_id, + ]); + expect(comparison.normalized_result).toMatchObject({ + summary: { + added: 0, + removed: 0, + changed: 0, + unknown: 1, + }, + changes: [expect.objectContaining({ path: "/value", status: "unknown" })], + }); + const harness = await createApplicationMcpHarness(); + try { + const reply = await harness.client.callTool({ + name: "compare_javascript_export_shapes", + arguments: input, + }); + expect(reply.isError).not.toBe(true); + expect(parseEvidence(reply.structuredContent)).toEqual(comparison); + const retained = await harness.client.callTool({ + name: "compare_javascript_export_shapes", + arguments: { + ...input, + left: { kind: "retained-evidence", evidence_id: left.evidence_id }, + right: { kind: "retained-evidence", evidence_id: right.evidence_id }, + }, + }); + expect(retained.isError).not.toBe(true); + expect(parseEvidence(retained.structuredContent)).toEqual(comparison); + } finally { + await harness.close(); + } + }, + 120_000, +); diff --git a/tests/fixtures/javascriptReturnShapes.ts b/tests/fixtures/javascriptReturnShapes.ts index 02c66bc84b..e26f0d35d5 100644 --- a/tests/fixtures/javascriptReturnShapes.ts +++ b/tests/fixtures/javascriptReturnShapes.ts @@ -41,9 +41,10 @@ export const verifyJavaScriptReturnShapes = async ( cli: TestCli, source: string, check: (fields: JavaScriptReturnFields) => void, + fileName = "app.js", ): Promise => { const root = await createTestTempDirectory("rea-return-shapes-"); - await writeFile(join(root, "app.js"), source); + await writeFile(join(root, fileName), source); const cliResponse = await cli.run({ arguments: [ "analyze-javascript-application",