From 9ac76d3d9486856d25395b413d7ac5ffa744cad0 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:08:37 +0800 Subject: [PATCH] fix(javascript): invalidate stale facts across direct eval Index unshadowed direct-eval sites once at the semantic owner. Distinguish lexical rebinding, non-strict eval-introduced names, and possible mutation of reachable objects. Reuse existing effect timing and capture proofs; retain original definitions and module origins without inventing assignments or parsing or executing eval payloads. Query object uncertainty conservatively across the owning activation so aliases remain covered when names are shadowed or leave block scope. Retain no binding-by-eval table. Use the shared facts for values, read provenance, reference effects and child argument shapes. Preserve indirect/optional/shadowed eval controls, unrelated activations, immutable shapes, strict-mode bindings and prior primitive captures. Remove obsolete reference plumbing and the source-wide eval blocker. Validation: 306 focused checks across 16 files pass, including compiled CLI and stdio MCP return projections, constructor uncertainty and child-process consumers. Against the preceding SQLite commit, 9 of 23 selected checks fail and 14 controls pass. Static/docs/test discovery (1559 files) pass. Two source reviews found no remaining scoped blocker after alias and sloppy-name fixes. No analyzed JavaScript, SQLite engine, heavy test or profiler was executed. Conservative object reachability can intentionally lose exactness; large-case opaque-query CPU and memory remain unmeasured. Fixes #4875 --- ...criptSemanticAnalysis.eval_effects.test.ts | 51 +++++ .../javascript/javascriptSemanticAnalysis.ts | 35 ++- .../javascript/javascriptSemanticCalls.ts | 4 +- .../javascriptSemanticChildProcesses.ts | 4 +- .../javascriptSemanticDerivedAnalysis.ts | 1 - ...javascriptSemanticExpressionShapes.test.ts | 15 +- .../javascriptSemanticExpressionShapes.ts | 42 ++-- .../javascriptSemanticMutationInitializers.ts | 15 ++ .../javascriptSemanticOpaqueEffects.ts | 206 ++++++++++++++++++ .../javascript/javascriptSemanticState.ts | 2 + .../javascript/javascriptSemanticValues.ts | 68 +++++- ...scriptStaticAnalysis.sqliteStorage.test.ts | 1 + .../javascriptEvalEffectsCli.test.ts | 48 ++++ 13 files changed, 446 insertions(+), 46 deletions(-) create mode 100644 src/domain/javascript/javascriptSemanticAnalysis.eval_effects.test.ts create mode 100644 src/domain/javascript/javascriptSemanticOpaqueEffects.ts create mode 100644 tests/acceptance/applications/javascriptEvalEffectsCli.test.ts 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", + ); + }, +);