import * as t from "@babel/types"; import { javaScriptProgramStrictness } from "./javascriptSourceParser.js"; import { unwrapJavaScriptExpression } from "./javascriptAstValues.js"; import { createAmbientPathResolver } from "./javascriptSemanticAmbientWrites.js"; import { createSemanticModuleInterop } from "./javascriptModuleInterop.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 { /** Cheap source evidence collected with declarations, before alias queries. */ mayReferenceGlobalEval: boolean; readonly sites: WeakSet; readonly sloppySites: WeakSet; readonly byScope: Map; readonly children: Map>; readonly globalSites: (t.CallExpression | t.OptionalCallExpression)[]; } 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); } } }; /** The existing ambient invocation walk owns global effects; ordinary sources * neither retain a second call list nor create another alias resolver. */ export const createSemanticOpaqueGlobalObserver = ( state: JavaScriptSemanticAnalysisState, ): ((node: t.Node) => void) | undefined => { if (!state.opaqueEffects.mayReferenceGlobalEval) return undefined; const ambientPaths = createAmbientPathResolver(state, true); let invocationSite: t.Node | undefined; const interop = createSemanticModuleInterop(state, { intrinsicPath: (node) => { const paths = ambientPaths(node, invocationSite).paths; const path = paths.find((path) => path[1] === "eval") ?? paths[0]; if (path?.[0] !== "global" || path.some((key) => key === null)) return undefined; // Keep the global callable owner for generic call/apply/bind resolution. return path[1] === "eval" ? ["intrinsic", ...(path as readonly string[])] : ["intrinsic", ...(path.slice(1) as readonly string[])]; }, }); return (node) => { if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return; invocationSite = node; const invocation = interop.intrinsicInvocation(node); if (invocation?.path.join(".") === "intrinsic.global.eval") state.opaqueEffects.globalSites.push(node); }; }; /** Indirect eval can change globals, but cannot rebind its caller's locals. */ export const semanticOpaqueGlobalMayHaveChanged = ( read: t.Node, state: JavaScriptSemanticAnalysisState, ): boolean => state.opaqueEffects.globalSites.some( (effect) => semanticEffectTiming(read, effect, state) !== "after", ); /** 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) && javaScriptProgramStrictness(node) === "strict") ) return true; const directives = t.isFunction(node) && t.isBlockStatement(node.body) ? node.body.directives : []; if (directives.some(({ value }) => value.value === "use strict")) return true; } return false; };