diff --git a/src/domain/javascript/javascriptSemanticAnalysis.eval_effects.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.eval_effects.test.ts new file mode 100644 index 0000000000..d5d5a14627 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.eval_effects.test.ts @@ -0,0 +1,51 @@ +import { expect, it } from "vitest"; +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js"; +const returned = (body: string) => + onlyCallable(analyzeJavaScriptSemantics(body), "run").returnSites[0]?.value; + +it.each([ + 'export function run() { const value={n:1}; const alias=value; {const value=0;eval("alias.n=2");} return value.n; }', + 'const value=1; function run(){eval("var value=2");return value;}', + 'export function run(){let alias;let result;{const value={n:1};alias=value;eval("alias.n=2");result=value.n;}return result;}', + 'function run(){function value(){} eval("value=2");return value;}', + 'function run(){class value {} eval("value=2");return value;}', + + 'export function run() { let value=1; eval("value=2"); return value; }', + 'export function run() { const value={n:1}; eval("value.n=2"); return value.n; }', + 'export function run() { const value={n:1}; const alias=value; eval("value.n=2"); return alias.n; }', + 'export function run() { let value=1; function change(){ eval("value=2"); } change(); return value; }', + 'export function run() { let value=1; change(); return value; function change(){ eval("value=2"); } }', +])("does not retain stale exact values after direct eval: %s", (source) => { + expect(returned(source)).toMatchObject({ status: "unknown" }); +}); + +it.each([ + 'eval("var value=2"); function run(){const value=1;return value;}', + 'export function run(){const value={n:1};const saved=value.n;eval("value.n=2");return saved;}', + 'const value=1; function run(){"use strict";eval("var value=2");return value;}', + 'function run(){const value=1;eval("var other=2");return value;}', + 'function other(){const value={n:2};eval("value.n=3");} export function run(){const value={n:1};return value.n;}', + 'export function run() { let value=1; (0,eval)("value=2"); return value; }', + 'export function run() { let value=1; eval?.("value=2"); return value; }', + 'function run() { let value=1; function eval(x) {} eval("value=2"); return value; }', + 'function other() { let value=3; eval("value=4"); } export function run() { let value=1; return value; }', + 'export function run() { let value=1; function other() { let value=3; eval("value=4"); } other(); return value; }', + 'export function run() { let value=1; const saved=value; eval("value=2"); return saved; }', +])( + "preserves unaffected lexical facts and primitive captures: %s", + (source) => { + expect(returned(source)).toEqual({ status: "literal", value: 1 }); + }, +); + +it("invalidates object facts captured in a block when an alias reaches eval outside it", () => { + const ir = analyzeJavaScriptSemantics(`export function run(){ + let alias; + {const value={n:1};alias=value; function read(){return value.n;}} + eval("alias.n=2"); + }`); + expect(onlyCallable(ir, "read").returnSites[0]?.value).toMatchObject({ + status: "unknown", + }); +}); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index f23437394b..bb0d424215 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -1,3 +1,8 @@ +import { + indexSemanticOpaqueEffects, + semanticOpaqueBindingEffects, + semanticBindingCanBeRebound, +} from "./javascriptSemanticOpaqueEffects.js"; import * as t from "@babel/types"; import { @@ -69,7 +74,10 @@ import { resolveSemanticModuleCallables, } from "./javascriptSemanticReturns.js"; import { collectJavaScriptDerivedSemanticsSteps } from "./javascriptSemanticDerivedAnalysis.js"; -import { evaluateSemanticProvenance } from "./javascriptSemanticValues.js"; +import { + evaluateSemanticProvenance, + clearSemanticValueCaches, +} from "./javascriptSemanticValues.js"; import { collectJavaScriptIdentityWritesSteps, electronExportPath, @@ -218,6 +226,15 @@ const visitSemanticReferenceEffectsAtSite = ( }); }; if (binding !== undefined) { + if (semanticBindingCanBeRebound(binding)) + for (const node of semanticOpaqueBindingEffects(binding, state)) + if (!retain("binding-write", node)) return metadata; + for (const node of semanticOpaqueBindingEffects( + binding, + state, + "reference", + )) + if (!retain("escape", node, [])) return metadata; for (const node of state.bindingWrites.get(binding.bindingId) ?? []) if (!retain("binding-write", node)) return metadata; if (indexedMemberMutationStates.has(state)) { @@ -901,6 +918,12 @@ const createState = ( }; return { program, + opaqueEffects: { + sites: new WeakSet(), + sloppySites: new WeakSet(), + byScope: new Map(), + children: new Map(), + }, staticImportResolutionMode, valueResourceLimits: new Set(), getterResourceLimits: new Set(), @@ -939,6 +962,7 @@ function* collectDefinitionsSteps( readonly scope: JavaScriptSemanticScopeState; readonly conditionallySkipped: boolean; }[] = []; + const opaqueCandidates: { node: t.CallExpression; scopeId: string }[] = []; let needsPrototypeFacts = false; const functionBodies: { readonly body: t.BlockStatement; @@ -1016,6 +1040,13 @@ function* collectDefinitionsSteps( state, }); bindInnerDeclaration(node, parent, scope, state); + if ( + t.isCallExpression(node) && + t.isIdentifier(unwrapJavaScriptExpression(node.callee).node, { + name: "eval", + }) + ) + opaqueCandidates.push({ node, scopeId: scope.scopeId }); if ( t.isVariableDeclarator(node) && node.init != null && @@ -1064,6 +1095,8 @@ function* collectDefinitionsSteps( if (t.isIdentifier(identifier)) copyParameterToBody({ identifier, body, scope, state }); } + indexSemanticOpaqueEffects(opaqueCandidates, state); + if (opaqueCandidates.length > 0) clearSemanticValueCaches(state); } type PrototypeOwner = "Object" | "Array" | "String"; diff --git a/src/domain/javascript/javascriptSemanticCalls.ts b/src/domain/javascript/javascriptSemanticCalls.ts index 488e57eae4..65c342fefb 100644 --- a/src/domain/javascript/javascriptSemanticCalls.ts +++ b/src/domain/javascript/javascriptSemanticCalls.ts @@ -375,8 +375,8 @@ const collectDynamicScope = ( ): void => { const reason = t.isWithStatement(node) ? "`with` introduces a runtime binding environment; names inside are not statically resolvable." - : t.isCallExpression(node) && isUnshadowedGlobal(node.callee, state, "eval") - ? "`eval` can declare bindings at runtime; names inside the evaluated source are not statically resolvable." + : state.opaqueEffects.sites.has(node) + ? "`eval` can change bindings and reachable objects at runtime; its effects are not statically resolvable." : null; if (reason === null) return; addFrontier( diff --git a/src/domain/javascript/javascriptSemanticChildProcesses.ts b/src/domain/javascript/javascriptSemanticChildProcesses.ts index 2ffe91fe67..639be8268a 100644 --- a/src/domain/javascript/javascriptSemanticChildProcesses.ts +++ b/src/domain/javascript/javascriptSemanticChildProcesses.ts @@ -4,7 +4,6 @@ import type { JavaScriptSemanticCallable, JavaScriptSemanticChildProcessInteraction, JavaScriptSemanticChildProcessSpawn, - JavaScriptSemanticReference, } from "./javascriptSemanticIr.js"; import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js"; import { createSemanticExpressionShapeResolver } from "./javascriptSemanticExpressionShapes.js"; @@ -68,10 +67,9 @@ export const collectJavaScriptSemanticChildProcesses = ( program: t.Program, state: JavaScriptSemanticAnalysisState, callables: readonly JavaScriptSemanticCallable[], - references: readonly JavaScriptSemanticReference[], ): ChildProcessAnalysis => { const candidates = collectSpawns(program, state, callables); - const context = createSemanticExpressionShapeResolver(state, references); + const context = createSemanticExpressionShapeResolver(state); const childProcessSpawns = candidates.map((candidate) => immutableSpawn(candidate, context), ); diff --git a/src/domain/javascript/javascriptSemanticDerivedAnalysis.ts b/src/domain/javascript/javascriptSemanticDerivedAnalysis.ts index 2a36158dd6..84605b6295 100644 --- a/src/domain/javascript/javascriptSemanticDerivedAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticDerivedAnalysis.ts @@ -66,7 +66,6 @@ export function* collectJavaScriptDerivedSemanticsSteps( program, state, callables, - references, ); yield; const dataEffects = collectJavaScriptSemanticDataEffects( diff --git a/src/domain/javascript/javascriptSemanticExpressionShapes.test.ts b/src/domain/javascript/javascriptSemanticExpressionShapes.test.ts index dbb92cfff9..e4714572c8 100644 --- a/src/domain/javascript/javascriptSemanticExpressionShapes.test.ts +++ b/src/domain/javascript/javascriptSemanticExpressionShapes.test.ts @@ -6,7 +6,6 @@ import { indexJavaScriptBindingsSteps, } from "./javascriptSemanticAnalysis.js"; import { createSemanticExpressionShapeResolver } from "./javascriptSemanticExpressionShapes.js"; -import { collectSemanticReferences } from "./javascriptSemanticProjection.js"; import { parseJavaScriptSource } from "./javascriptSourceParser.js"; import { completeSemanticSteps, @@ -32,6 +31,8 @@ it.each([ ["var a=null;frames.a={};read(a);", ["unknown"]], ["function f(){const a=[];a.push(1);read(a);}", ["array"]], ["function f(){const a=[];const saved=a;read(saved);}", ["array"]], + ['const a=[];function f(){eval("var a={}");read(a)}', ["unknown"]], + ['function f(){const a=[];eval("a={}");read(a)}', ["array"]], ["function f(){let a=[];eval('a={}');read(a)}", ["unknown"]], ["function f(){eval('var undefined={}');read(undefined)}", ["unknown"]], ["var a=[];Function('a={}');read(a);", ["unknown"]], @@ -45,10 +46,7 @@ it.each([ const state = completeSemanticSteps( indexJavaScriptBindingsSteps(file.program), ); - const resolver = createSemanticExpressionShapeResolver( - state, - collectSemanticReferences(state.program, state), - ); + const resolver = createSemanticExpressionShapeResolver(state); const shapes: string[] = []; traverseJavaScriptAst(file.program, { enter(node) { @@ -83,10 +81,7 @@ it("bounds long callback aliases without invoking the shared callable resolver", const state = completeSemanticSteps( indexJavaScriptBindingsSteps(file.program), ); - const resolver = createSemanticExpressionShapeResolver( - state, - collectSemanticReferences(state.program, state), - ); + const resolver = createSemanticExpressionShapeResolver(state); const results: boolean[] = []; traverseJavaScriptAst(file.program, { enter(node) { @@ -123,7 +118,7 @@ it.each([ [ "fixture.cts", "const argv=[];const done=()=>{};eval(input);", - ["dynamic", "dynamic"], + ["inherit", "default"], ], [ "fixture.cts", diff --git a/src/domain/javascript/javascriptSemanticExpressionShapes.ts b/src/domain/javascript/javascriptSemanticExpressionShapes.ts index eb44bb6259..b82ca9f397 100644 --- a/src/domain/javascript/javascriptSemanticExpressionShapes.ts +++ b/src/domain/javascript/javascriptSemanticExpressionShapes.ts @@ -1,3 +1,7 @@ +import { + semanticOpaqueBindingReadMayHaveChanged, + semanticOpaqueNameMayHaveChanged, +} from "./javascriptSemanticOpaqueEffects.js"; import * as t from "@babel/types"; import { @@ -9,7 +13,6 @@ import { isUnmodeledJavaScriptRuntimeScope, } from "./javascriptModuleDeclaration.js"; import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; -import type { JavaScriptSemanticReference } from "./javascriptSemanticIr.js"; import { SEMANTIC_EXPRESSION_DEPTH_LIMIT } from "./javascriptSemanticResourceLimits.js"; import { isUnshadowedGlobal, @@ -32,7 +35,6 @@ const commonJsWrapperParameterNames = new Set([ /** Bounded child-argument identity only; never recover named object contents. */ export const createSemanticExpressionShapeResolver = ( state: JavaScriptSemanticAnalysisState, - references: readonly JavaScriptSemanticReference[], ) => { const hoistedFunction = (node: t.Node): boolean => { const parent = state.parentsByNode.get(node); @@ -49,22 +51,21 @@ export const createSemanticExpressionShapeResolver = ( const blockFunctionNames = new Set(); const namedProofBlocked = (): boolean => { if (blocked !== undefined) return blocked; - blocked = references.some(({ name }) => name === "eval"); - if (!blocked) - traverseJavaScriptAst(state.program, { - enter(node) { - blocked ||= - isErasedJavaScriptDeclaration(node) || - isUnmodeledJavaScriptRuntimeScope(node); - if ( - javaScriptRuntimeContext(state.program) !== "module" && - t.isFunctionDeclaration(node) && - node.id != null && - !hoistedFunction(node) - ) - blockFunctionNames.add(node.id.name); - }, - }); + blocked = false; + traverseJavaScriptAst(state.program, { + enter(node) { + blocked ||= + isErasedJavaScriptDeclaration(node) || + isUnmodeledJavaScriptRuntimeScope(node); + if ( + javaScriptRuntimeContext(state.program) !== "module" && + t.isFunctionDeclaration(node) && + node.id != null && + !hoistedFunction(node) + ) + blockFunctionNames.add(node.id.name); + }, + }); return blocked; }; const shape = (input: t.Node): Shape => { @@ -103,6 +104,7 @@ export const createSemanticExpressionShapeResolver = ( // assignment. Keep same-named vars unknown rather than model that copy. if ( namedProofBlocked() || + semanticOpaqueBindingReadMayHaveChanged(binding, node, state) || (binding.declarationKinds.has("var") && blockFunctionNames.has(binding.name)) ) @@ -128,7 +130,9 @@ export const createSemanticExpressionShapeResolver = ( const { node } = unwrapJavaScriptExpression(input); return ( isNullishJavaScriptSyntax(node) || - (isUnshadowedGlobal(node, state, "undefined") && !namedProofBlocked()) + (isUnshadowedGlobal(node, state, "undefined") && + !namedProofBlocked() && + !semanticOpaqueNameMayHaveChanged(node, state)) ); }, }; diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.ts index 7adc626f8e..c297caa13d 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.ts @@ -896,6 +896,21 @@ export const semanticBindingPrecedesCapture = ( ); }; +/** A preceding initializer and capture in one body precede its later effect. */ +export const semanticBindingEffectFollowsCapture = ( + binding: JavaScriptSemanticBindingState, + capture: t.Node, + effect: t.Node, + parents: WeakMap, +): boolean => { + if (!semanticBindingPrecedesCapture(binding, capture, parents)) return false; + const captured = semanticCapturePosition(capture, parents); + const written = directMutationPosition(effect, parents); + return ( + captured !== null && written !== null && positionPrecedes(captured, written) + ); +}; + /** Ignore later escapes only while reading earlier references in one activation. */ export const semanticEscapeFollowsCapture = ( binding: JavaScriptSemanticBindingState, diff --git a/src/domain/javascript/javascriptSemanticOpaqueEffects.ts b/src/domain/javascript/javascriptSemanticOpaqueEffects.ts new file mode 100644 index 0000000000..04991e1428 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticOpaqueEffects.ts @@ -0,0 +1,206 @@ +import * as t from "@babel/types"; +import { unwrapJavaScriptExpression } from "./javascriptAstValues.js"; +import { + semanticEffectTiming, + semanticBindingEffectFollowsCapture, +} from "./javascriptSemanticMutationInitializers.js"; +import { + isUnshadowedGlobal, + resolveSemanticBindingState, + type JavaScriptSemanticAnalysisState, + type JavaScriptSemanticBindingState, +} from "./javascriptSemanticState.js"; + +/** One source owns sites and the minimal scope tree reaching them; O(AST) storage. */ +export interface JavaScriptOpaqueScopeEffects { + readonly sites: WeakSet; + readonly sloppySites: WeakSet; + readonly byScope: Map; + readonly children: Map>; +} + +export const indexSemanticOpaqueEffects = ( + candidates: readonly { node: t.CallExpression; scopeId: string }[], + state: JavaScriptSemanticAnalysisState, +): void => { + for (const { node, scopeId } of candidates) { + const callee = unwrapJavaScriptExpression(node.callee).node; + if (!isUnshadowedGlobal(callee, state, "eval")) continue; + state.opaqueEffects.sites.add(node); + if (!strictEval(node, state)) state.opaqueEffects.sloppySites.add(node); + const sites = state.opaqueEffects.byScope.get(scopeId) ?? []; + sites.push(node); + state.opaqueEffects.byScope.set(scopeId, sites); + let scope = state.scopesById.get(scopeId); + while (scope?.parentScopeId != null) { + const children = + state.opaqueEffects.children.get(scope.parentScopeId) ?? + new Set(); + if (children.has(scope.scopeId)) break; + children.add(scope.scopeId); + state.opaqueEffects.children.set(scope.parentScopeId, children); + scope = state.scopesById.get(scope.parentScopeId); + } + } +}; + +/** Rebinding requires lexical visibility. Object references can remain reachable + * through aliases even where the original name is shadowed or out of block scope. + * Without a disjointness proof, consider their enclosing activation and closures. + * Queries retain no binding-by-eval table. */ +export function* semanticOpaqueBindingEffects( + binding: JavaScriptSemanticBindingState, + state: JavaScriptSemanticAnalysisState, + kind: "binding" | "reference" = "binding", +): Generator { + if (state.opaqueEffects.byScope.size === 0) return; + const pending = [ + kind === "reference" ? activation(binding.scopeId, state) : binding.scopeId, + ]; + while (pending.length > 0) { + const scope = pending.pop()!; + for (const node of state.opaqueEffects.byScope.get(scope) ?? []) + if ( + kind === "reference" || + resolveSemanticBindingState(state, node, binding.name)?.bindingId === + binding.bindingId + ) + yield node; + for (const child of state.opaqueEffects.children.get(scope) ?? []) + pending.push(child); + } +} + +export const semanticBindingMayHaveOpaqueEffect = ( + binding: JavaScriptSemanticBindingState, + state: JavaScriptSemanticAnalysisState, + read?: t.Node, + kind: "binding" | "reference" = "binding", +): boolean => { + if (state.opaqueEffects.byScope.size === 0) return false; + for (const effect of semanticOpaqueBindingEffects(binding, state, kind)) + if ( + read === undefined || + (semanticEffectTiming(read, effect, state) !== "after" && + !semanticBindingEffectFollowsCapture( + binding, + read, + effect, + state.parentsByNode, + )) + ) + return true; + return false; +}; + +/** Opaque eval may introduce an otherwise unbound name in a visible activation. */ +export const semanticOpaqueNameMayHaveChanged = ( + read: t.Node, + state: JavaScriptSemanticAnalysisState, + binding?: JavaScriptSemanticBindingState, +): boolean => { + if (state.opaqueEffects.byScope.size === 0) return false; + const visible = new Set(); + let node: t.Node | undefined = read; + while (node !== undefined) { + const scope = state.scopeByNode.get(node); + if (scope !== undefined) { + let current: typeof scope | undefined = scope; + while (current !== undefined) { + visible.add(current.scopeId); + current = + current.parentScopeId === null + ? undefined + : state.scopesById.get(current.parentScopeId); + } + break; + } + node = state.parentsByNode.get(node); + } + for (const [scopeId, sites] of state.opaqueEffects.byScope) { + let scope = state.scopesById.get(scopeId); + while ( + scope !== undefined && + scope.kind !== "function" && + scope.kind !== "program" + ) + scope = + scope.parentScopeId === null + ? undefined + : state.scopesById.get(scope.parentScopeId); + if ( + scope !== undefined && + visible.has(scope.scopeId) && + (binding === undefined || + activation(binding.scopeId, state) !== scope.scopeId) && + sites.some( + (effect) => + state.opaqueEffects.sloppySites.has(effect) && + (binding === undefined || + resolveSemanticBindingState(state, effect, binding.name) + ?.bindingId === binding.bindingId) && + semanticEffectTiming(read, effect, state) !== "after", + ) + ) + return true; + } + return false; +}; + +/** Declaration assignability is independent of observed explicit writes. */ +export const semanticBindingCanBeRebound = ( + binding: JavaScriptSemanticBindingState, +): boolean => + binding.mutable || + (binding.definitions.some( + ({ kind }) => kind === "function" || kind === "class", + ) && + binding.initializers.some( + ({ node }) => t.isFunctionDeclaration(node) || t.isClassDeclaration(node), + )); + +export const semanticOpaqueBindingReadMayHaveChanged = ( + binding: JavaScriptSemanticBindingState, + read: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => + (semanticBindingCanBeRebound(binding) && + semanticBindingMayHaveOpaqueEffect(binding, state, read)) || + semanticOpaqueNameMayHaveChanged(read, state, binding); + +const activation = ( + scopeId: string, + state: JavaScriptSemanticAnalysisState, +): string => { + let scope = state.scopesById.get(scopeId); + while ( + scope !== undefined && + scope.kind !== "function" && + scope.kind !== "program" && + scope.parentScopeId !== null + ) + scope = state.scopesById.get(scope.parentScopeId); + return scope?.scopeId ?? scopeId; +}; + +const strictEval = ( + site: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => { + for ( + let node: t.Node | undefined = site; + node !== undefined; + node = state.parentsByNode.get(node) + ) { + if (t.isClass(node) || (t.isProgram(node) && node.sourceType === "module")) + return true; + const directives = t.isProgram(node) + ? node.directives + : t.isFunction(node) && t.isBlockStatement(node.body) + ? node.body.directives + : []; + if (directives.some(({ value }) => value.value === "use strict")) + return true; + } + return false; +}; diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index 328f14e8ac..67b27a863a 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -1,3 +1,4 @@ +import type { JavaScriptOpaqueScopeEffects } from "./javascriptSemanticOpaqueEffects.js"; import * as t from "@babel/types"; import { semanticStaticPropertyKey, @@ -101,6 +102,7 @@ export interface JavaScriptSemanticScopeState { /** Shared state for semantic collection and evaluation. */ export interface JavaScriptSemanticAnalysisState { readonly program: t.Program; + readonly opaqueEffects: JavaScriptOpaqueScopeEffects; /** Effective loader for static imports in this source artifact. */ readonly staticImportResolutionMode: JavaScriptModuleResolutionMode; /** Bounds encountered during this analysis, independent of later projections. */ diff --git a/src/domain/javascript/javascriptSemanticValues.ts b/src/domain/javascript/javascriptSemanticValues.ts index f3720815e1..966c11a13e 100644 --- a/src/domain/javascript/javascriptSemanticValues.ts +++ b/src/domain/javascript/javascriptSemanticValues.ts @@ -1,3 +1,8 @@ +import { + semanticBindingMayHaveOpaqueEffect, + semanticBindingCanBeRebound, + semanticOpaqueBindingReadMayHaveChanged, +} from "./javascriptSemanticOpaqueEffects.js"; import * as t from "@babel/types"; import { @@ -196,14 +201,20 @@ export const evaluateSemanticBinding = ( binding: JavaScriptSemanticBindingState, state: JavaScriptSemanticAnalysisState, ): JavaScriptSemanticValue => - evaluateBinding(binding, { - state, - bindings: new Map(), - expressionDepth: 0, - primitiveBindingValues: primitiveBindingValuesFor(state), - dependence: { lowest: CALLER_INDEPENDENT }, - memo: createEvaluationMemo(state), - }); + semanticBindingCanBeRebound(binding) && + semanticBindingMayHaveOpaqueEffect(binding, state) + ? { + status: "unknown", + reason: "Direct eval may have changed this lexical binding.", + } + : evaluateBinding(binding, { + state, + bindings: new Map(), + expressionDepth: 0, + primitiveBindingValues: primitiveBindingValuesFor(state), + dependence: { lowest: CALLER_INDEPENDENT }, + memo: createEvaluationMemo(state), + }); /** Evaluate an inert expression, optionally at an earlier reference capture. */ export const evaluateSemanticExpression = ( @@ -245,8 +256,8 @@ export const evaluateSemanticExpressionProvenance = ( export const evaluateSemanticProvenance = ( binding: JavaScriptSemanticBindingState, state: JavaScriptSemanticAnalysisState, -): JavaScriptBindingProvenance => - provenanceForBinding(binding, { +): JavaScriptBindingProvenance => { + const provenance = provenanceForBinding(binding, { state, bindings: new Map(), expressionDepth: 0, @@ -254,6 +265,14 @@ export const evaluateSemanticProvenance = ( dependence: { lowest: CALLER_INDEPENDENT }, memo: createEvaluationMemo(state), }); + return semanticBindingCanBeRebound(binding) && + semanticBindingMayHaveOpaqueEffect(binding, state) + ? semanticAmbiguousProvenance( + provenance.origins, + "Direct eval may have changed this runtime value.", + ) + : provenance; +}; /** Follow a namespace binding as it was read at a specific source position. */ export const evaluateSemanticProvenanceAtRead = ( @@ -509,6 +528,19 @@ const evaluateBinding = ( continue; projected = invalidateSemanticEscapedPath(projected, escape.path); } + if ( + (projected.status === "object" || projected.status === "array") && + semanticBindingMayHaveOpaqueEffect( + binding, + context.state, + context.capturePoint, + "reference", + ) + ) + projected = { + status: "unknown", + reason: "Direct eval may have mutated this object reference.", + }; if ( callerIndependent && independent !== undefined && @@ -606,6 +638,17 @@ const evaluateExpressionValue = ( }; } const atRead = read?.binding ?? binding; + if ( + semanticOpaqueBindingReadMayHaveChanged( + binding, + context.capturePoint ?? node, + context.state, + ) + ) + return { + status: "unknown", + reason: "Direct eval may have changed this lexical binding.", + }; return evaluateBinding(atRead, nestedContext(context), atRead === binding); } if (t.isTemplateLiteral(node)) return evaluateTemplate(node, context); @@ -1150,6 +1193,11 @@ const provenanceForBindingAtRead = ( read: t.Identifier, context: EvaluationContext, ): JavaScriptBindingProvenance => { + if (semanticOpaqueBindingReadMayHaveChanged(binding, read, context.state)) + return semanticAmbiguousProvenance( + provenanceForBinding(binding, context).origins, + "Direct eval may have changed this runtime value.", + ); const { binding: snapshot } = semanticBindingAtRead( binding, read, diff --git a/src/domain/javascript/javascriptStaticAnalysis.sqliteStorage.test.ts b/src/domain/javascript/javascriptStaticAnalysis.sqliteStorage.test.ts index 0e45dd6101..7cc7ff78e3 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.sqliteStorage.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.sqliteStorage.test.ts @@ -13,6 +13,7 @@ it.each([ it.each([ 'new Database("shared");', 'import type Database = require("better-sqlite3"); new Database("shared");', + 'let Database = require("better-sqlite3"); eval("Database = Other"); new Database("shared");', 'import * as sqlite from "sqlite3"; const alias = sqlite; alias.Database = Other; new sqlite.Database("shared");', 'import Database from "better-sqlite3"; expose(Database); new Database("shared");', 'import type TypeDatabase from "better-sqlite3"; const Database = TypeDatabase; new Database("shared");', diff --git a/tests/acceptance/applications/javascriptEvalEffectsCli.test.ts b/tests/acceptance/applications/javascriptEvalEffectsCli.test.ts new file mode 100644 index 0000000000..d6de671baa --- /dev/null +++ b/tests/acceptance/applications/javascriptEvalEffectsCli.test.ts @@ -0,0 +1,48 @@ +import { expect } from "vitest"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { verifyJavaScriptReturnShapes } from "../../fixtures/javascriptReturnShapes.js"; + +cliTest( + "retains eval uncertainty through CLI and stdio MCP return projections", + async ({ cli }) => { + await verifyJavaScriptReturnShapes( + cli, + ` + export function direct() { let value=1; eval("value=2"); return {result:value}; } + export function member() { const value={n:1}; eval("value.n=2"); return {result:value.n}; } + export function alias() { const value={n:1}; const copied=value; eval("value.n=2"); return {result:copied.n}; } + export function shadowedAlias() { const value={n:1};const copied=value; {const value=0;eval("copied.n=2");} return {result:value.n}; } + export function nested() { let value=1; change(); return {result:value}; function change(){eval("value=2");} } + export function indirect() { let value=1; (0,eval)("value=2"); return {result:value}; } + export function optional() { let value=1; eval?.("value=2"); return {result:value}; } + export function captured() { let value=1; const saved=value; eval("value=2"); return {result:saved}; } + export function unrelated() { let value=1; return {result:value}; } + `, + (fields) => { + for (const name of [ + "direct", + "member", + "alias", + "nested", + "shadowedAlias", + ]) + expect(fields(name)).toContainEqual( + expect.objectContaining({ + path: "/result", + state: "unknown", + value: null, + }), + ); + for (const name of ["indirect", "optional", "captured", "unrelated"]) + expect(fields(name)).toContainEqual( + expect.objectContaining({ + path: "/result", + state: "literal", + value: 1, + }), + ); + }, + "app.mjs", + ); + }, +);