Module refinery.lib.scripts.js.deobfuscation.interpreter

Mini-interpreter for executing pure JavaScript functions with concrete arguments.

Expand source code Browse git
"""
Mini-interpreter for executing pure JavaScript functions with concrete arguments.
"""
from __future__ import annotations

import base64
import json
import math
import re
import sys
import urllib.parse

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from typing import Callable, Mapping

    from refinery.lib.scripts.js.analysis.effects import EffectModel
    from refinery.lib.scripts.js.analysis.tampering import TamperingModel
    from refinery.lib.scripts.js.deobfuscation.helpers import Value

from refinery.lib.scripts import Node
from refinery.lib.scripts.js.analysis.effects import object_sets_prototype
from refinery.lib.scripts.js.analysis.environment import HostEnvironment, typeof_of_global
from refinery.lib.scripts.js.analysis.model import (
    SemanticModel,
    call_supplies_an_arguments_object,
    statement_list_holding,
)
from refinery.lib.scripts.js.deobfuscation.helpers import (
    GLOBAL_VALUE_NAMES,
    JS_HOLE,
    JS_NULL,
    LOGICAL_ASSIGNMENT_OPS,
    PROTO_KEY,
    RELATIONAL_OPS,
    SEQUENCE_DATA_PROPERTIES,
    UNARY_OPS,
    JsBuffer,
    MemberRead,
    _array_element_string,
    _js_pow,
    _to_int,
    _to_int32,
    _to_uint32,
    canonical_array_index,
    code_points,
    coerces_uninterceptably,
    coerces_uninterceptably_from_written_chain,
    eval_binary_op,
    js_typeof,
    name_is_unbound,
    names_global_value,
    own_property_keys,
    property_absent_from_written_chain,
    property_is_inherited_from_an_intact_chain,
    property_is_inherited_from_an_unwritten_chain,
    property_provably_absent,
    read_data_property,
    spell_astral_characters,
    to_boolean,
    to_number,
    to_string,
    utf16_code_units,
    walk_scope,
)
from refinery.lib.scripts.js.model import (
    JsArrayExpression,
    JsArrowFunctionExpression,
    JsAssignmentExpression,
    JsBinaryExpression,
    JsBlockStatement,
    JsBooleanLiteral,
    JsBreakStatement,
    JsCallExpression,
    JsConditionalExpression,
    JsContinueStatement,
    JsDoWhileStatement,
    JsEmptyStatement,
    JsExpressionStatement,
    JsForInStatement,
    JsForOfStatement,
    JsForStatement,
    JsFunctionDeclaration,
    JsFunctionExpression,
    JsFunctionNode,
    JsIdentifier,
    JsIfStatement,
    JsLogicalExpression,
    JsMemberExpression,
    JsNullLiteral,
    JsNumericLiteral,
    JsObjectExpression,
    JsParenthesizedExpression,
    JsProperty,
    JsPropertyKind,
    JsReturnStatement,
    JsSequenceExpression,
    JsStringLiteral,
    JsSwitchCase,
    JsSwitchStatement,
    JsTemplateLiteral,
    JsThrowStatement,
    JsTryStatement,
    JsUnaryExpression,
    JsUpdateExpression,
    JsVariableDeclaration,
    JsVariableDeclarator,
    JsVarKind,
    JsWhileStatement,
    wraps_return,
)
from refinery.lib.scripts.js.numbers import (
    TRIMMABLE_WHITESPACE,
    js_parse_float,
    js_parse_int,
)
from refinery.lib.scripts.js.token import ASCII_WHITESPACE
from refinery.lib.scripts.js.utf16 import to_code_units

MAX_ITERATIONS = 100_000
MAX_STRING_LEN = 1_000_000
MAX_ARRAY_LEN = 1_000_000
_MAX_RECURSION = 10


class InterpreterError(Exception):
    pass


class IrreducibleExpression(Exception):
    def __init__(self, node: Node):
        self.node = node


class _ReturnSignal(Exception):
    def __init__(self, value: Value):
        self.value = value


class _BreakSignal(Exception):
    pass


class JsArguments:
    """
    The `arguments` object of the call being interpreted: the argument values that call was made
    with, held read-only. Deliberately not a `list` subclass — nothing that dispatches on a list
    may answer for it, no builtin registry entry exists for the type, and the object this models
    exposes only `length` and the indexed elements. It cannot escape into a folded value: a body
    whose `arguments` reads are not all element-wise never passes the preflight that admits one,
    so no bare `arguments` ever reaches the value domain.
    """
    __slots__ = ('values',)

    def __init__(self, values: list[Value]):
        self.values = values


class _ContinueSignal(Exception):
    pass


class _ThrowSignal(Exception):
    def __init__(self, value: Value):
        self.value = value


def _js_throw(name: str, message: str = '') -> None:
    """
    Signal a genuine JavaScript runtime exception (e.g. a `TypeError` or `RangeError`) that an
    emulated `try/catch` must be able to catch. The thrown value is a plain object carrying `name`
    and `message`, so `typeof e` is `'object'` and `e.name` / `e.message` are usable. This is
    distinct from `InterpreterError`, which means "abort interpretation" and is never caught.
    """
    raise _ThrowSignal({'name': name, 'message': message})


class _ReturnIrreducible(Exception):
    """
    Raised when a function's return value (or an arrow's tail expression) is an irreducible
    expression. This is distinct from a bare `IrreducibleExpression`, which may surface from a
    non-return position (a variable initializer, an expression statement, a loop) and therefore does
    NOT represent the function's value. Only a `_ReturnIrreducible` is converted back into an
    `IrreducibleExpression` for the evaluator to substitute at the call site.
    """
    def __init__(self, node: Node):
        self.node = node


def _deep_copy_value(value):
    if isinstance(value, list):
        return type(value)(_deep_copy_value(item) for item in value)
    if isinstance(value, dict):
        return {k: _deep_copy_value(v) for k, v in value.items()}
    return value


def _to_index(value: Value) -> int:
    n = to_number(value)
    if n != n:
        return 0
    if n == float('inf'):
        return sys.maxsize
    if n == float('-inf'):
        return -sys.maxsize
    return int(n)


def _to_array_length(value: Value) -> int:
    """
    Coerce a value to a valid array length. Per ECMA-262 ArraySetLength, `ToUint32(v)` must equal
    `ToNumber(v)`; otherwise the length is invalid and a JavaScript `RangeError` is signalled. This
    rejects NaN, +/-Infinity, negative, and non-integer lengths, each of which a real engine
    reports as `Invalid array length`.
    """
    number_len = to_number(value)
    length = _to_uint32(number_len)
    if length != number_len:
        _js_throw('RangeError', 'Invalid array length')
    return length


#: The value-domain types that model a JavaScript object: an array, a plain object, and a function.
#: They share the two behaviours the primitives do not — a ToPrimitive that runs a (here
#: interceptable) conversion, and an equality by reference rather than by value. `_to_primitive` and
#: `_loose_equal` name the set once here rather than each enumerating it and drifting apart.
_OBJECT_VALUE_TYPES = (
    list, dict, JsFunctionDeclaration, JsFunctionExpression, JsArrowFunctionExpression,
)


def _to_primitive(value: Value) -> Value:
    """
    Replicate the ECMA-262 ToPrimitive abstract operation with the default hint, as used by `+`.
    Arrays, plain objects and functions have no useful `valueOf`, so they coerce to their string
    form; all other values are already primitive.
    """
    if isinstance(value, _OBJECT_VALUE_TYPES):
        return to_string(value)
    return value


def js_strict_equal(a: Value, b: Value) -> bool:
    """
    Compare two interpreter values using JavaScript strict-equality (`===`) semantics. Unlike
    Python equality this does not conflate booleans with the numbers `1` and `0`.
    """
    if isinstance(a, bool) or isinstance(b, bool):
        return a is b
    if a is None or b is None:
        return a is None and b is None
    if isinstance(a, (int, float)) and isinstance(b, (int, float)):
        return a == b
    if type(a) is not type(b):
        return False
    if isinstance(a, str):
        return a == b
    return a is b


BUILTIN_REGISTRY: dict[tuple, Callable] = {}


def _in_code_units(fn: Callable) -> Callable:
    """
    Wrap a builtin so a bare string it produces is held as UTF-16 code units before it enters the
    value domain, so a decode that introduces an astral code point yields the surrogate pair a
    literal already is. Every reader of the registry — the interpreter and the simplifier — gets this
    without repeating the rule, and a builtin added later is covered by being registered. It reaches
    only a string the builtin returns directly: a string nested in a returned list or dict is the
    producer's own to normalize (as `_json_to_value` does its keys and values). It is idempotent,
    because `to_code_units` leaves a string already in code units untouched, and a no-op on any
    non-string result.
    """
    def in_code_units(*args):
        result = fn(*args)
        if isinstance(result, str):
            return to_code_units(result)
        return result
    return in_code_units


def _register(key: tuple):
    def _decorator(fn: Callable):
        BUILTIN_REGISTRY[key] = _in_code_units(fn)
        return fn
    return _decorator


@_register((str, 'charAt'))
def _str_char_at(s: str, args: list[Value]) -> Value:
    idx = _to_index(args[0]) if args else 0
    if 0 <= idx < len(s):
        return s[idx]
    return ''


@_register((str, 'charCodeAt'))
def _str_char_code_at(s: str, args: list[Value]) -> Value:
    idx = _to_index(args[0]) if args else 0
    if 0 <= idx < len(s):
        return ord(s[idx])
    return float('nan')


@_register((str, 'indexOf'))
def _str_index_of(s: str, args: list[Value]) -> Value:
    if not args:
        return -1
    search = to_string(args[0])
    start = _to_index(args[1]) if len(args) > 1 else 0
    return s.find(search, max(0, start))


@_register((str, 'lastIndexOf'))
def _str_last_index_of(s: str, args: list[Value]) -> Value:
    if not args:
        return -1
    search = to_string(args[0])
    n = len(s)
    if len(args) > 1:
        pos = to_number(args[1])
        start = n if pos != pos else max(0, min(_to_index(args[1]), n))
    else:
        start = n
    return s.rfind(search, 0, start + len(search))


@_register((str, 'includes'))
def _str_includes(s: str, args: list[Value]) -> Value:
    if not args:
        return False
    search = to_string(args[0])
    start = _to_index(args[1]) if len(args) > 1 else 0
    return s.find(search, max(0, start)) != -1


@_register((str, 'startsWith'))
def _str_starts_with(s: str, args: list[Value]) -> Value:
    if not args:
        return False
    prefix = to_string(args[0])
    start = max(0, _to_index(args[1])) if len(args) > 1 else 0
    return s[start:].startswith(prefix)


@_register((str, 'endsWith'))
def _str_ends_with(s: str, args: list[Value]) -> Value:
    if not args:
        return False
    suffix = to_string(args[0])
    end = max(0, min(_to_index(args[1]), len(s))) if len(args) > 1 else len(s)
    return s[:end].endswith(suffix)


@_register((str, 'slice'))
def _str_slice(s: str, args: list[Value]) -> Value:
    n = len(s)
    start = _to_index(args[0]) if args else 0
    end = _to_index(args[1]) if len(args) > 1 else n
    if start < 0:
        start = max(n + start, 0)
    if end < 0:
        end = max(n + end, 0)
    return s[start:end]


@_register((str, 'substring'))
def _str_substring(s: str, args: list[Value]) -> Value:
    n = len(s)
    start = _to_index(args[0]) if args else 0
    end = _to_index(args[1]) if len(args) > 1 else n
    start = max(0, min(start, n))
    end = max(0, min(end, n))
    if start > end:
        start, end = end, start
    return s[start:end]


@_register((str, 'substr'))
def _str_substr(s: str, args: list[Value]) -> Value:
    n = len(s)
    start = _to_index(args[0]) if args else 0
    length = _to_index(args[1]) if len(args) > 1 else n
    if start < 0:
        start = max(n + start, 0)
    return s[start:start + max(0, length)]


@_register((str, 'split'))
def _str_split(s: str, args: list[Value]) -> Value:
    if not args or args[0] is None:
        if len(args) > 1 and args[1] is not None:
            if _to_index(args[1]) == 0:
                return []
        return [s]
    sep = to_string(args[0])
    if not sep:
        result = utf16_code_units(s)
    else:
        result = s.split(sep)
    if len(args) > 1 and args[1] is not None:
        limit = _to_uint32(to_number(args[1]))
        result = result[:limit]
    return result


def _expand_replacement(replacement: str, s: str, start: int, matched: str) -> str:
    """
    Expand the JavaScript replacement-string patterns ($$, $&, $`, $') for a literal-string match
    of `matched` at index `start` in `s`. Capture-group patterns ($1..) have no meaning for a
    string search and are emitted verbatim, as JavaScript does.
    """
    out: list[str] = []
    i = 0
    n = len(replacement)
    while i < n:
        c = replacement[i]
        if c == '$' and i + 1 < n:
            nxt = replacement[i + 1]
            if nxt == '$':
                out.append('$')
            elif nxt == '&':
                out.append(matched)
            elif nxt == '`':
                out.append(s[:start])
            elif nxt == "'":
                out.append(s[start + len(matched):])
            else:
                out.append('$')
                out.append(nxt)
            i += 2
            continue
        out.append(c)
        i += 1
    return ''.join(out)


@_register((str, 'replace'))
def _str_replace(s: str, args: list[Value]) -> Value:
    if len(args) < 2:
        return s
    search = to_string(args[0])
    replacement = to_string(args[1])
    index = s.find(search)
    if index < 0:
        return s
    expanded = _expand_replacement(replacement, s, index, search)
    return s[:index] + expanded + s[index + len(search):]


@_register((str, 'replaceAll'))
def _str_replace_all(s: str, args: list[Value]) -> Value:
    if len(args) < 2:
        return s
    search = to_string(args[0])
    replacement = to_string(args[1])
    if not search:
        raise InterpreterError
    out: list[str] = []
    pos = 0
    while True:
        index = s.find(search, pos)
        if index < 0:
            out.append(s[pos:])
            break
        out.append(s[pos:index])
        out.append(_expand_replacement(replacement, s, index, search))
        pos = index + len(search)
    return ''.join(out)


@_register((str, 'toLowerCase'))
def _str_to_lower(s: str, args: list[Value]) -> Value:
    return spell_astral_characters(s).lower()


@_register((str, 'toUpperCase'))
def _str_to_upper(s: str, args: list[Value]) -> Value:
    return spell_astral_characters(s).upper()


@_register((str, 'trim'))
def _str_trim(s: str, args: list[Value]) -> Value:
    return s.strip(TRIMMABLE_WHITESPACE)


@_register((str, 'trimStart'))
def _str_trim_start(s: str, args: list[Value]) -> Value:
    return s.lstrip(TRIMMABLE_WHITESPACE)


@_register((str, 'trimEnd'))
def _str_trim_end(s: str, args: list[Value]) -> Value:
    return s.rstrip(TRIMMABLE_WHITESPACE)


@_register((str, 'repeat'))
def _str_repeat(s: str, args: list[Value]) -> Value:
    count = _to_index(args[0]) if args else 0
    if count < 0 or count > 0x10000000:
        _js_throw('RangeError', 'Invalid count value')
    return s * count


def _str_pad(s: str, args: list[Value], prepend: bool) -> Value:
    target_len = _to_index(args[0]) if args else 0
    if target_len > 0x10000000:
        _js_throw('RangeError', 'Invalid string length')
    fill = to_string(args[1]) if len(args) > 1 else ' '
    needed = target_len - len(s)
    if needed <= 0 or not fill:
        return s
    pad = (fill * (needed // len(fill) + 1))[:needed]
    return pad + s if prepend else s + pad


@_register((str, 'padStart'))
def _str_pad_start(s: str, args: list[Value]) -> Value:
    return _str_pad(s, args, prepend=True)


@_register((str, 'padEnd'))
def _str_pad_end(s: str, args: list[Value]) -> Value:
    return _str_pad(s, args, prepend=False)


@_register((str, 'at'))
def _str_at(s: str, args: list[Value]) -> Value:
    idx = _to_index(args[0]) if args else 0
    if idx < 0:
        idx += len(s)
    if 0 <= idx < len(s):
        return s[idx]
    return None


@_register(('String', 'fromCharCode'))
def _string_from_char_code(args: list[Value]) -> Value:
    return ''.join(chr(_to_int(a) & 0xFFFF) for a in args)


def _json_to_value(value):
    """
    The interpreter's value for a node `json.loads` decoded: a JSON `null` (Python `None`) becomes
    the `JS_NULL` sentinel rather than `undefined`, and every string — an object key as much as a
    value — becomes the UTF-16 code units the value domain counts, so an astral character
    `json.loads` read as one code point is the surrogate pair a literal already is. It recurses, so
    a string nested in an object or array is reached too.
    """
    if value is None:
        return JS_NULL
    if isinstance(value, str):
        return to_code_units(value)
    if isinstance(value, list):
        return [_json_to_value(v) for v in value]
    if isinstance(value, dict):
        return {to_code_units(k): _json_to_value(v) for k, v in value.items()}
    return value


@_register(('JSON', 'parse'))
def _json_parse(args: list[Value]) -> Value:
    if not args:
        raise InterpreterError

    def _reject_constant(_: str) -> Value:
        raise InterpreterError
    s = to_string(args[0])
    try:
        parsed = json.loads(s, parse_int=float, parse_constant=_reject_constant)
    except Exception:
        raise InterpreterError
    return _json_to_value(parsed)


@_register((list, 'push'))
def _arr_push(arr: list, args: list[Value]) -> Value:
    arr.extend(args)
    return len(arr)


@_register((list, 'pop'))
def _arr_pop(arr: list, args: list[Value]) -> Value:
    if arr:
        return _element_read(arr.pop())
    return None


@_register((list, 'shift'))
def _arr_shift(arr: list, args: list[Value]) -> Value:
    if arr:
        return _element_read(arr.pop(0))
    return None


@_register((list, 'unshift'))
def _arr_unshift(arr: list, args: list[Value]) -> Value:
    for i, a in enumerate(args):
        arr.insert(i, a)
    return len(arr)


@_register((list, 'reverse'))
def _arr_reverse(arr: list, args: list[Value]) -> Value:
    arr.reverse()
    return arr


@_register((list, 'concat'))
def _arr_concat(arr: list, args: list[Value]) -> Value:
    result = list(arr)
    for a in args:
        if isinstance(a, list):
            result.extend(a)
        else:
            result.append(a)
    return result


@_register((list, 'slice'))
def _arr_slice(arr: list, args: list[Value]) -> Value:
    n = len(arr)
    start = _to_index(args[0]) if args else 0
    end = _to_index(args[1]) if len(args) > 1 else n
    if start < 0:
        start = max(n + start, 0)
    if end < 0:
        end = max(n + end, 0)
    return arr[start:end]


@_register((list, 'splice'))
def _arr_splice(arr: list, args: list[Value]) -> Value:
    if not args:
        return []
    start = _to_index(args[0])
    n = len(arr)
    if start < 0:
        start = max(n + start, 0)
    else:
        start = min(start, n)
    delete_count = _to_index(args[1]) if len(args) > 1 else n - start
    delete_count = max(0, min(delete_count, n - start))
    removed = arr[start:start + delete_count]
    new_items = list(args[2:])
    arr[start:start + delete_count] = new_items
    return removed


@_register((list, 'join'))
def _arr_join(arr: list, args: list[Value]) -> Value:
    sep = ',' if not args or args[0] is None else to_string(args[0])
    return sep.join(_array_element_string(v) for v in arr)


@_register((list, 'toString'))
def _arr_to_string(arr: list, args: list[Value]) -> Value:
    return to_string(arr)


@_register((int, 'toString'))
@_register((float, 'toString'))
def _number_to_string(num: int | float, args: list[Value]) -> Value:
    radix = _to_int(args[0]) if args and args[0] is not None else 10
    if radix == 10:
        return to_string(num)
    if not 2 <= radix <= 36:
        _js_throw('RangeError', 'toString() radix must be between 2 and 36')
    value = to_number(num)
    if value != value or math.isinf(value):
        return to_string(value)
    if value != int(value):
        raise InterpreterError
    integer = abs(int(value))
    if integer == 0:
        return '0'
    digits = '0123456789abcdefghijklmnopqrstuvwxyz'
    out: list[str] = []
    while integer:
        out.append(digits[integer % radix])
        integer //= radix
    text = ''.join(reversed(out))
    return '-' + text if value < 0 else text


@_register((list, 'indexOf'))
def _arr_index_of(arr: list, args: list[Value]) -> Value:
    if not args:
        return -1
    target = args[0]
    start = _to_index(args[1]) if len(args) > 1 else 0
    if start < 0:
        start = max(0, len(arr) + start)
    for i in range(start, len(arr)):
        if js_strict_equal(arr[i], target):
            return i
    return -1


def _element_read(value: Value) -> Value:
    """
    The value an element-reading array method answers for *value*: a hole reads as `undefined`,
    the same value the array's own iterator yields for it, and every other element is itself.
    """
    return None if value is JS_HOLE else value


def _same_value_zero(a: Value, b: Value) -> bool:
    """
    Compare two interpreter values with SameValueZero, the equality `includes` uses: strict
    equality, except that two NaNs are equal — and a hole reads as the `undefined` the
    iteration that visits it would yield.
    """
    a = _element_read(a)
    if isinstance(a, (int, float)) and isinstance(b, (int, float)):
        if a != a and b != b:
            return True
    return js_strict_equal(a, b)


@_register((list, 'includes'))
def _arr_includes(arr: list, args: list[Value]) -> Value:
    if not args:
        return False
    start = _to_index(args[1]) if len(args) > 1 else 0
    if start < 0:
        start = max(0, len(arr) + start)
    return any(_same_value_zero(item, args[0]) for item in arr[start:])


@_register((list, 'flat'))
def _arr_flat(arr: list, args: list[Value]) -> Value:
    depth = _to_index(args[0]) if args else 1

    def _flatten(lst: list, d: int) -> list:
        result: list = []
        for item in lst:
            # Flattening asks whether the array has a property at each index, at every level,
            # so a hole is skipped there rather than read or carried into the result.
            if item is JS_HOLE:
                continue
            if isinstance(item, list) and d > 0:
                result.extend(_flatten(item, d - 1))
            else:
                result.append(item)
        return result
    return _flatten(arr, depth)


@_register((list, 'at'))
def _arr_at(arr: list, args: list[Value]) -> Value:
    idx = _to_index(args[0]) if args else 0
    if idx < 0:
        idx += len(arr)
    if 0 <= idx < len(arr):
        return _element_read(arr[idx])
    return None


@_register((list, 'fill'))
def _arr_fill(arr: list, args: list[Value]) -> Value:
    if not args:
        return arr
    value = args[0]
    n = len(arr)
    start = _to_index(args[1]) if len(args) > 1 else 0
    end = _to_index(args[2]) if len(args) > 2 else n
    if start < 0:
        start = max(n + start, 0)
    if end < 0:
        end = max(n + end, 0)
    for i in range(start, min(end, n)):
        arr[i] = value
    return arr


_ARRAY_HOF_METHODS = frozenset({
    'every', 'some', 'map', 'filter', 'reduce', 'forEach', 'find', 'findIndex',
})

_BUFFER_PRESERVING_HOFS = frozenset({'map', 'filter'})


def _to_js_integer(args: list[Value], round_to_integer) -> Value:
    """
    Shared implementation for the integer-valued `Math` roundings (floor/ceil/round/trunc). Passes NaN
    and the infinities through unchanged and preserves the sign of a negative-zero result, which JS
    requires (e.g. `Math.round(-0)` is `-0`, observable as `1 / Math.round(-0) === -Infinity`).
    """
    v = to_number(args[0]) if args else float('nan')
    if v != v:
        return float('nan')
    if math.isinf(v):
        return v
    result = int(round_to_integer(v))
    if result == 0 and math.copysign(1.0, v) < 0:
        return -0.0
    return result


@_register(('Math', 'floor'))
def _math_floor(args: list[Value]) -> Value:
    return _to_js_integer(args, math.floor)


@_register(('Math', 'ceil'))
def _math_ceil(args: list[Value]) -> Value:
    return _to_js_integer(args, math.ceil)


def _round_half_up(v: float) -> float:
    """
    Round *v* to the nearest integer, ties toward positive infinity, matching JS `Math.round`. The
    result is taken from the fractional distance to the floor rather than `floor(v + 0.5)`, whose
    addition rounds the largest double below `0.5` up to `1.0` and would yield `1` instead of `0`.
    """
    lower = math.floor(v)
    return lower if v - lower < 0.5 else lower + 1


@_register(('Math', 'round'))
def _math_round(args: list[Value]) -> Value:
    return _to_js_integer(args, _round_half_up)


@_register(('Math', 'abs'))
def _math_abs(args: list[Value]) -> Value:
    return abs(to_number(args[0])) if args else float('nan')


@_register(('Math', 'pow'))
def _math_pow(args: list[Value]) -> Value:
    if len(args) < 2:
        return float('nan')
    return _js_pow(to_number(args[0]), to_number(args[1]))


@_register(('Math', 'sqrt'))
def _math_sqrt(args: list[Value]) -> Value:
    v = to_number(args[0]) if args else float('nan')
    if v < 0:
        return float('nan')
    return math.sqrt(v)


@_register(('Math', 'min'))
def _math_min(args: list[Value]) -> Value:
    if not args:
        return float('inf')
    values = [to_number(a) for a in args]
    if any(v != v for v in values):
        return float('nan')
    result = min(values)
    if result == 0 and any(math.copysign(1.0, v) < 0 for v in values):
        return -0.0
    return result


@_register(('Math', 'max'))
def _math_max(args: list[Value]) -> Value:
    if not args:
        return float('-inf')
    values = [to_number(a) for a in args]
    if any(v != v for v in values):
        return float('nan')
    result = max(values)
    if result == 0 and any(math.copysign(1.0, v) > 0 for v in values):
        return 0.0
    return result


@_register(('Math', 'trunc'))
def _math_trunc(args: list[Value]) -> Value:
    return _to_js_integer(args, math.trunc)


@_register(('Math', 'sign'))
def _math_sign(args: list[Value]) -> Value:
    v = to_number(args[0]) if args else float('nan')
    if v != v:
        return v
    if v > 0:
        return 1
    if v < 0:
        return -1
    return v


def _math_log_impl(args: list[Value], fn) -> Value:
    v = to_number(args[0]) if args else float('nan')
    if v <= 0:
        return float('-inf') if v == 0 else float('nan')
    return fn(v)


@_register(('Math', 'log'))
def _math_log(args: list[Value]) -> Value:
    return _math_log_impl(args, math.log)


@_register(('Math', 'log2'))
def _math_log2(args: list[Value]) -> Value:
    return _math_log_impl(args, math.log2)


@_register((None, 'parseInt'))
@_register(('Number', 'parseInt'))
def _global_parse_int(args: list[Value]) -> Value:
    """
    `Number.parseInt` is registered to the same function rather than to a copy of it, because the
    specification says the two property values *are* the same function object; an obfuscator
    reaching the global through its `Number` spelling is reaching this.

    The radix is read with ToInt32 rather than by truncation, because that is the coercion the
    specification names and the two disagree outside the int32 range: `parseInt('10', 2 ** 32 + 16)`
    is `16`, the radix the wrap lands on, and not the `NaN` an out-of-range value would answer.
    """
    if not args:
        return float('nan')
    s = to_string(args[0])
    radix = _to_int32(to_number(args[1])) if len(args) > 1 else 0
    result = js_parse_int(s, radix)
    if result is None:
        return float('nan')
    return result


@_register((None, 'parseFloat'))
@_register(('Number', 'parseFloat'))
def _global_parse_float(args: list[Value]) -> Value:
    if not args:
        return float('nan')
    return js_parse_float(to_string(args[0]))


@_register((None, 'isNaN'))
def _global_is_nan(args: list[Value]) -> Value:
    v = to_number(args[0]) if args else float('nan')
    return v != v


@_register((None, 'isFinite'))
def _global_is_finite(args: list[Value]) -> Value:
    v = to_number(args[0]) if args else float('nan')
    return math.isfinite(v)


@_register((None, 'Number'))
def _global_number(args: list[Value]) -> Value:
    if not args:
        return 0
    return to_number(args[0])


@_register((None, 'String'))
def _global_string(args: list[Value]) -> Value:
    if not args:
        return ''
    return to_string(args[0])


@_register((None, 'atob'))
def _global_atob(args: list[Value]) -> Value:
    """
    Apply the WHATWG forgiving-base64 decode, which is what `atob` answers with. Padding is read
    rather than supplied: the decode takes off one or two `=` only from an argument whose length is
    already a multiple of four, and every `=` that survives that is a character outside the alphabet
    and refuses the whole argument. Completing the group instead is how `atob('QQ=')` answered `'A'`
    where the engine throws, so a call the program never returns from folded to a value.
    """
    if not args:
        raise InterpreterError
    s = to_string(args[0])
    try:
        cleaned = _RE_ASCII_WHITESPACE.sub('', s)
        if len(cleaned) % 4 == 0:
            cleaned = cleaned[:-2] if cleaned.endswith('==') else cleaned.removesuffix('=')
        if len(cleaned) % 4 == 1 or '=' in cleaned:
            raise InterpreterError
        padded = cleaned + '=' * (-len(cleaned) % 4)
        return base64.b64decode(padded, validate=True).decode('latin-1')
    except Exception:
        raise InterpreterError


@_register((None, 'btoa'))
def _global_btoa(args: list[Value]) -> Value:
    if not args:
        raise InterpreterError
    s = to_string(args[0])
    try:
        return base64.b64encode(s.encode('latin-1')).decode('ascii')
    except Exception:
        raise InterpreterError


_UNESCAPE_PATTERN = re.compile(r'%u([0-9A-Fa-f]{4})|%([0-9A-Fa-f]{2})')
_RE_ASCII_WHITESPACE = re.compile(F"[{re.escape(ASCII_WHITESPACE)}]")
"""
The characters forgiving-base64 decoding removes before it reads an `atob` argument. The set is
`refinery.lib.scripts.js.token.ASCII_WHITESPACE` and is narrower than Python's `\\s`, which also
takes the vertical tab and `U+001C` through `U+001F`, and narrower than
`refinery.lib.scripts.js.numbers.TRIMMABLE_WHITESPACE`, which takes the space separators and the
byte order mark. Every character outside it is a character `atob` throws on, so reading it as
padding deletes a throw.
"""
_RE_NON_BASE64 = re.compile(r'[^A-Za-z0-9+/=]')
_RE_URI_ESCAPE = re.compile('%([0-9A-Fa-f]{2})')
"""
One escape of a URI: a percent sign and the two hexadecimal digits spelling one octet. A decode
throws a `URIError` for a percent sign anything else follows, so a sign this does not match is not
a character it passes through: `decodeURIComponent('100%')` throws where `urllib.parse.unquote`
answers `'100%'`.
"""


@_register((None, 'unescape'))
def _global_unescape(args: list[Value]) -> Value:
    if not args:
        return 'undefined'
    s = to_string(args[0])
    return _UNESCAPE_PATTERN.sub(lambda m: chr(int(m.group(1) or m.group(2), 16)), s)


def _uri_escape_octet(s: str, index: int) -> int:
    match = _RE_URI_ESCAPE.match(s, index)
    if match is None:
        raise InterpreterError
    return int(match.group(1), 16)


def _utf8_sequence_length(lead: int) -> int:
    if 0xC0 <= lead < 0xE0:
        return 2
    if 0xE0 <= lead < 0xF0:
        return 3
    if 0xF0 <= lead < 0xF8:
        return 4
    raise InterpreterError


@_register((None, 'decodeURIComponent'))
def _global_decode_uri_component(args: list[Value]) -> Value:
    """
    Apply the `Decode` operation of the specification over the code units of the argument, with no
    escape preserved. A character no escape introduced is copied straight into the answer, a
    surrogate the argument itself spells included; an escaped octet outside ASCII must open a run
    of escaped octets that is strict UTF-8 — an overlong form, an encoded surrogate, or a code
    point past the last one all throw — and the code point that run encodes comes back as the code
    units that spell it, so a decoded astral character is the pair a literal holding it would be.
    """
    if not args:
        raise InterpreterError
    s = to_string(args[0])
    result: list[str] = []
    index = 0
    while index < len(s):
        unit = s[index]
        if unit != '%':
            result.append(unit)
            index += 1
            continue
        lead = _uri_escape_octet(s, index)
        index += 3
        if lead < 0x80:
            result.append(chr(lead))
            continue
        octets = bytearray([lead])
        for _ in range(_utf8_sequence_length(lead) - 1):
            octets.append(_uri_escape_octet(s, index))
            index += 3
        try:
            decoded = octets.decode('utf-8')
        except UnicodeDecodeError:
            raise InterpreterError
        result.append(to_code_units(decoded))
    return ''.join(result)


@_register((None, 'encodeURIComponent'))
def _global_encode_uri_component(args: list[Value]) -> Value:
    if not args:
        raise InterpreterError
    s = spell_astral_characters(to_string(args[0]))
    try:
        return urllib.parse.quote(s, safe="!'()*~-._")
    except Exception:
        raise InterpreterError


@_register(('Object', 'keys'))
def _object_keys(args: list[Value]) -> Value:
    if not args:
        raise InterpreterError
    obj = args[0]
    if isinstance(obj, dict):
        return own_property_keys(obj)
    if isinstance(obj, list):
        # An array's own keys are its indices in ascending order, minus the holes: a hole is a
        # position the array holds no property at, which is what `Object.keys` reports as absent.
        return [str(i) for i, item in enumerate(obj) if item is not JS_HOLE]
    raise InterpreterError


@_register(('Object', 'values'))
def _object_values(args: list[Value]) -> Value:
    if args and isinstance(args[0], dict):
        return [args[0][key] for key in own_property_keys(args[0])]
    raise InterpreterError


@_register(('Object', 'entries'))
def _object_entries(args: list[Value]) -> Value:
    if args and isinstance(args[0], dict):
        return [[key, args[0][key]] for key in own_property_keys(args[0])]
    raise InterpreterError


@_register(('Array', 'from'))
def _array_from(args: list[Value]) -> Value:
    if not args:
        return []
    src = args[0]
    if isinstance(src, str):
        return code_points(src)
    if isinstance(src, list):
        # The iteration `Array.from` runs over an array yields a hole as `undefined` rather
        # than skipping it, so the copy it builds holds an element there.
        return [_element_read(item) for item in src]
    raise InterpreterError


@_register(('Array', 'isArray'))
def _array_is_array(args: list[Value]) -> Value:
    return isinstance(args[0], list) and not isinstance(args[0], JsBuffer) if args else False


@_register(('Buffer', 'from'))
def _buffer_from(args: list[Value]) -> Value:
    if not args:
        raise InterpreterError
    data = args[0]
    if isinstance(data, list):
        return JsBuffer(_to_int(v) & 0xFF for v in data)
    if not isinstance(data, str):
        raise InterpreterError
    encoding = args[1] if len(args) > 1 else 'utf8'
    if not isinstance(encoding, str):
        raise InterpreterError
    try:
        if encoding == 'base64':
            normalized = data.replace('-', '+').replace('_', '/')
            stripped = _RE_NON_BASE64.sub('', normalized)
            padded = stripped.rstrip('=')
            padded = padded + '=' * (-len(padded) % 4)
            return JsBuffer(base64.b64decode(padded))
        if encoding in ('utf8', 'utf-8'):
            return JsBuffer(spell_astral_characters(data).encode('utf-8'))
        if encoding in ('latin1', 'binary'):
            return JsBuffer(data.encode('latin-1'))
        if encoding == 'hex':
            return JsBuffer(bytes.fromhex(data))
    except Exception:
        raise InterpreterError
    raise InterpreterError


@_register((JsBuffer, 'toString'))
def _list_to_string(buf: list, args: list[Value]) -> Value:
    encoding = args[0] if args else 'utf8'
    if not isinstance(encoding, str):
        raise InterpreterError
    try:
        raw = bytes(_to_int(v) & 0xFF for v in buf)
    except (TypeError, ValueError, OverflowError):
        raise InterpreterError
    try:
        if encoding in ('utf8', 'utf-8'):
            return raw.decode('utf-8')
        if encoding in ('latin1', 'binary'):
            return raw.decode('latin-1')
        if encoding == 'base64':
            return base64.b64encode(raw).decode('ascii')
        if encoding == 'hex':
            return raw.hex()
        if encoding == 'ascii':
            return raw.decode('ascii')
    except Exception:
        raise InterpreterError
    raise InterpreterError


STATIC_OBJECTS = frozenset({'Math', 'String', 'Object', 'Array', 'Number', 'JSON', 'Buffer'})

_COERCING_GLOBALS = frozenset({'String', 'Number'})
"""
The global functions whose value *is* the coercion of their argument: `String(x)` is ToString and
`Number(x)` is ToNumber as callable spellings. They are the one pair the interpreter guards at its
dispatch, because their argument is exactly the value a program-installed conversion replaces;
the registry arms that coerce an argument in the course of a larger operation keep the behavior
they have, which the fold-admission gate outside the interpreter is what stands behind.
"""


def is_runtime_name(name: str) -> bool:
    """
    Return True if `name` is a known JavaScript runtime symbol — either a static object namespace
    (e.g. `Math`, `String`) or a global function registered in the builtin registry (e.g.
    `parseInt`, `parseFloat`).
    """
    return name in STATIC_OBJECTS or (None, name) in BUILTIN_REGISTRY


def names_runtime_builtin(node: JsIdentifier, model: SemanticModel) -> bool:
    """
    Whether *node* names a runtime symbol this interpreter models, read where nothing has bound the
    name. The spelling alone does not settle it: `catch (parseInt)` and `function o(parseInt)` both
    give the name a value of their own, and evaluating a call to it as the built-in then computes an
    answer the program never produces.
    """
    return is_runtime_name(node.name) and name_is_unbound(node, model)


def _declares_a_function_inside_a_block(
    func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression,
) -> bool:
    """
    Whether *func*'s body declares a function anywhere other than directly among its own statements
    - inside a block, a `switch` case, or the clause of an `if` or a loop.

    Which list a declaration stands in is `statement_list_holding`'s answer, so a labelled one is
    read as standing where its label stands, exactly as the scope model reads it. The walk is the
    boundary-respecting one, so a function written inside a *nested* function is that function's
    business and not this one's.
    """
    body = func.body
    if not isinstance(body, JsBlockStatement):
        return False
    return any(
        isinstance(node, JsFunctionDeclaration) and statement_list_holding(node) is not body
        for node in walk_scope(body)
    )


class JsInterpreter:
    """
    Execute a JavaScript function body with concrete argument values. Returns a Python value or
    raises `IrreducibleExpression` when the return value cannot be reduced to a simple value.
    """

    def __init__(
        self, *,
        max_iterations: int = MAX_ITERATIONS,
        max_string_len: int = MAX_STRING_LEN,
        max_array_len: int = MAX_ARRAY_LEN,
        max_recursion: int = _MAX_RECURSION,
        effects: EffectModel | None = None,
        model: SemanticModel | None = None,
        anchor: Node | None = None,
        tampering: TamperingModel | None = None,
        closure: Mapping[str, Value] | None = None,
        closure_env: Mapping[int, Mapping[str, Value]] | None = None,
        established: Callable[[JsFunctionNode], bool] | None = None,
        depth: int = 0,
    ):
        self.max_iterations = max_iterations
        self.max_string_len = max_string_len
        self.max_array_len = max_array_len
        self.max_recursion = max_recursion
        self._effects = effects
        self._model = model if model is not None else effects and effects.model
        self._anchor = anchor
        self._tampering = tampering
        """
        The scope authority, held separately from *effects*. Whether a name still reaches the host is a
        question about bindings alone, so a caller that has only the semantic model — reflection builds
        one and would pay for an effect model it never consults — can answer it by passing *model*.
        """
        self._closure: Mapping[str, Value] = closure or {}
        self._closure_env: Mapping[int, Mapping[str, Value]] = closure_env or {}
        self._established = established
        self._env: dict[str, Value] = {}
        self._iterations = 0
        self._depth = depth

    def execute(
        self,
        func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression,
        arguments: list[Value],
    ) -> Value:
        """
        The value a call to *func* with *arguments* answers, which is the value its body returned
        only where a call answers that value at all. An `async` function answers a promise and a
        generator answers a generator object whose body has not run, so `wraps_return` ends the
        interpretation rather than reporting a value the call never had. `InterpreterError` is what
        ends it and not `IrreducibleExpression`: the latter hands the caller the body's return
        expression to splice into the call site, which is the same wrong answer written a second
        way.

        A body declaring a function anywhere but among its own statements ends it too. This
        interpreter has one environment per call and none per block, so it holds such a function
        from the entry of the body, where the language holds it in the block it is written in and,
        in sloppy code, in the enclosing scope only from the point the declaration runs. A read
        before that point is the difference, and it is one this answers with the function where a
        program answers `undefined`.
        """
        if wraps_return(func):
            raise InterpreterError
        if _declares_a_function_inside_a_block(func):
            raise InterpreterError
        params = func.params
        param_names: list[str] = []
        for p in params:
            if not isinstance(p, JsIdentifier):
                raise InterpreterError
            param_names.append(p.name)
        self._env = {}
        for i, name in enumerate(param_names):
            self._env[name] = arguments[i] if i < len(arguments) else None
        body = func.body
        for name in self._collect_hoisted_var_names(body):
            if name == 'arguments' and not isinstance(func, JsArrowFunctionExpression):
                # The call has already bound `arguments` to the argument object before any
                # statement runs, so an entry holding `undefined` would answer a read with a
                # value the call never made — including under a `var` that displaces it, where
                # leaving the name unbound refuses instead.
                continue
            self._env.setdefault(name, None)
        arguments_object = self._arguments_object(func, arguments)
        if arguments_object is not None:
            self._env['arguments'] = arguments_object
        for name, value in self._closure.items():
            if name not in self._env:
                self._env[name] = _deep_copy_value(value)
        self._iterations = 0
        if isinstance(body, JsBlockStatement):
            try:
                self._exec_statements(body.body)
            except _ReturnSignal as r:
                return r.value
            except _ReturnIrreducible as r:
                raise IrreducibleExpression(r.node)
            except IrreducibleExpression:
                raise InterpreterError
            except _ThrowSignal:
                if self._depth == 0:
                    raise InterpreterError
                raise
            return None
        if body is not None:
            try:
                return self._eval(body)
            except IrreducibleExpression:
                raise IrreducibleExpression(body)
            except _ThrowSignal:
                if self._depth == 0:
                    raise InterpreterError
                raise
        return None

    @staticmethod
    def _collect_hoisted_var_names(body) -> list[str]:
        """
        Collect the names of all `var` declarations in *body*, which JavaScript hoists to the top of
        the function scope (initialized to `undefined`). Nested function bodies are not traversed.
        Reading a hoisted name before its initializer must yield `undefined`, not an unresolved free
        identifier.
        """
        if not isinstance(body, JsBlockStatement):
            return []
        names: list[str] = []
        for node in walk_scope(body, include_root_body=True):
            if isinstance(node, JsVariableDeclaration) and node.kind == JsVarKind.VAR:
                for decl in node.declarations:
                    if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier):
                        names.append(decl.id.name)
        return names

    def _arguments_object(self, func, values: list[Value]) -> JsArguments | None:
        """
        The `arguments` object a call to *func* may be given, or `None` where the body may not be
        handed one. Whether the object a call models from its argument values can stand in for
        the name is `call_supplies_an_arguments_object`'s question, shared with the fold that
        asks which names a call supplies. Every failure leaves the name unbound, so the read
        declines through the `IrreducibleExpression` every unresolved name takes, and a nested
        function's own `arguments` declines the same way in its own environment.
        """
        model = self._model
        if model is None or not call_supplies_an_arguments_object(model, func):
            return None
        return JsArguments(values)

    def _exec_statements(self, stmts: list) -> None:
        for stmt in stmts:
            self._exec_statement(stmt)

    def _exec_statement(self, stmt) -> None:
        if isinstance(stmt, JsVariableDeclaration):
            self._exec_var_decl(stmt)
        elif isinstance(stmt, JsExpressionStatement):
            self._eval(stmt.expression)
        elif isinstance(stmt, JsIfStatement):
            self._exec_if(stmt)
        elif isinstance(stmt, JsSwitchStatement):
            self._exec_switch(stmt)
        elif isinstance(stmt, JsForStatement):
            self._exec_for(stmt)
        elif isinstance(stmt, JsWhileStatement):
            self._exec_while(stmt)
        elif isinstance(stmt, JsDoWhileStatement):
            self._exec_do_while(stmt)
        elif isinstance(stmt, JsForInStatement):
            self._exec_for_in(stmt)
        elif isinstance(stmt, JsForOfStatement):
            self._exec_for_of(stmt)
        elif isinstance(stmt, JsReturnStatement):
            if stmt.argument is None:
                raise _ReturnSignal(None)
            try:
                value = self._eval(stmt.argument)
            except IrreducibleExpression:
                raise _ReturnIrreducible(stmt.argument)
            raise _ReturnSignal(value)
        elif isinstance(stmt, JsBreakStatement):
            raise _BreakSignal
        elif isinstance(stmt, JsContinueStatement):
            raise _ContinueSignal
        elif isinstance(stmt, JsBlockStatement):
            self._exec_statements(stmt.body)
        elif isinstance(stmt, JsTryStatement):
            self._exec_try(stmt)
        elif isinstance(stmt, JsThrowStatement):
            value = self._eval(stmt.argument) if stmt.argument else None
            raise _ThrowSignal(value)
        elif isinstance(stmt, JsFunctionDeclaration):
            if isinstance(stmt.id, JsIdentifier):
                self._env[stmt.id.name] = stmt
        elif isinstance(stmt, JsEmptyStatement):
            return
        else:
            raise InterpreterError

    def _exec_var_decl(self, node: JsVariableDeclaration) -> None:
        for decl in node.declarations:
            if not isinstance(decl, JsVariableDeclarator):
                raise InterpreterError
            if not isinstance(decl.id, JsIdentifier):
                raise InterpreterError
            name = decl.id.name
            if decl.init is not None:
                self._env[name] = self._eval(decl.init)
            elif node.kind == JsVarKind.VAR:
                self._env.setdefault(name, None)
            else:
                self._env[name] = None

    def _exec_if(self, node: JsIfStatement) -> None:
        if to_boolean(self._eval(node.test)):
            if node.consequent:
                self._exec_statement(node.consequent)
        elif node.alternate:
            self._exec_statement(node.alternate)

    def _exec_switch(self, node: JsSwitchStatement) -> None:
        """
        A switch runs the clause whose test the discriminant matches, and where none matches, the
        default clause. The default is chosen only after every test has been evaluated, so a clause
        written after it is still asked. Writing the default in the middle of a dispatcher is a way
        to make the clause order look like the run order when it is not, and reading the default the
        moment it is reached answers with the wrong branch for every discriminant a later clause
        would have claimed.

        Once a clause is chosen, execution enters it and falls through the clauses behind it, which
        is why the run is a walk from the chosen index rather than of the clause alone. Tests behind
        the match are not evaluated: a match ends the search, and an expression that would throw is
        never reached.

        A second default clause is an early error, so a switch carrying one is not a program and is
        refused rather than interpreted as though the first of them governed.
        """
        cases = [case for case in node.cases if isinstance(case, JsSwitchCase)]
        if len(cases) != len(node.cases):
            raise InterpreterError
        defaults = [index for index, case in enumerate(cases) if case.test is None]
        if len(defaults) > 1:
            raise InterpreterError
        discriminant = self._eval(node.discriminant)
        chosen = None
        for index, case in enumerate(cases):
            if case.test is None:
                continue
            if self._strict_equal(discriminant, self._eval(case.test)):
                chosen = index
                break
        if chosen is None:
            if not defaults:
                return
            chosen = defaults[0]
        try:
            for case in cases[chosen:]:
                self._exec_statements(case.body)
        except _BreakSignal:
            return

    def _exec_loop_body(self, body) -> bool:
        if not body:
            return False
        try:
            self._exec_statement(body)
        except _BreakSignal:
            return True
        except _ContinueSignal:
            pass
        return False

    def _exec_for(self, node: JsForStatement) -> None:
        if node.init:
            if isinstance(node.init, JsVariableDeclaration):
                self._exec_var_decl(node.init)
            else:
                self._eval(node.init)
        while True:
            self._tick()
            if node.test and not to_boolean(self._eval(node.test)):
                break
            if self._exec_loop_body(node.body):
                break
            if node.update:
                self._eval(node.update)

    def _exec_while(self, node: JsWhileStatement) -> None:
        while True:
            self._tick()
            if not to_boolean(self._eval(node.test)):
                break
            if self._exec_loop_body(node.body):
                break

    def _exec_do_while(self, node: JsDoWhileStatement) -> None:
        while True:
            self._tick()
            if self._exec_loop_body(node.body):
                break
            if not to_boolean(self._eval(node.test)):
                break

    def _exec_for_in(self, node: JsForInStatement) -> None:
        """
        Walk the enumerable names of the receiver, which is every name it owns and every enumerable
        one its prototype chain holds.

        The chain contributes none of its own while the program leaves it alone: measured across
        sixteen receiver kinds, every name the language installs on a prototype — and every method a
        class body writes onto one — is non-enumerable. So the own names are the whole answer
        exactly while the chain is intact, and where it is not, the walk is refused rather than
        answered a name short. Refusing one walk is what that costs, which is why this asks
        `read_chain_intact` rather than the arm that overlooks a reflective surface.

        The question asked is about the whole chain rather than about one key, which over-refuses
        where an own name shadows an added inherited one: `Object.prototype.z = 9` over a receiver
        holding its own `z` walks `z` once either way. That costs a fold and never an answer.
        """
        right = self._eval(node.right)
        if right is None or right is JS_NULL:
            return
        if not isinstance(right, (dict, list)):
            raise InterpreterError
        if not self._chain_is_intact(type(right)):
            raise InterpreterError
        keys: list
        if isinstance(right, dict):
            keys = own_property_keys(right)
        else:
            keys = [str(i) for i, item in enumerate(right) if item is not JS_HOLE]
        var_name = self._get_loop_var(node.left)
        for key in keys:
            self._tick()
            self._env[var_name] = key
            if self._exec_loop_body(node.body):
                break

    def _exec_for_of(self, node: JsForOfStatement) -> None:
        """
        Walk the values the receiver's iterator yields, which is the iterator a list's or a
        string's prototype supplies — so the walk asks the same chain question a for-in walk does
        before it yields anything: a program that patched `Array.prototype[Symbol.iterator]` makes
        the real loop answer something other than the elements, and an interpretation that walks
        them regardless is answering for it.
        """
        right = self._eval(node.right)
        if right is None or right is JS_NULL:
            _js_throw('TypeError', F'{to_string(right)} is not iterable')
        if not isinstance(right, (list, str)):
            raise InterpreterError
        if not self._chain_is_intact(type(right)):
            raise InterpreterError
        if isinstance(right, list):
            # An array's iterator yields a hole as `undefined` — it asks whether the array has a
            # property at the index and takes the value on either branch — so a hole is not a skip.
            items = [None if item is JS_HOLE else item for item in right]
        else:
            items = code_points(right)
        var_name = self._get_loop_var(node.left)
        for item in items:
            self._tick()
            self._env[var_name] = item
            if self._exec_loop_body(node.body):
                break

    def _exec_try(self, node: JsTryStatement) -> None:
        thrown: _ThrowSignal | None = None
        propagate: Exception | None = None
        try:
            if node.block:
                self._exec_statements(node.block.body)
        except _ThrowSignal as exc:
            thrown = exc
        except (
            IrreducibleExpression,
            InterpreterError,
            _ReturnSignal,
            _BreakSignal,
            _ContinueSignal,
            _ReturnIrreducible,
        ) as exc:
            propagate = exc
        if propagate is not None:
            if node.finalizer:
                self._exec_statements(node.finalizer.body)
            raise propagate
        if thrown is not None:
            if node.handler and node.handler.body:
                param_name: str | None = None
                had_param: bool = False
                prev_param: Value = None
                if isinstance(node.handler.param, JsIdentifier):
                    param_name = node.handler.param.name
                    had_param = param_name in self._env
                    prev_param = self._env.get(param_name)
                    self._env[param_name] = thrown.value
                handler_outcome: Exception | None = None
                try:
                    self._exec_statements(node.handler.body.body)
                except (
                    _ThrowSignal,
                    IrreducibleExpression,
                    InterpreterError,
                    _ReturnSignal,
                    _BreakSignal,
                    _ContinueSignal,
                    _ReturnIrreducible,
                ) as exc:
                    handler_outcome = exc
                finally:
                    if param_name is not None:
                        if had_param:
                            self._env[param_name] = prev_param
                        else:
                            self._env.pop(param_name, None)
                if node.finalizer:
                    self._exec_statements(node.finalizer.body)
                if handler_outcome is not None:
                    raise handler_outcome
                return
            if node.finalizer:
                self._exec_statements(node.finalizer.body)
            raise thrown
        if node.finalizer:
            self._exec_statements(node.finalizer.body)

    def _get_loop_var(self, left) -> str:
        if isinstance(left, JsVariableDeclaration):
            if len(left.declarations) == 1:
                decl = left.declarations[0]
                if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier):
                    return decl.id.name
        if isinstance(left, JsIdentifier):
            return left.name
        raise InterpreterError

    def _tick(self) -> None:
        self._iterations += 1
        if self._iterations > self.max_iterations:
            raise InterpreterError

    def _eval(self, expr) -> Value:
        if expr is None:
            return None
        if isinstance(expr, JsStringLiteral):
            if not expr.terminated or expr.value is None:
                raise IrreducibleExpression(expr)
            return expr.value
        if isinstance(expr, JsNumericLiteral):
            return expr.value
        if isinstance(expr, JsBooleanLiteral):
            return expr.value
        if isinstance(expr, JsNullLiteral):
            return JS_NULL
        if isinstance(expr, JsIdentifier):
            return self._eval_identifier(expr)
        if isinstance(expr, JsBinaryExpression):
            return self._eval_binary(expr)
        if isinstance(expr, JsUnaryExpression):
            return self._eval_unary(expr)
        if isinstance(expr, JsUpdateExpression):
            return self._eval_update(expr)
        if isinstance(expr, JsLogicalExpression):
            return self._eval_logical(expr)
        if isinstance(expr, JsAssignmentExpression):
            return self._eval_assignment(expr)
        if isinstance(expr, JsCallExpression):
            return self._eval_call(expr)
        if isinstance(expr, JsMemberExpression):
            return self._eval_member(expr)
        if isinstance(expr, JsConditionalExpression):
            test = self._eval(expr.test)
            return self._eval(expr.consequent) if to_boolean(test) else self._eval(expr.alternate)
        if isinstance(expr, JsArrayExpression):
            return self._eval_array(expr)
        if isinstance(expr, JsSequenceExpression):
            result: Value = None
            for e in expr.expressions:
                result = self._eval(e)
            return result
        if isinstance(expr, JsTemplateLiteral):
            return self._eval_template(expr)
        if isinstance(expr, JsObjectExpression):
            return self._eval_object(expr)
        if isinstance(expr, (JsFunctionExpression, JsArrowFunctionExpression)):
            return expr
        if isinstance(expr, JsParenthesizedExpression):
            return self._eval(expr.expression)
        raise InterpreterError

    def _resolve_function_node(self, node: JsIdentifier) -> JsFunctionNode | None:
        """
        The single function *node* names, or `None`. Delegates to `EffectModel.unambiguous_function`: a
        function declaration or a bare-assignment (`var f; f = function(){}`) resolves, but a name
        reassigned away from a value it already held stays unresolved. When an *established* predicate was
        supplied, a resolved function is returned only if it is in place before the call site folding began
        — a bare-assignment or initializer function reached before its establishing node has run reads a
        temporal dead zone or a hoisted `undefined` at runtime, so resolving it here would replace that
        throw with a value. A hoisted function declaration is always established and passes unconditionally.
        """
        effects = self._effects
        if effects is None:
            return None
        func = effects.unambiguous_function(effects.model.resolve(node))
        if func is None:
            return None
        if self._established is not None and not self._established(func):
            return None
        return func

    def _names_a_runtime_builtin(self, node: JsIdentifier) -> bool:
        """
        Whether *node* still reaches the host built-in of that name. Without a model this answers
        `False` and the call becomes irreducible, for the same reason `_names_a_global_value` does.
        """
        model = self._model
        return model is not None and names_runtime_builtin(node, model)

    def _names_a_static_object(self, node: JsIdentifier) -> bool:
        """
        Whether *node*, standing in receiver position ahead of a method the registry knows, still
        reaches the host object of that name. It is the question `_names_a_runtime_builtin` asks
        for a bare callee, and the static branch has to ask it too: `var Number = {parseInt: ...}`
        gives the name a value of its own, and answering the call from the registry then computes a
        number the program never produces. Without a model this answers `True` rather than `False`,
        matching `_callee_is_intact`: an interpreter used on one expression in isolation cannot see
        a scope, so the assumption is the caller's.
        """
        if node.name in self._env:
            return False
        model = self._model
        return model is None or names_runtime_builtin(node, model)

    def _names_a_global_value(self, node: JsIdentifier) -> bool:
        """
        Whether *node* is `undefined`, `NaN` or `Infinity` still denoting the global value, which only
        the semantic model can say. Without one this answers `False` and the name becomes irreducible:
        an interpreter that cannot see the scope cannot tell the global from a binding an enclosing
        function supplies, and guessing the global is how a call to `function f(NaN) { return NaN + 1 }`
        folded to `NaN` instead of to its argument.
        """
        model = self._model
        return model is not None and names_global_value(node, model)

    def _resolves_to_a_binding(self, node: JsIdentifier) -> bool:
        """
        Whether *node* resolves to any binding at all. A name the model binds but `_env` does not hold
        has a value this interpreter does not know — it may belong to an enclosing scope that is not in
        the closure, or to a `let` whose declarator has not run, which is a read in its temporal dead
        zone that throws — so the well-known-global `typeof` fallback must not answer for it.
        """
        model = self._model
        return model is not None and model.resolve(node) is not None

    def _eval_identifier(self, node: JsIdentifier) -> Value:
        name = node.name
        if name in self._env:
            return self._env[name]
        func = self._resolve_function_node(node)
        if func is not None:
            return func
        if self._names_a_global_value(node):
            return GLOBAL_VALUE_NAMES[name]
        raise IrreducibleExpression(node)

    def _js_add(self, left: Value, right: Value) -> Value:
        """
        Replicate the JavaScript `+` operator: apply ToPrimitive to both operands, then concatenate
        as strings if either is a string, otherwise add numerically.
        """
        left = _to_primitive(left)
        right = _to_primitive(right)
        if isinstance(left, str) or isinstance(right, str):
            result = to_string(left) + to_string(right)
            if len(result) > self.max_string_len:
                raise InterpreterError
            return result
        return to_number(left) + to_number(right)

    def _eval_binary(self, node: JsBinaryExpression) -> Value:
        return self._apply_binary(node.operator, self._eval(node.left), self._eval(node.right))

    def _apply_binary(self, op: str, left: Value, right: Value) -> Value:
        """
        Apply the binary operator *op* to two already-evaluated values — the one place this interpreter
        decides what an operator means. A binary expression and the arithmetic step of a compound
        assignment ask the same question, so they must ask it here.

        `eval_binary_op` handles the numeric operators alone, which is why the string cases resolve first:
        `+` needs ToPrimitive on both operands and may yield a concatenation, and a relational operator
        compares two strings lexicographically rather than numerically. Everything after that is a number.

        Every operator but strict equality coerces its operands — `+` and the relational operators to
        primitives, the numeric ones to numbers, `==` to whichever the other side demands, and `in` its
        left side to a key — so every one of them asks the coercion guard first, the `in` operator of
        its key alone: an object whose conversion a program installed decides what all of these answer,
        and strict equality is the one operator that runs nothing.
        """
        if op not in ('===', '!=='):
            self._require_uninterceptable(left)
            if op != 'in':
                self._require_uninterceptable(right)
        if op == '===':
            return self._strict_equal(left, right)
        if op == '!==':
            return not self._strict_equal(left, right)
        if op == '==':
            return self._loose_equal(left, right)
        if op == '!=':
            return not self._loose_equal(left, right)
        if op == '+':
            return self._js_add(left, right)
        if op == 'in':
            return self._eval_in(left, right)
        if op == 'instanceof':
            raise InterpreterError
        if op in RELATIONAL_OPS:
            primitive_left = _to_primitive(left)
            primitive_right = _to_primitive(right)
            if isinstance(primitive_left, str) and isinstance(primitive_right, str):
                return RELATIONAL_OPS[op](primitive_left, primitive_right)
        result = eval_binary_op(op, to_number(left), to_number(right))
        if result is None:
            raise InterpreterError
        return result

    def _eval_in(self, left: Value, right: Value) -> bool:
        """
        Whether *right* has a property named by *left*, which the operator answers for the whole
        prototype chain and not for the own properties alone. The three ways that can go are the
        three this file has words for: the value owns a slot there, the chain supplies the name, or
        neither does and it is `property_provably_absent`'s to say whether that settles anything.

        A name it will not decide without the chain is one this operator cannot answer `false` for
        either, so the interpretation stops rather than reporting what the own properties happen to
        show. `Object.prototype.z = 9` makes `'z' in {}` true, and reading a refusal as an absence
        is what a program written to be read wrongly is looking for.
        """
        if not isinstance(right, (dict, list)):
            raise InterpreterError
        key = to_string(left)
        if read_data_property(right, key)[0] is MemberRead.FOUND:
            return True
        chain_intact = property_is_inherited_from_an_intact_chain(self._effects, type(right), key)
        if not chain_intact and self._builtins_intact():
            chain_intact = property_is_inherited_from_an_unwritten_chain(
                self._effects, type(right), key)
        if chain_intact:
            return True
        if self._property_is_absent(right, key):
            return False
        raise InterpreterError

    def _eval_array(self, expr: JsArrayExpression) -> list[Value]:
        """
        The list an array literal builds, refusing one that would hold a hole. An elision is not an
        element whose value is `undefined`: `[1, , 3][1]` reads `Array.prototype[1]` where
        `[1, undefined, 3][1]` reads the element, so the two answer differently wherever that
        prototype is written, and `indexOf` and the callback methods skip the first while visiting
        the second.

        A hole is representable in this domain — a store growing an array past its end builds one —
        but a literal holding one has no fold to go to: `value_to_node` refuses a holey list, so
        building it here would answer no call. The syntax model spells an elision as no element at
        all, which is the shape this refusal leaves standing.
        """
        if any(element is None for element in expr.elements):
            raise InterpreterError
        return [self._eval(element) for element in expr.elements]

    def _eval_unary(self, node: JsUnaryExpression) -> Value:
        """
        Apply a unary operator through `UNARY_OPS`, which holds the ones that are functions of the
        operand's value. `typeof` on a bare name is answered before the operand is evaluated because it
        is the one expression that reads a name without requiring it to exist: `typeof missing` is
        `'undefined'` where evaluating `missing` is a `ReferenceError`.
        """
        op = node.operator
        operand = node.operand
        if op == 'typeof' and isinstance(operand, JsIdentifier):
            name = operand.name
            if name in self._env:
                return js_typeof(self._env[name])
            if self._resolve_function_node(operand) is not None:
                return 'function'
            if self._resolves_to_a_binding(operand):
                raise IrreducibleExpression(node)
            environment = self._model.environment if self._model is not None else HostEnvironment.universal
            result = typeof_of_global(name, environment)
            if result is None:
                raise IrreducibleExpression(node)
            return result
        apply = UNARY_OPS.get(op)
        if apply is None:
            raise InterpreterError
        value = self._eval(operand)
        if op in ('+', '-', '~'):
            self._require_uninterceptable(value)
        return apply(value)

    def _eval_update(self, node: JsUpdateExpression) -> Value:
        """
        Apply `++` or `--`. The target may be a plain identifier or a member, and both must be supported here:
        `a[i]++` is the same operation as `a[i] += 1` on a value the language has already coerced to a number,
        so refusing one while folding the other would make the two disagree.
        """
        delta = {'++': 1, '--': -1}.get(node.operator)
        if delta is None:
            raise InterpreterError
        target = node.argument
        if isinstance(target, JsIdentifier):
            name = target.name
            if name not in self._env:
                raise InterpreterError
            self._require_uninterceptable(self._env[name])
            current = to_number(self._env[name])
            self._env[name] = current + delta
        elif isinstance(target, JsMemberExpression):
            obj = self._eval(target.object)
            key = self._member_key(target)
            current = self._get_property(obj, key)
            self._require_uninterceptable(current)
            current = to_number(current)
            self._set_property(obj, key, current + delta)
        else:
            raise InterpreterError
        return current + delta if node.prefix else current

    def _short_circuits(self, operator: str, current: Value) -> bool:
        """
        Whether a logical assignment (`&&=`, `||=`, `??=`) is already decided by *current*, so neither its
        right operand nor the store runs. The three truthiness rules are the ones `_eval_logical` applies to
        the expression forms, read in the other direction.
        """
        if operator == '&&=':
            return not to_boolean(current)
        if operator == '||=':
            return to_boolean(current)
        if operator == '??=':
            return current is not None and current is not JS_NULL
        raise InterpreterError

    def _compound_value(self, operator: str, current: Value, node: JsAssignmentExpression) -> Value:
        """
        The value a compound assignment stores: its right operand evaluated and combined with *current* under
        the operator the assignment names. *current* is read by the caller before this runs, because
        JavaScript reads the target before evaluating the right operand — `v += (v = 10)` on `v = 5` is 15.
        """
        return self._apply_binary(operator[:-1], current, self._eval(node.right))

    def _eval_logical(self, node: JsLogicalExpression) -> Value:
        left = self._eval(node.left)
        if node.operator == '&&':
            return self._eval(node.right) if to_boolean(left) else left
        if node.operator == '||':
            return left if to_boolean(left) else self._eval(node.right)
        if node.operator == '??':
            if left is None or left is JS_NULL:
                return self._eval(node.right)
            return left
        raise InterpreterError

    def _eval_assignment(self, node: JsAssignmentExpression) -> Value:
        if isinstance(node.left, JsMemberExpression):
            return self._eval_member_assignment(node)
        if not isinstance(node.left, JsIdentifier):
            raise InterpreterError
        name = node.left.name
        op = node.operator
        if op == '=':
            value = self._eval(node.right)
            self._env[name] = value
            return value
        current = self._eval(node.left)
        if op in LOGICAL_ASSIGNMENT_OPS:
            if self._short_circuits(op, current):
                return current
            self._env[name] = self._eval(node.right)
            return self._env[name]
        self._env[name] = self._compound_value(op, current, node)
        return self._env[name]

    def _eval_member_assignment(self, node: JsAssignmentExpression) -> Value:
        """
        Assign to a member target. The object and key expressions are evaluated exactly once, before the
        operator is considered, so `a[i++] += 1` advances `i` once — and a logical assignment that
        short-circuits performs no store at all.

        Skipping that store is what JavaScript specifies rather than an optimization: the language makes it
        observable through a setter or a frozen object, neither of which this interpreter's value domain has.
        Within this domain a store of the value just read is idempotent, so no program folded here can tell
        the difference — the rule is kept because the domain is a subset of the language's, not because a test
        can witness it.
        """
        member = node.left
        if not isinstance(member, JsMemberExpression):
            raise InterpreterError
        obj = self._eval(member.object)
        key = self._member_key(member)
        if node.operator == '=':
            value = self._eval(node.right)
        elif node.operator in LOGICAL_ASSIGNMENT_OPS:
            old = self._get_property(obj, key)
            if self._short_circuits(node.operator, old):
                return old
            value = self._eval(node.right)
        else:
            value = self._compound_value(node.operator, self._get_property(obj, key), node)
        self._set_property(obj, key, value)
        return value

    def _eval_call(self, node: JsCallExpression) -> Value:
        if isinstance(node.callee, JsMemberExpression):
            return self._eval_method_call(node)
        if isinstance(node.callee, JsIdentifier):
            return self._eval_function_call(node)
        if isinstance(node.callee, (JsFunctionExpression, JsArrowFunctionExpression)):
            return self._eval_inline_call(node.callee, node.arguments)
        raise InterpreterError

    def _eval_function_call(self, node: JsCallExpression) -> Value:
        """
        Call the function a bare name denotes. The registry is consulted last, and only for a name the
        scope has left alone: it records what the *host* provides, which is what a name reaches only
        when nothing nearer has claimed it. Asking it first is how a call to a parameter or a `catch`
        binding named `parseInt` was answered by the real `parseInt`.
        """
        callee = node.callee
        if not isinstance(callee, JsIdentifier):
            raise InterpreterError
        name = callee.name
        args = [self._eval(a) for a in node.arguments]
        if name in self._env:
            target = self._env[name]
            if isinstance(target, (JsFunctionDeclaration, JsFunctionExpression, JsArrowFunctionExpression)):
                return self._call_function(target, args)
            if node.optional and (target is None or target is JS_NULL):
                return None
            _js_throw('TypeError', F'{name} is not a function')
        func = self._resolve_function_node(callee)
        if func is not None:
            return self._call_function(func, args)
        builtin = BUILTIN_REGISTRY.get((None, name))
        if builtin is not None and self._names_a_runtime_builtin(callee):
            if name in _COERCING_GLOBALS and not all(
                self._coerces_uninterceptably(arg) for arg in args
            ):
                raise InterpreterError
            return builtin(args)
        raise InterpreterError

    def _eval_method_call(self, node: JsCallExpression) -> Value:
        member = node.callee
        if not isinstance(member, JsMemberExpression):
            raise InterpreterError
        if (
            isinstance(member.object, JsIdentifier)
            and member.object.name in STATIC_OBJECTS
        ):
            static_name = member.object.name
            method_name = self._member_key(member)
            args = [self._eval(a) for a in node.arguments]
            builtin = BUILTIN_REGISTRY.get((static_name, method_name))
            if builtin is not None:
                if not self._names_a_static_object(member.object):
                    raise InterpreterError
                if not self._callee_is_intact(node):
                    raise InterpreterError
                return builtin(args)
            raise InterpreterError
        obj = self._eval(member.object)
        if obj is None or obj is JS_NULL:
            if member.optional:
                return None
            _js_throw('TypeError', F"Cannot read properties of {to_string(obj)} (reading a method)")
        method_name = self._member_key(member)
        args = [self._eval(a) for a in node.arguments]
        if isinstance(obj, (str, list)) and method_name in SEQUENCE_DATA_PROPERTIES:
            # A data property is not callable. The arguments are evaluated first, as JS does, so a side
            # effect or a throw inside one is not lost to this TypeError.
            _js_throw('TypeError', F'{to_string(obj)}.{method_name} is not a function')
        obj_type = type(obj)
        builtin = BUILTIN_REGISTRY.get((obj_type, method_name))
        if builtin is None and obj_type is not list and isinstance(obj, list):
            builtin = BUILTIN_REGISTRY.get((list, method_name))
        if builtin is not None:
            if not self._prototype_is_intact(obj_type):
                raise InterpreterError
            result = builtin(obj, args)
            if isinstance(obj, JsBuffer) and isinstance(result, list) and not isinstance(result, JsBuffer):
                result = JsBuffer(result)
            return result
        if isinstance(obj, list) and method_name in _ARRAY_HOF_METHODS:
            if not self._prototype_is_intact(obj_type):
                raise InterpreterError
            result = self._eval_array_hof(obj, method_name, args)
            if isinstance(obj, JsBuffer) and method_name in _BUFFER_PRESERVING_HOFS:
                if isinstance(result, list) and not isinstance(result, JsBuffer):
                    result = JsBuffer(result)
            return result
        if isinstance(obj, (JsFunctionExpression, JsArrowFunctionExpression)):
            if method_name in ('call', 'apply') and not self._prototype_is_intact(obj_type):
                raise InterpreterError
            if method_name == 'call':
                return self._call_function(obj, args[1:] if len(args) > 1 else [])
            if method_name == 'apply':
                actual_args = args[1] if len(args) > 1 and isinstance(args[1], list) else []
                return self._call_function(obj, actual_args)
        raise InterpreterError

    def _builtins_intact(self) -> bool:
        """
        Whether the tampering oracle vouches that no builtin was replaced before the anchor's
        execution. An interpreter constructed with no anchor, or with no oracle beside it, answers
        `False`: the trust questions below then fall through to their no-anchor arms, which refuse
        wherever a reflective surface could have done the replacing.
        """
        return (
            self._tampering is not None
            and self._anchor is not None
            and self._tampering.builtins_intact_at(self._anchor)
        )

    def _callee_is_intact(self, node: JsCallExpression) -> bool:
        """
        Whether the built-in *node* names is still that built-in. Evaluating a call by looking its name up
        in the registry assumes the program has not replaced it, and an obfuscated file may well have.
        Without an effect model there is nothing to consult, and the interpreter is then used on a single
        expression in isolation rather than over a whole program, so the assumption is the caller's.

        Where the oracle has vouched for the anchor, the reflection terms of the name question are
        answered already, so the attributed writes alone decide — `call_names_an_unwritten_builtin`.
        """
        effects = self._effects
        if effects is None:
            return True
        if self._builtins_intact():
            return effects.call_names_an_unwritten_builtin(node)
        return effects.call_is_foldable(node)

    def _require_uninterceptable(self, value: Value) -> None:
        """
        Refuse where converting *value* to the primitive an operator, a key, or a template hole needs
        could run a conversion the program installed. `coerces_uninterceptably` decides, against the
        effect model this interpreter holds — or its surface-free half, where the oracle has vouched
        for the anchor; without an effect model there is no chain to vouch for an object,
        so every object conversion refuses rather than assuming a file that replaced none.
        """
        if not self._coerces_uninterceptably(value):
            raise InterpreterError

    def _coerces_uninterceptably(self, value: Value) -> bool:
        """
        The coercion guard the operator, key, and template arms ask: `coerces_uninterceptably`, or
        its written-chain half where the oracle has vouched for the anchor.
        """
        if self._builtins_intact():
            return coerces_uninterceptably_from_written_chain(self._effects, value)
        return coerces_uninterceptably(self._effects, value)

    def _prototype_is_intact(self, value_type: type) -> bool:
        """
        Whether the prototype that supplies *value_type*'s methods is unmodified, so dispatching a method
        on a receiver of that type by name still means what the language says. This is the same question
        `_callee_is_intact` asks, for a receiver that names no global; where the oracle has vouched for
        the anchor, the attributed writes alone decide.
        """
        effects = self._effects
        if effects is None:
            return True
        if self._builtins_intact():
            return effects.prototype_name_unwritten(value_type)
        return effects.trusted_prototype(value_type)

    def _chain_is_intact(self, value_type: type) -> bool:
        """
        Whether every prototype a plain property read on a value of *value_type* consults is
        unmodified — `EffectModel.read_chain_intact`, or its surface-free half where the oracle has
        vouched for the anchor. The question the for-in and for-of walks and the inherited-read
        arms ask; without an effect model there is no chain to consult and the caller owns the
        assumption.
        """
        effects = self._effects
        if effects is None:
            return True
        if self._builtins_intact():
            return effects.chain_roots_unwritten(value_type)
        return effects.read_chain_intact(value_type)

    def _callback_is_contained(self, callback: JsFunctionNode) -> bool:
        """
        Whether running *callback* for its return values alone loses nothing. A higher-order method is
        evaluated here for the value it produces, so a callback that also writes a binding outside itself
        has an effect the resulting literal cannot carry: `[1,2].map(function (x) { n += x; return x; })`
        yields the right array while leaving `n` unchanged.

        It asks `is_effect_free_when_discarded` rather than `is_pure`, which is stricter than this
        position needs: a mutation the callback confines to a fresh local it returns is tolerated, while
        a throw the summary records in `throws` is not. Purity alone is not sufficient either — a write
        to a *script-scope* `var` reports `writes_captured=False`, slipping through every purity flag —
        so `written_bindings`, which records outer bindings by identity, is checked too.
        """
        effects = self._effects
        if effects is None:
            return True
        summary = effects.summary_of(callback)
        return summary.is_effect_free_when_discarded and not summary.written_bindings

    def _eval_array_hof(self, arr: list, method: str, args: list[Value]) -> Value:
        if not args:
            raise InterpreterError
        callback = args[0]
        if not isinstance(
            callback,
            (JsFunctionDeclaration, JsFunctionExpression, JsArrowFunctionExpression)
        ):
            raise InterpreterError
        if not self._callback_is_contained(callback):
            raise InterpreterError
        # Every higher-order method asks the array whether it has a property at each index
        # before it calls, so a hole is a position no callback runs at and no filter keeps.
        # `find` and `findIndex` are the exception: they read the element directly and visit a
        # hole as the `undefined` the read yields.
        if method == 'every':
            for i, item in enumerate(arr):
                if item is JS_HOLE:
                    continue
                self._tick()
                if not to_boolean(self._call_function(callback, [item, i, arr])):
                    return False
            return True
        if method == 'some':
            for i, item in enumerate(arr):
                if item is JS_HOLE:
                    continue
                self._tick()
                if to_boolean(self._call_function(callback, [item, i, arr])):
                    return True
            return False
        if method == 'map':
            mapped: list[Value] = []
            for i, item in enumerate(arr):
                if item is JS_HOLE:
                    mapped.append(JS_HOLE)
                    continue
                self._tick()
                mapped.append(self._call_function(callback, [item, i, arr]))
            return mapped
        if method == 'filter':
            filtered: list[Value] = []
            for i, item in enumerate(arr):
                if item is JS_HOLE:
                    continue
                self._tick()
                if to_boolean(self._call_function(callback, [item, i, arr])):
                    filtered.append(item)
            return filtered
        if method == 'find':
            for i, item in enumerate(arr):
                self._tick()
                if to_boolean(self._call_function(callback, [_element_read(item), i, arr])):
                    return _element_read(item)
            return None
        if method == 'findIndex':
            for i, item in enumerate(arr):
                self._tick()
                if to_boolean(self._call_function(callback, [_element_read(item), i, arr])):
                    return i
            return -1
        if method == 'forEach':
            for i, item in enumerate(arr):
                if item is JS_HOLE:
                    continue
                self._tick()
                self._call_function(callback, [item, i, arr])
            return None
        if method == 'reduce':
            if len(args) >= 2:
                acc: Value = args[1]
                start = 0
            else:
                start = 0
                while start < len(arr) and arr[start] is JS_HOLE:
                    start += 1
                if start == len(arr):
                    _js_throw('TypeError', 'Reduce of empty array with no initial value')
                acc = arr[start]
                start += 1
            for i in range(start, len(arr)):
                if arr[i] is JS_HOLE:
                    continue
                self._tick()
                acc = self._call_function(callback, [acc, arr[i], i, arr])
            return acc
        raise InterpreterError

    def _eval_inline_call(self, func, arguments: list) -> Value:
        args = [self._eval(a) for a in arguments]
        return self._call_function(func, args)

    def _mutates_captured_binding(self, func) -> bool:
        """
        Whether *func* assigns to a name that the calling environment binds but that *func* does not
        declare locally — a write through a closure into a captured outer variable. A nested call runs
        in an isolated child interpreter with only a snapshot of captured values and no write-back, so
        the mutation would be silently lost. Refusing to evaluate leaves the call in place for a real
        engine to run and keeps the fold sound rather than producing a wrong constant.
        """
        body = func.body
        if not isinstance(body, JsBlockStatement):
            return False
        local_names: set[str] = {p.name for p in func.params if isinstance(p, JsIdentifier)}
        if isinstance(func, JsFunctionDeclaration) and isinstance(func.id, JsIdentifier):
            local_names.add(func.id.name)
        for node in walk_scope(body):
            if isinstance(node, JsVariableDeclaration):
                for decl in node.declarations:
                    if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier):
                        local_names.add(decl.id.name)
            elif isinstance(node, JsFunctionDeclaration) and isinstance(node.id, JsIdentifier):
                local_names.add(node.id.name)
        for node in walk_scope(body):
            if isinstance(node, JsAssignmentExpression) and isinstance(node.left, JsIdentifier):
                name = node.left.name
            elif isinstance(node, JsUpdateExpression) and isinstance(node.argument, JsIdentifier):
                name = node.argument.name
            else:
                continue
            if name not in local_names and name in self._env:
                return True
        return False

    def _call_function(self, func: JsFunctionNode, args: list[Value]) -> Value:
        """
        The value a call to *func* answers, computed by a child interpreter. What a call answers at
        all is `execute`'s question and is refused there, so that the refusal covers the entry a
        caller outside this class reaches as well.
        """
        if self._depth >= self.max_recursion:
            raise InterpreterError
        if self._mutates_captured_binding(func):
            raise InterpreterError
        callee_closure = self._closure_env.get(id(func)) or {}
        child = JsInterpreter(
            max_iterations=max(1, self.max_iterations - self._iterations),
            max_string_len=self.max_string_len,
            max_array_len=self.max_array_len,
            max_recursion=self.max_recursion,
            effects=self._effects,
            model=self._model,
            anchor=self._anchor,
            tampering=self._tampering,
            closure=callee_closure,
            closure_env=self._closure_env,
            established=self._established,
            depth=self._depth + 1,
        )
        try:
            result = child.execute(func, args)
        finally:
            self._iterations += child._iterations
        return result

    def _eval_member(self, node: JsMemberExpression) -> Value:
        if isinstance(node.object, JsIdentifier) and node.object.name in STATIC_OBJECTS:
            raise InterpreterError
        obj = self._eval(node.object)
        if node.optional and (obj is None or obj is JS_NULL):
            return None
        key = self._member_key(node)
        if isinstance(obj, JsArguments):
            return self._read_arguments_element(node, obj, key)
        return self._get_property(obj, key)

    def _read_arguments_element(
        self, node: JsMemberExpression, arguments: JsArguments, key: str,
    ) -> Value:
        """
        The element or the element count the arguments object answers for *key*. Every other key
        refuses rather than guessing, for the same reason a method read off any other value does:
        the answer lives on the prototype chain, on a `callee` no value domain here holds, or at
        an index past the end the chain may supply, and none of those is this object's to decide.
        """
        if key == 'length':
            return len(arguments.values)
        index = canonical_array_index(key)
        if index is not None and 0 <= index < len(arguments.values):
            return arguments.values[index]
        raise IrreducibleExpression(node)

    def _eval_template(self, node: JsTemplateLiteral) -> Value:
        """
        A template denotes the text of its runs with what its holes evaluate to between them. A run
        that denotes nothing makes the whole literal denote nothing: the language refuses such a
        template rather than reading it, so there is no string to hand back and computing one would
        answer for a script no engine will run.

        A hole's value is converted to a string, so it asks the coercion guard first: a program
        that installed a conversion on the object the hole evaluates to decides what the template
        spells, and that is a program this interpreter refuses to answer for.
        """
        parts: list[str] = []
        for i, quasi in enumerate(node.quasis):
            if quasi.value is None:
                raise IrreducibleExpression(node)
            parts.append(quasi.value)
            if i < len(node.expressions):
                value = self._eval(node.expressions[i])
                self._require_uninterceptable(value)
                parts.append(to_string(value))
        result = ''.join(parts)
        if len(result) > self.max_string_len:
            raise InterpreterError
        return result

    def _eval_object(self, node: JsObjectExpression) -> Value:
        """
        Evaluate an object literal into a dict of its own data properties. A literal that installs a
        prototype through the plain `__proto__:` form has a member surface this dict cannot express —
        the installed object's properties are inherited, not owned — so it aborts interpretation rather
        than record `__proto__` as an ordinary key, which would invent an own property JavaScript does
        not create. `object_sets_prototype` decides which spellings do that; the computed form and a
        shorthand or method define an ordinary own property and evaluate normally.
        """
        if object_sets_prototype(node):
            raise InterpreterError
        result: dict[str, Value] = {}
        for prop in node.properties:
            if not isinstance(prop, JsProperty):
                raise InterpreterError
            if prop.kind != JsPropertyKind.INIT:
                raise InterpreterError
            key: str
            if prop.computed:
                key = to_string(self._eval(prop.key))
            elif isinstance(prop.key, JsIdentifier):
                key = prop.key.name
            elif isinstance(prop.key, JsStringLiteral):
                if prop.key.value is None:
                    raise InterpreterError
                key = prop.key.value
            elif isinstance(prop.key, JsNumericLiteral):
                key = to_string(prop.key.value)
            else:
                raise InterpreterError
            result[key] = self._eval(prop.value)
        return result

    def _member_key(self, node: JsMemberExpression) -> str:
        if node.computed:
            val = self._eval(node.property)
            self._require_uninterceptable(val)
            return to_string(val)
        if isinstance(node.property, JsIdentifier):
            return node.property.name
        raise InterpreterError

    def _get_property(self, obj: Value, key: str) -> Value:
        """
        Read property *key* off *obj*, as a property *read* rather than a call. A method name yields the
        method itself, which this interpreter has no value domain for — a JS function value has an
        observable identity, `name`, `length`, and source text — so reading one aborts interpretation
        instead of guessing. Crucially it must not *invoke* the method: `typeof 'abc'.charAt` is
        `'function'`, not the `typeof` of what `charAt()` would return.

        Which keys the value itself decides is `read_data_property`'s to say, so that an emulated
        execution and a fold read a property the same way by construction. What is left here is the
        half that needs this interpreter: a nullish receiver throws before any key is looked at, and
        a key the value does not own is `undefined` only while the prototype chain is intact.

        An index past the end of an array is such a key and not a value of its own. Writing
        `Array.prototype[5]` puts a value where the array has no slot, so a read of `a[5]` answers
        it, and both of the outcomes the value declines to answer therefore ask the same question:
        the one that names an index is not the shorter road to `undefined` it looks like.
        """
        if obj is None or obj is JS_NULL:
            _js_throw('TypeError', F"Cannot read properties of {to_string(obj)} (reading '{key}')")
        outcome, value = read_data_property(obj, key)
        if outcome is MemberRead.FOUND:
            return value
        if self._property_is_absent(obj, key):
            return None
        raise InterpreterError

    def _property_is_absent(self, obj: Value, key: str) -> bool:
        """
        Whether *key* provably does not exist anywhere on *obj*'s prototype chain, making a read of it
        `undefined`. `property_provably_absent` decides it, so that an emulated execution and a fold
        answer an inherited read the same way by construction; what is left here is naming the type
        this interpreter's value is of and handing over the effect model when there is one. Where
        the oracle has vouched for the anchor, the chain is asked its attributed writes alone
        (`property_absent_from_written_chain`) rather than refused on a reflective surface.
        """
        if self._builtins_intact():
            return property_absent_from_written_chain(self._effects, type(obj), key)
        return property_provably_absent(self._effects, type(obj), key)

    def _set_property(self, obj: Value, key: str, value: Value) -> None:
        """
        Store *value* at *key* on *obj*, or refuse when this interpreter cannot model what the store means.

        An array accepts only `length` and a canonical index. Refusing every *named* property on one is not
        merely a gap in coverage: it is what keeps a function that writes `constructor` or `__proto__` on an
        array unfoldable. Those two properties decide what `slice` and its neighbours return — the read goes
        through ArraySpeciesCreate — so a program that sets them can make an apparently fresh array be a
        shared object, or make the call throw by leaving a primitive there. `EffectModel` refuses such a
        receiver as well, and both refusals are wanted: a widening here that started modelling named array
        properties would need that reasoning restated, not silently dropped.

        Growing the array — storing past its end, or writing a larger `length` — happens in place, filling
        the positions the store skipped over with holes, because the array another name holds must grow
        with it: `var v = u; u[87] = 5` leaves `v.length` at 88, so a copy is not a modelling of that
        store. How far an index or a length may reach is capped, which bounds the memory one store may
        ask for; a store the cap refuses still refuses here.
        """
        if isinstance(obj, dict):
            if key == PROTO_KEY and key not in obj:
                # Writing `__proto__` runs the accessor Object.prototype carries, which installs a
                # prototype instead of creating an own property. An existing own `__proto__` entry
                # shadows that accessor, and then the write does land in the data slot.
                raise InterpreterError
            obj[key] = value
            return
        if isinstance(obj, list):
            if key in SEQUENCE_DATA_PROPERTIES:
                new_len = _to_array_length(value)
                if new_len > len(obj):
                    if new_len > self.max_array_len:
                        raise InterpreterError
                    obj.extend([JS_HOLE] * (new_len - len(obj)))
                    return
                del obj[new_len:]
                return
            index = canonical_array_index(key)
            if index is not None:
                if index >= self.max_array_len:
                    raise InterpreterError
                if index > len(obj):
                    obj.extend([JS_HOLE] * (index - len(obj)))
                if index == len(obj):
                    obj.append(value)
                else:
                    obj[index] = value
                return
        raise InterpreterError

    @staticmethod
    def _strict_equal(a: Value, b: Value) -> bool:
        return js_strict_equal(a, b)

    @staticmethod
    def _loose_equal(a: Value, b: Value) -> bool:
        """
        Replicate the ECMA-262 abstract-equality (`==`) algorithm. `null` and `undefined` are equal to
        each other and to nothing else; a boolean coerces to a number; two objects compare by
        reference, while an object compared with a primitive coerces to a primitive first; a number
        compared with a string compares by numeric value.
        """
        a_nullish = a is None or a is JS_NULL
        b_nullish = b is None or b is JS_NULL
        if a_nullish or b_nullish:
            return a_nullish and b_nullish
        if isinstance(a, bool):
            a = 1 if a else 0
        if isinstance(b, bool):
            b = 1 if b else 0
        a_object = isinstance(a, _OBJECT_VALUE_TYPES)
        b_object = isinstance(b, _OBJECT_VALUE_TYPES)
        if a_object and b_object:
            return js_strict_equal(a, b)
        if a_object:
            a = _to_primitive(a)
        elif b_object:
            b = _to_primitive(b)
        if isinstance(a, str) and isinstance(b, str):
            return a == b
        a_num = isinstance(a, (int, float))
        b_num = isinstance(b, (int, float))
        if a_num and b_num:
            return a == b
        if (a_num and isinstance(b, str)) or (isinstance(a, str) and b_num):
            return to_number(a) == to_number(b)
        return js_strict_equal(a, b)

    def eval_expression(self, expr) -> Value:
        return self._eval(expr)

Functions

def js_strict_equal(a, b)

Compare two interpreter values using JavaScript strict-equality (===) semantics. Unlike Python equality this does not conflate booleans with the numbers 1 and 0.

Expand source code Browse git
def js_strict_equal(a: Value, b: Value) -> bool:
    """
    Compare two interpreter values using JavaScript strict-equality (`===`) semantics. Unlike
    Python equality this does not conflate booleans with the numbers `1` and `0`.
    """
    if isinstance(a, bool) or isinstance(b, bool):
        return a is b
    if a is None or b is None:
        return a is None and b is None
    if isinstance(a, (int, float)) and isinstance(b, (int, float)):
        return a == b
    if type(a) is not type(b):
        return False
    if isinstance(a, str):
        return a == b
    return a is b
def is_runtime_name(name)

Return True if name is a known JavaScript runtime symbol — either a static object namespace (e.g. Math, String) or a global function registered in the builtin registry (e.g. parseInt, parseFloat).

Expand source code Browse git
def is_runtime_name(name: str) -> bool:
    """
    Return True if `name` is a known JavaScript runtime symbol — either a static object namespace
    (e.g. `Math`, `String`) or a global function registered in the builtin registry (e.g.
    `parseInt`, `parseFloat`).
    """
    return name in STATIC_OBJECTS or (None, name) in BUILTIN_REGISTRY
def names_runtime_builtin(node, model)

Whether node names a runtime symbol this interpreter models, read where nothing has bound the name. The spelling alone does not settle it: catch (parseInt) and function o(parseInt) both give the name a value of their own, and evaluating a call to it as the built-in then computes an answer the program never produces.

Expand source code Browse git
def names_runtime_builtin(node: JsIdentifier, model: SemanticModel) -> bool:
    """
    Whether *node* names a runtime symbol this interpreter models, read where nothing has bound the
    name. The spelling alone does not settle it: `catch (parseInt)` and `function o(parseInt)` both
    give the name a value of their own, and evaluating a call to it as the built-in then computes an
    answer the program never produces.
    """
    return is_runtime_name(node.name) and name_is_unbound(node, model)

Classes

class InterpreterError (*args, **kwargs)

Common base class for all non-exit exceptions.

Expand source code Browse git
class InterpreterError(Exception):
    pass

Ancestors

  • builtins.Exception
  • builtins.BaseException
class IrreducibleExpression (node)

Common base class for all non-exit exceptions.

Expand source code Browse git
class IrreducibleExpression(Exception):
    def __init__(self, node: Node):
        self.node = node

Ancestors

  • builtins.Exception
  • builtins.BaseException
class JsArguments (values)

The arguments object of the call being interpreted: the argument values that call was made with, held read-only. Deliberately not a list subclass — nothing that dispatches on a list may answer for it, no builtin registry entry exists for the type, and the object this models exposes only length and the indexed elements. It cannot escape into a folded value: a body whose arguments reads are not all element-wise never passes the preflight that admits one, so no bare arguments ever reaches the value domain.

Expand source code Browse git
class JsArguments:
    """
    The `arguments` object of the call being interpreted: the argument values that call was made
    with, held read-only. Deliberately not a `list` subclass — nothing that dispatches on a list
    may answer for it, no builtin registry entry exists for the type, and the object this models
    exposes only `length` and the indexed elements. It cannot escape into a folded value: a body
    whose `arguments` reads are not all element-wise never passes the preflight that admits one,
    so no bare `arguments` ever reaches the value domain.
    """
    __slots__ = ('values',)

    def __init__(self, values: list[Value]):
        self.values = values

Instance variables

var values
Expand source code Browse git
class JsArguments:
    """
    The `arguments` object of the call being interpreted: the argument values that call was made
    with, held read-only. Deliberately not a `list` subclass — nothing that dispatches on a list
    may answer for it, no builtin registry entry exists for the type, and the object this models
    exposes only `length` and the indexed elements. It cannot escape into a folded value: a body
    whose `arguments` reads are not all element-wise never passes the preflight that admits one,
    so no bare `arguments` ever reaches the value domain.
    """
    __slots__ = ('values',)

    def __init__(self, values: list[Value]):
        self.values = values
class JsInterpreter (*, max_iterations=100000, max_string_len=1000000, max_array_len=1000000, max_recursion=10, effects=None, model=None, anchor=None, tampering=None, closure=None, closure_env=None, established=None, depth=0)

Execute a JavaScript function body with concrete argument values. Returns a Python value or raises IrreducibleExpression when the return value cannot be reduced to a simple value.

Expand source code Browse git
class JsInterpreter:
    """
    Execute a JavaScript function body with concrete argument values. Returns a Python value or
    raises `IrreducibleExpression` when the return value cannot be reduced to a simple value.
    """

    def __init__(
        self, *,
        max_iterations: int = MAX_ITERATIONS,
        max_string_len: int = MAX_STRING_LEN,
        max_array_len: int = MAX_ARRAY_LEN,
        max_recursion: int = _MAX_RECURSION,
        effects: EffectModel | None = None,
        model: SemanticModel | None = None,
        anchor: Node | None = None,
        tampering: TamperingModel | None = None,
        closure: Mapping[str, Value] | None = None,
        closure_env: Mapping[int, Mapping[str, Value]] | None = None,
        established: Callable[[JsFunctionNode], bool] | None = None,
        depth: int = 0,
    ):
        self.max_iterations = max_iterations
        self.max_string_len = max_string_len
        self.max_array_len = max_array_len
        self.max_recursion = max_recursion
        self._effects = effects
        self._model = model if model is not None else effects and effects.model
        self._anchor = anchor
        self._tampering = tampering
        """
        The scope authority, held separately from *effects*. Whether a name still reaches the host is a
        question about bindings alone, so a caller that has only the semantic model — reflection builds
        one and would pay for an effect model it never consults — can answer it by passing *model*.
        """
        self._closure: Mapping[str, Value] = closure or {}
        self._closure_env: Mapping[int, Mapping[str, Value]] = closure_env or {}
        self._established = established
        self._env: dict[str, Value] = {}
        self._iterations = 0
        self._depth = depth

    def execute(
        self,
        func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression,
        arguments: list[Value],
    ) -> Value:
        """
        The value a call to *func* with *arguments* answers, which is the value its body returned
        only where a call answers that value at all. An `async` function answers a promise and a
        generator answers a generator object whose body has not run, so `wraps_return` ends the
        interpretation rather than reporting a value the call never had. `InterpreterError` is what
        ends it and not `IrreducibleExpression`: the latter hands the caller the body's return
        expression to splice into the call site, which is the same wrong answer written a second
        way.

        A body declaring a function anywhere but among its own statements ends it too. This
        interpreter has one environment per call and none per block, so it holds such a function
        from the entry of the body, where the language holds it in the block it is written in and,
        in sloppy code, in the enclosing scope only from the point the declaration runs. A read
        before that point is the difference, and it is one this answers with the function where a
        program answers `undefined`.
        """
        if wraps_return(func):
            raise InterpreterError
        if _declares_a_function_inside_a_block(func):
            raise InterpreterError
        params = func.params
        param_names: list[str] = []
        for p in params:
            if not isinstance(p, JsIdentifier):
                raise InterpreterError
            param_names.append(p.name)
        self._env = {}
        for i, name in enumerate(param_names):
            self._env[name] = arguments[i] if i < len(arguments) else None
        body = func.body
        for name in self._collect_hoisted_var_names(body):
            if name == 'arguments' and not isinstance(func, JsArrowFunctionExpression):
                # The call has already bound `arguments` to the argument object before any
                # statement runs, so an entry holding `undefined` would answer a read with a
                # value the call never made — including under a `var` that displaces it, where
                # leaving the name unbound refuses instead.
                continue
            self._env.setdefault(name, None)
        arguments_object = self._arguments_object(func, arguments)
        if arguments_object is not None:
            self._env['arguments'] = arguments_object
        for name, value in self._closure.items():
            if name not in self._env:
                self._env[name] = _deep_copy_value(value)
        self._iterations = 0
        if isinstance(body, JsBlockStatement):
            try:
                self._exec_statements(body.body)
            except _ReturnSignal as r:
                return r.value
            except _ReturnIrreducible as r:
                raise IrreducibleExpression(r.node)
            except IrreducibleExpression:
                raise InterpreterError
            except _ThrowSignal:
                if self._depth == 0:
                    raise InterpreterError
                raise
            return None
        if body is not None:
            try:
                return self._eval(body)
            except IrreducibleExpression:
                raise IrreducibleExpression(body)
            except _ThrowSignal:
                if self._depth == 0:
                    raise InterpreterError
                raise
        return None

    @staticmethod
    def _collect_hoisted_var_names(body) -> list[str]:
        """
        Collect the names of all `var` declarations in *body*, which JavaScript hoists to the top of
        the function scope (initialized to `undefined`). Nested function bodies are not traversed.
        Reading a hoisted name before its initializer must yield `undefined`, not an unresolved free
        identifier.
        """
        if not isinstance(body, JsBlockStatement):
            return []
        names: list[str] = []
        for node in walk_scope(body, include_root_body=True):
            if isinstance(node, JsVariableDeclaration) and node.kind == JsVarKind.VAR:
                for decl in node.declarations:
                    if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier):
                        names.append(decl.id.name)
        return names

    def _arguments_object(self, func, values: list[Value]) -> JsArguments | None:
        """
        The `arguments` object a call to *func* may be given, or `None` where the body may not be
        handed one. Whether the object a call models from its argument values can stand in for
        the name is `call_supplies_an_arguments_object`'s question, shared with the fold that
        asks which names a call supplies. Every failure leaves the name unbound, so the read
        declines through the `IrreducibleExpression` every unresolved name takes, and a nested
        function's own `arguments` declines the same way in its own environment.
        """
        model = self._model
        if model is None or not call_supplies_an_arguments_object(model, func):
            return None
        return JsArguments(values)

    def _exec_statements(self, stmts: list) -> None:
        for stmt in stmts:
            self._exec_statement(stmt)

    def _exec_statement(self, stmt) -> None:
        if isinstance(stmt, JsVariableDeclaration):
            self._exec_var_decl(stmt)
        elif isinstance(stmt, JsExpressionStatement):
            self._eval(stmt.expression)
        elif isinstance(stmt, JsIfStatement):
            self._exec_if(stmt)
        elif isinstance(stmt, JsSwitchStatement):
            self._exec_switch(stmt)
        elif isinstance(stmt, JsForStatement):
            self._exec_for(stmt)
        elif isinstance(stmt, JsWhileStatement):
            self._exec_while(stmt)
        elif isinstance(stmt, JsDoWhileStatement):
            self._exec_do_while(stmt)
        elif isinstance(stmt, JsForInStatement):
            self._exec_for_in(stmt)
        elif isinstance(stmt, JsForOfStatement):
            self._exec_for_of(stmt)
        elif isinstance(stmt, JsReturnStatement):
            if stmt.argument is None:
                raise _ReturnSignal(None)
            try:
                value = self._eval(stmt.argument)
            except IrreducibleExpression:
                raise _ReturnIrreducible(stmt.argument)
            raise _ReturnSignal(value)
        elif isinstance(stmt, JsBreakStatement):
            raise _BreakSignal
        elif isinstance(stmt, JsContinueStatement):
            raise _ContinueSignal
        elif isinstance(stmt, JsBlockStatement):
            self._exec_statements(stmt.body)
        elif isinstance(stmt, JsTryStatement):
            self._exec_try(stmt)
        elif isinstance(stmt, JsThrowStatement):
            value = self._eval(stmt.argument) if stmt.argument else None
            raise _ThrowSignal(value)
        elif isinstance(stmt, JsFunctionDeclaration):
            if isinstance(stmt.id, JsIdentifier):
                self._env[stmt.id.name] = stmt
        elif isinstance(stmt, JsEmptyStatement):
            return
        else:
            raise InterpreterError

    def _exec_var_decl(self, node: JsVariableDeclaration) -> None:
        for decl in node.declarations:
            if not isinstance(decl, JsVariableDeclarator):
                raise InterpreterError
            if not isinstance(decl.id, JsIdentifier):
                raise InterpreterError
            name = decl.id.name
            if decl.init is not None:
                self._env[name] = self._eval(decl.init)
            elif node.kind == JsVarKind.VAR:
                self._env.setdefault(name, None)
            else:
                self._env[name] = None

    def _exec_if(self, node: JsIfStatement) -> None:
        if to_boolean(self._eval(node.test)):
            if node.consequent:
                self._exec_statement(node.consequent)
        elif node.alternate:
            self._exec_statement(node.alternate)

    def _exec_switch(self, node: JsSwitchStatement) -> None:
        """
        A switch runs the clause whose test the discriminant matches, and where none matches, the
        default clause. The default is chosen only after every test has been evaluated, so a clause
        written after it is still asked. Writing the default in the middle of a dispatcher is a way
        to make the clause order look like the run order when it is not, and reading the default the
        moment it is reached answers with the wrong branch for every discriminant a later clause
        would have claimed.

        Once a clause is chosen, execution enters it and falls through the clauses behind it, which
        is why the run is a walk from the chosen index rather than of the clause alone. Tests behind
        the match are not evaluated: a match ends the search, and an expression that would throw is
        never reached.

        A second default clause is an early error, so a switch carrying one is not a program and is
        refused rather than interpreted as though the first of them governed.
        """
        cases = [case for case in node.cases if isinstance(case, JsSwitchCase)]
        if len(cases) != len(node.cases):
            raise InterpreterError
        defaults = [index for index, case in enumerate(cases) if case.test is None]
        if len(defaults) > 1:
            raise InterpreterError
        discriminant = self._eval(node.discriminant)
        chosen = None
        for index, case in enumerate(cases):
            if case.test is None:
                continue
            if self._strict_equal(discriminant, self._eval(case.test)):
                chosen = index
                break
        if chosen is None:
            if not defaults:
                return
            chosen = defaults[0]
        try:
            for case in cases[chosen:]:
                self._exec_statements(case.body)
        except _BreakSignal:
            return

    def _exec_loop_body(self, body) -> bool:
        if not body:
            return False
        try:
            self._exec_statement(body)
        except _BreakSignal:
            return True
        except _ContinueSignal:
            pass
        return False

    def _exec_for(self, node: JsForStatement) -> None:
        if node.init:
            if isinstance(node.init, JsVariableDeclaration):
                self._exec_var_decl(node.init)
            else:
                self._eval(node.init)
        while True:
            self._tick()
            if node.test and not to_boolean(self._eval(node.test)):
                break
            if self._exec_loop_body(node.body):
                break
            if node.update:
                self._eval(node.update)

    def _exec_while(self, node: JsWhileStatement) -> None:
        while True:
            self._tick()
            if not to_boolean(self._eval(node.test)):
                break
            if self._exec_loop_body(node.body):
                break

    def _exec_do_while(self, node: JsDoWhileStatement) -> None:
        while True:
            self._tick()
            if self._exec_loop_body(node.body):
                break
            if not to_boolean(self._eval(node.test)):
                break

    def _exec_for_in(self, node: JsForInStatement) -> None:
        """
        Walk the enumerable names of the receiver, which is every name it owns and every enumerable
        one its prototype chain holds.

        The chain contributes none of its own while the program leaves it alone: measured across
        sixteen receiver kinds, every name the language installs on a prototype — and every method a
        class body writes onto one — is non-enumerable. So the own names are the whole answer
        exactly while the chain is intact, and where it is not, the walk is refused rather than
        answered a name short. Refusing one walk is what that costs, which is why this asks
        `read_chain_intact` rather than the arm that overlooks a reflective surface.

        The question asked is about the whole chain rather than about one key, which over-refuses
        where an own name shadows an added inherited one: `Object.prototype.z = 9` over a receiver
        holding its own `z` walks `z` once either way. That costs a fold and never an answer.
        """
        right = self._eval(node.right)
        if right is None or right is JS_NULL:
            return
        if not isinstance(right, (dict, list)):
            raise InterpreterError
        if not self._chain_is_intact(type(right)):
            raise InterpreterError
        keys: list
        if isinstance(right, dict):
            keys = own_property_keys(right)
        else:
            keys = [str(i) for i, item in enumerate(right) if item is not JS_HOLE]
        var_name = self._get_loop_var(node.left)
        for key in keys:
            self._tick()
            self._env[var_name] = key
            if self._exec_loop_body(node.body):
                break

    def _exec_for_of(self, node: JsForOfStatement) -> None:
        """
        Walk the values the receiver's iterator yields, which is the iterator a list's or a
        string's prototype supplies — so the walk asks the same chain question a for-in walk does
        before it yields anything: a program that patched `Array.prototype[Symbol.iterator]` makes
        the real loop answer something other than the elements, and an interpretation that walks
        them regardless is answering for it.
        """
        right = self._eval(node.right)
        if right is None or right is JS_NULL:
            _js_throw('TypeError', F'{to_string(right)} is not iterable')
        if not isinstance(right, (list, str)):
            raise InterpreterError
        if not self._chain_is_intact(type(right)):
            raise InterpreterError
        if isinstance(right, list):
            # An array's iterator yields a hole as `undefined` — it asks whether the array has a
            # property at the index and takes the value on either branch — so a hole is not a skip.
            items = [None if item is JS_HOLE else item for item in right]
        else:
            items = code_points(right)
        var_name = self._get_loop_var(node.left)
        for item in items:
            self._tick()
            self._env[var_name] = item
            if self._exec_loop_body(node.body):
                break

    def _exec_try(self, node: JsTryStatement) -> None:
        thrown: _ThrowSignal | None = None
        propagate: Exception | None = None
        try:
            if node.block:
                self._exec_statements(node.block.body)
        except _ThrowSignal as exc:
            thrown = exc
        except (
            IrreducibleExpression,
            InterpreterError,
            _ReturnSignal,
            _BreakSignal,
            _ContinueSignal,
            _ReturnIrreducible,
        ) as exc:
            propagate = exc
        if propagate is not None:
            if node.finalizer:
                self._exec_statements(node.finalizer.body)
            raise propagate
        if thrown is not None:
            if node.handler and node.handler.body:
                param_name: str | None = None
                had_param: bool = False
                prev_param: Value = None
                if isinstance(node.handler.param, JsIdentifier):
                    param_name = node.handler.param.name
                    had_param = param_name in self._env
                    prev_param = self._env.get(param_name)
                    self._env[param_name] = thrown.value
                handler_outcome: Exception | None = None
                try:
                    self._exec_statements(node.handler.body.body)
                except (
                    _ThrowSignal,
                    IrreducibleExpression,
                    InterpreterError,
                    _ReturnSignal,
                    _BreakSignal,
                    _ContinueSignal,
                    _ReturnIrreducible,
                ) as exc:
                    handler_outcome = exc
                finally:
                    if param_name is not None:
                        if had_param:
                            self._env[param_name] = prev_param
                        else:
                            self._env.pop(param_name, None)
                if node.finalizer:
                    self._exec_statements(node.finalizer.body)
                if handler_outcome is not None:
                    raise handler_outcome
                return
            if node.finalizer:
                self._exec_statements(node.finalizer.body)
            raise thrown
        if node.finalizer:
            self._exec_statements(node.finalizer.body)

    def _get_loop_var(self, left) -> str:
        if isinstance(left, JsVariableDeclaration):
            if len(left.declarations) == 1:
                decl = left.declarations[0]
                if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier):
                    return decl.id.name
        if isinstance(left, JsIdentifier):
            return left.name
        raise InterpreterError

    def _tick(self) -> None:
        self._iterations += 1
        if self._iterations > self.max_iterations:
            raise InterpreterError

    def _eval(self, expr) -> Value:
        if expr is None:
            return None
        if isinstance(expr, JsStringLiteral):
            if not expr.terminated or expr.value is None:
                raise IrreducibleExpression(expr)
            return expr.value
        if isinstance(expr, JsNumericLiteral):
            return expr.value
        if isinstance(expr, JsBooleanLiteral):
            return expr.value
        if isinstance(expr, JsNullLiteral):
            return JS_NULL
        if isinstance(expr, JsIdentifier):
            return self._eval_identifier(expr)
        if isinstance(expr, JsBinaryExpression):
            return self._eval_binary(expr)
        if isinstance(expr, JsUnaryExpression):
            return self._eval_unary(expr)
        if isinstance(expr, JsUpdateExpression):
            return self._eval_update(expr)
        if isinstance(expr, JsLogicalExpression):
            return self._eval_logical(expr)
        if isinstance(expr, JsAssignmentExpression):
            return self._eval_assignment(expr)
        if isinstance(expr, JsCallExpression):
            return self._eval_call(expr)
        if isinstance(expr, JsMemberExpression):
            return self._eval_member(expr)
        if isinstance(expr, JsConditionalExpression):
            test = self._eval(expr.test)
            return self._eval(expr.consequent) if to_boolean(test) else self._eval(expr.alternate)
        if isinstance(expr, JsArrayExpression):
            return self._eval_array(expr)
        if isinstance(expr, JsSequenceExpression):
            result: Value = None
            for e in expr.expressions:
                result = self._eval(e)
            return result
        if isinstance(expr, JsTemplateLiteral):
            return self._eval_template(expr)
        if isinstance(expr, JsObjectExpression):
            return self._eval_object(expr)
        if isinstance(expr, (JsFunctionExpression, JsArrowFunctionExpression)):
            return expr
        if isinstance(expr, JsParenthesizedExpression):
            return self._eval(expr.expression)
        raise InterpreterError

    def _resolve_function_node(self, node: JsIdentifier) -> JsFunctionNode | None:
        """
        The single function *node* names, or `None`. Delegates to `EffectModel.unambiguous_function`: a
        function declaration or a bare-assignment (`var f; f = function(){}`) resolves, but a name
        reassigned away from a value it already held stays unresolved. When an *established* predicate was
        supplied, a resolved function is returned only if it is in place before the call site folding began
        — a bare-assignment or initializer function reached before its establishing node has run reads a
        temporal dead zone or a hoisted `undefined` at runtime, so resolving it here would replace that
        throw with a value. A hoisted function declaration is always established and passes unconditionally.
        """
        effects = self._effects
        if effects is None:
            return None
        func = effects.unambiguous_function(effects.model.resolve(node))
        if func is None:
            return None
        if self._established is not None and not self._established(func):
            return None
        return func

    def _names_a_runtime_builtin(self, node: JsIdentifier) -> bool:
        """
        Whether *node* still reaches the host built-in of that name. Without a model this answers
        `False` and the call becomes irreducible, for the same reason `_names_a_global_value` does.
        """
        model = self._model
        return model is not None and names_runtime_builtin(node, model)

    def _names_a_static_object(self, node: JsIdentifier) -> bool:
        """
        Whether *node*, standing in receiver position ahead of a method the registry knows, still
        reaches the host object of that name. It is the question `_names_a_runtime_builtin` asks
        for a bare callee, and the static branch has to ask it too: `var Number = {parseInt: ...}`
        gives the name a value of its own, and answering the call from the registry then computes a
        number the program never produces. Without a model this answers `True` rather than `False`,
        matching `_callee_is_intact`: an interpreter used on one expression in isolation cannot see
        a scope, so the assumption is the caller's.
        """
        if node.name in self._env:
            return False
        model = self._model
        return model is None or names_runtime_builtin(node, model)

    def _names_a_global_value(self, node: JsIdentifier) -> bool:
        """
        Whether *node* is `undefined`, `NaN` or `Infinity` still denoting the global value, which only
        the semantic model can say. Without one this answers `False` and the name becomes irreducible:
        an interpreter that cannot see the scope cannot tell the global from a binding an enclosing
        function supplies, and guessing the global is how a call to `function f(NaN) { return NaN + 1 }`
        folded to `NaN` instead of to its argument.
        """
        model = self._model
        return model is not None and names_global_value(node, model)

    def _resolves_to_a_binding(self, node: JsIdentifier) -> bool:
        """
        Whether *node* resolves to any binding at all. A name the model binds but `_env` does not hold
        has a value this interpreter does not know — it may belong to an enclosing scope that is not in
        the closure, or to a `let` whose declarator has not run, which is a read in its temporal dead
        zone that throws — so the well-known-global `typeof` fallback must not answer for it.
        """
        model = self._model
        return model is not None and model.resolve(node) is not None

    def _eval_identifier(self, node: JsIdentifier) -> Value:
        name = node.name
        if name in self._env:
            return self._env[name]
        func = self._resolve_function_node(node)
        if func is not None:
            return func
        if self._names_a_global_value(node):
            return GLOBAL_VALUE_NAMES[name]
        raise IrreducibleExpression(node)

    def _js_add(self, left: Value, right: Value) -> Value:
        """
        Replicate the JavaScript `+` operator: apply ToPrimitive to both operands, then concatenate
        as strings if either is a string, otherwise add numerically.
        """
        left = _to_primitive(left)
        right = _to_primitive(right)
        if isinstance(left, str) or isinstance(right, str):
            result = to_string(left) + to_string(right)
            if len(result) > self.max_string_len:
                raise InterpreterError
            return result
        return to_number(left) + to_number(right)

    def _eval_binary(self, node: JsBinaryExpression) -> Value:
        return self._apply_binary(node.operator, self._eval(node.left), self._eval(node.right))

    def _apply_binary(self, op: str, left: Value, right: Value) -> Value:
        """
        Apply the binary operator *op* to two already-evaluated values — the one place this interpreter
        decides what an operator means. A binary expression and the arithmetic step of a compound
        assignment ask the same question, so they must ask it here.

        `eval_binary_op` handles the numeric operators alone, which is why the string cases resolve first:
        `+` needs ToPrimitive on both operands and may yield a concatenation, and a relational operator
        compares two strings lexicographically rather than numerically. Everything after that is a number.

        Every operator but strict equality coerces its operands — `+` and the relational operators to
        primitives, the numeric ones to numbers, `==` to whichever the other side demands, and `in` its
        left side to a key — so every one of them asks the coercion guard first, the `in` operator of
        its key alone: an object whose conversion a program installed decides what all of these answer,
        and strict equality is the one operator that runs nothing.
        """
        if op not in ('===', '!=='):
            self._require_uninterceptable(left)
            if op != 'in':
                self._require_uninterceptable(right)
        if op == '===':
            return self._strict_equal(left, right)
        if op == '!==':
            return not self._strict_equal(left, right)
        if op == '==':
            return self._loose_equal(left, right)
        if op == '!=':
            return not self._loose_equal(left, right)
        if op == '+':
            return self._js_add(left, right)
        if op == 'in':
            return self._eval_in(left, right)
        if op == 'instanceof':
            raise InterpreterError
        if op in RELATIONAL_OPS:
            primitive_left = _to_primitive(left)
            primitive_right = _to_primitive(right)
            if isinstance(primitive_left, str) and isinstance(primitive_right, str):
                return RELATIONAL_OPS[op](primitive_left, primitive_right)
        result = eval_binary_op(op, to_number(left), to_number(right))
        if result is None:
            raise InterpreterError
        return result

    def _eval_in(self, left: Value, right: Value) -> bool:
        """
        Whether *right* has a property named by *left*, which the operator answers for the whole
        prototype chain and not for the own properties alone. The three ways that can go are the
        three this file has words for: the value owns a slot there, the chain supplies the name, or
        neither does and it is `property_provably_absent`'s to say whether that settles anything.

        A name it will not decide without the chain is one this operator cannot answer `false` for
        either, so the interpretation stops rather than reporting what the own properties happen to
        show. `Object.prototype.z = 9` makes `'z' in {}` true, and reading a refusal as an absence
        is what a program written to be read wrongly is looking for.
        """
        if not isinstance(right, (dict, list)):
            raise InterpreterError
        key = to_string(left)
        if read_data_property(right, key)[0] is MemberRead.FOUND:
            return True
        chain_intact = property_is_inherited_from_an_intact_chain(self._effects, type(right), key)
        if not chain_intact and self._builtins_intact():
            chain_intact = property_is_inherited_from_an_unwritten_chain(
                self._effects, type(right), key)
        if chain_intact:
            return True
        if self._property_is_absent(right, key):
            return False
        raise InterpreterError

    def _eval_array(self, expr: JsArrayExpression) -> list[Value]:
        """
        The list an array literal builds, refusing one that would hold a hole. An elision is not an
        element whose value is `undefined`: `[1, , 3][1]` reads `Array.prototype[1]` where
        `[1, undefined, 3][1]` reads the element, so the two answer differently wherever that
        prototype is written, and `indexOf` and the callback methods skip the first while visiting
        the second.

        A hole is representable in this domain — a store growing an array past its end builds one —
        but a literal holding one has no fold to go to: `value_to_node` refuses a holey list, so
        building it here would answer no call. The syntax model spells an elision as no element at
        all, which is the shape this refusal leaves standing.
        """
        if any(element is None for element in expr.elements):
            raise InterpreterError
        return [self._eval(element) for element in expr.elements]

    def _eval_unary(self, node: JsUnaryExpression) -> Value:
        """
        Apply a unary operator through `UNARY_OPS`, which holds the ones that are functions of the
        operand's value. `typeof` on a bare name is answered before the operand is evaluated because it
        is the one expression that reads a name without requiring it to exist: `typeof missing` is
        `'undefined'` where evaluating `missing` is a `ReferenceError`.
        """
        op = node.operator
        operand = node.operand
        if op == 'typeof' and isinstance(operand, JsIdentifier):
            name = operand.name
            if name in self._env:
                return js_typeof(self._env[name])
            if self._resolve_function_node(operand) is not None:
                return 'function'
            if self._resolves_to_a_binding(operand):
                raise IrreducibleExpression(node)
            environment = self._model.environment if self._model is not None else HostEnvironment.universal
            result = typeof_of_global(name, environment)
            if result is None:
                raise IrreducibleExpression(node)
            return result
        apply = UNARY_OPS.get(op)
        if apply is None:
            raise InterpreterError
        value = self._eval(operand)
        if op in ('+', '-', '~'):
            self._require_uninterceptable(value)
        return apply(value)

    def _eval_update(self, node: JsUpdateExpression) -> Value:
        """
        Apply `++` or `--`. The target may be a plain identifier or a member, and both must be supported here:
        `a[i]++` is the same operation as `a[i] += 1` on a value the language has already coerced to a number,
        so refusing one while folding the other would make the two disagree.
        """
        delta = {'++': 1, '--': -1}.get(node.operator)
        if delta is None:
            raise InterpreterError
        target = node.argument
        if isinstance(target, JsIdentifier):
            name = target.name
            if name not in self._env:
                raise InterpreterError
            self._require_uninterceptable(self._env[name])
            current = to_number(self._env[name])
            self._env[name] = current + delta
        elif isinstance(target, JsMemberExpression):
            obj = self._eval(target.object)
            key = self._member_key(target)
            current = self._get_property(obj, key)
            self._require_uninterceptable(current)
            current = to_number(current)
            self._set_property(obj, key, current + delta)
        else:
            raise InterpreterError
        return current + delta if node.prefix else current

    def _short_circuits(self, operator: str, current: Value) -> bool:
        """
        Whether a logical assignment (`&&=`, `||=`, `??=`) is already decided by *current*, so neither its
        right operand nor the store runs. The three truthiness rules are the ones `_eval_logical` applies to
        the expression forms, read in the other direction.
        """
        if operator == '&&=':
            return not to_boolean(current)
        if operator == '||=':
            return to_boolean(current)
        if operator == '??=':
            return current is not None and current is not JS_NULL
        raise InterpreterError

    def _compound_value(self, operator: str, current: Value, node: JsAssignmentExpression) -> Value:
        """
        The value a compound assignment stores: its right operand evaluated and combined with *current* under
        the operator the assignment names. *current* is read by the caller before this runs, because
        JavaScript reads the target before evaluating the right operand — `v += (v = 10)` on `v = 5` is 15.
        """
        return self._apply_binary(operator[:-1], current, self._eval(node.right))

    def _eval_logical(self, node: JsLogicalExpression) -> Value:
        left = self._eval(node.left)
        if node.operator == '&&':
            return self._eval(node.right) if to_boolean(left) else left
        if node.operator == '||':
            return left if to_boolean(left) else self._eval(node.right)
        if node.operator == '??':
            if left is None or left is JS_NULL:
                return self._eval(node.right)
            return left
        raise InterpreterError

    def _eval_assignment(self, node: JsAssignmentExpression) -> Value:
        if isinstance(node.left, JsMemberExpression):
            return self._eval_member_assignment(node)
        if not isinstance(node.left, JsIdentifier):
            raise InterpreterError
        name = node.left.name
        op = node.operator
        if op == '=':
            value = self._eval(node.right)
            self._env[name] = value
            return value
        current = self._eval(node.left)
        if op in LOGICAL_ASSIGNMENT_OPS:
            if self._short_circuits(op, current):
                return current
            self._env[name] = self._eval(node.right)
            return self._env[name]
        self._env[name] = self._compound_value(op, current, node)
        return self._env[name]

    def _eval_member_assignment(self, node: JsAssignmentExpression) -> Value:
        """
        Assign to a member target. The object and key expressions are evaluated exactly once, before the
        operator is considered, so `a[i++] += 1` advances `i` once — and a logical assignment that
        short-circuits performs no store at all.

        Skipping that store is what JavaScript specifies rather than an optimization: the language makes it
        observable through a setter or a frozen object, neither of which this interpreter's value domain has.
        Within this domain a store of the value just read is idempotent, so no program folded here can tell
        the difference — the rule is kept because the domain is a subset of the language's, not because a test
        can witness it.
        """
        member = node.left
        if not isinstance(member, JsMemberExpression):
            raise InterpreterError
        obj = self._eval(member.object)
        key = self._member_key(member)
        if node.operator == '=':
            value = self._eval(node.right)
        elif node.operator in LOGICAL_ASSIGNMENT_OPS:
            old = self._get_property(obj, key)
            if self._short_circuits(node.operator, old):
                return old
            value = self._eval(node.right)
        else:
            value = self._compound_value(node.operator, self._get_property(obj, key), node)
        self._set_property(obj, key, value)
        return value

    def _eval_call(self, node: JsCallExpression) -> Value:
        if isinstance(node.callee, JsMemberExpression):
            return self._eval_method_call(node)
        if isinstance(node.callee, JsIdentifier):
            return self._eval_function_call(node)
        if isinstance(node.callee, (JsFunctionExpression, JsArrowFunctionExpression)):
            return self._eval_inline_call(node.callee, node.arguments)
        raise InterpreterError

    def _eval_function_call(self, node: JsCallExpression) -> Value:
        """
        Call the function a bare name denotes. The registry is consulted last, and only for a name the
        scope has left alone: it records what the *host* provides, which is what a name reaches only
        when nothing nearer has claimed it. Asking it first is how a call to a parameter or a `catch`
        binding named `parseInt` was answered by the real `parseInt`.
        """
        callee = node.callee
        if not isinstance(callee, JsIdentifier):
            raise InterpreterError
        name = callee.name
        args = [self._eval(a) for a in node.arguments]
        if name in self._env:
            target = self._env[name]
            if isinstance(target, (JsFunctionDeclaration, JsFunctionExpression, JsArrowFunctionExpression)):
                return self._call_function(target, args)
            if node.optional and (target is None or target is JS_NULL):
                return None
            _js_throw('TypeError', F'{name} is not a function')
        func = self._resolve_function_node(callee)
        if func is not None:
            return self._call_function(func, args)
        builtin = BUILTIN_REGISTRY.get((None, name))
        if builtin is not None and self._names_a_runtime_builtin(callee):
            if name in _COERCING_GLOBALS and not all(
                self._coerces_uninterceptably(arg) for arg in args
            ):
                raise InterpreterError
            return builtin(args)
        raise InterpreterError

    def _eval_method_call(self, node: JsCallExpression) -> Value:
        member = node.callee
        if not isinstance(member, JsMemberExpression):
            raise InterpreterError
        if (
            isinstance(member.object, JsIdentifier)
            and member.object.name in STATIC_OBJECTS
        ):
            static_name = member.object.name
            method_name = self._member_key(member)
            args = [self._eval(a) for a in node.arguments]
            builtin = BUILTIN_REGISTRY.get((static_name, method_name))
            if builtin is not None:
                if not self._names_a_static_object(member.object):
                    raise InterpreterError
                if not self._callee_is_intact(node):
                    raise InterpreterError
                return builtin(args)
            raise InterpreterError
        obj = self._eval(member.object)
        if obj is None or obj is JS_NULL:
            if member.optional:
                return None
            _js_throw('TypeError', F"Cannot read properties of {to_string(obj)} (reading a method)")
        method_name = self._member_key(member)
        args = [self._eval(a) for a in node.arguments]
        if isinstance(obj, (str, list)) and method_name in SEQUENCE_DATA_PROPERTIES:
            # A data property is not callable. The arguments are evaluated first, as JS does, so a side
            # effect or a throw inside one is not lost to this TypeError.
            _js_throw('TypeError', F'{to_string(obj)}.{method_name} is not a function')
        obj_type = type(obj)
        builtin = BUILTIN_REGISTRY.get((obj_type, method_name))
        if builtin is None and obj_type is not list and isinstance(obj, list):
            builtin = BUILTIN_REGISTRY.get((list, method_name))
        if builtin is not None:
            if not self._prototype_is_intact(obj_type):
                raise InterpreterError
            result = builtin(obj, args)
            if isinstance(obj, JsBuffer) and isinstance(result, list) and not isinstance(result, JsBuffer):
                result = JsBuffer(result)
            return result
        if isinstance(obj, list) and method_name in _ARRAY_HOF_METHODS:
            if not self._prototype_is_intact(obj_type):
                raise InterpreterError
            result = self._eval_array_hof(obj, method_name, args)
            if isinstance(obj, JsBuffer) and method_name in _BUFFER_PRESERVING_HOFS:
                if isinstance(result, list) and not isinstance(result, JsBuffer):
                    result = JsBuffer(result)
            return result
        if isinstance(obj, (JsFunctionExpression, JsArrowFunctionExpression)):
            if method_name in ('call', 'apply') and not self._prototype_is_intact(obj_type):
                raise InterpreterError
            if method_name == 'call':
                return self._call_function(obj, args[1:] if len(args) > 1 else [])
            if method_name == 'apply':
                actual_args = args[1] if len(args) > 1 and isinstance(args[1], list) else []
                return self._call_function(obj, actual_args)
        raise InterpreterError

    def _builtins_intact(self) -> bool:
        """
        Whether the tampering oracle vouches that no builtin was replaced before the anchor's
        execution. An interpreter constructed with no anchor, or with no oracle beside it, answers
        `False`: the trust questions below then fall through to their no-anchor arms, which refuse
        wherever a reflective surface could have done the replacing.
        """
        return (
            self._tampering is not None
            and self._anchor is not None
            and self._tampering.builtins_intact_at(self._anchor)
        )

    def _callee_is_intact(self, node: JsCallExpression) -> bool:
        """
        Whether the built-in *node* names is still that built-in. Evaluating a call by looking its name up
        in the registry assumes the program has not replaced it, and an obfuscated file may well have.
        Without an effect model there is nothing to consult, and the interpreter is then used on a single
        expression in isolation rather than over a whole program, so the assumption is the caller's.

        Where the oracle has vouched for the anchor, the reflection terms of the name question are
        answered already, so the attributed writes alone decide — `call_names_an_unwritten_builtin`.
        """
        effects = self._effects
        if effects is None:
            return True
        if self._builtins_intact():
            return effects.call_names_an_unwritten_builtin(node)
        return effects.call_is_foldable(node)

    def _require_uninterceptable(self, value: Value) -> None:
        """
        Refuse where converting *value* to the primitive an operator, a key, or a template hole needs
        could run a conversion the program installed. `coerces_uninterceptably` decides, against the
        effect model this interpreter holds — or its surface-free half, where the oracle has vouched
        for the anchor; without an effect model there is no chain to vouch for an object,
        so every object conversion refuses rather than assuming a file that replaced none.
        """
        if not self._coerces_uninterceptably(value):
            raise InterpreterError

    def _coerces_uninterceptably(self, value: Value) -> bool:
        """
        The coercion guard the operator, key, and template arms ask: `coerces_uninterceptably`, or
        its written-chain half where the oracle has vouched for the anchor.
        """
        if self._builtins_intact():
            return coerces_uninterceptably_from_written_chain(self._effects, value)
        return coerces_uninterceptably(self._effects, value)

    def _prototype_is_intact(self, value_type: type) -> bool:
        """
        Whether the prototype that supplies *value_type*'s methods is unmodified, so dispatching a method
        on a receiver of that type by name still means what the language says. This is the same question
        `_callee_is_intact` asks, for a receiver that names no global; where the oracle has vouched for
        the anchor, the attributed writes alone decide.
        """
        effects = self._effects
        if effects is None:
            return True
        if self._builtins_intact():
            return effects.prototype_name_unwritten(value_type)
        return effects.trusted_prototype(value_type)

    def _chain_is_intact(self, value_type: type) -> bool:
        """
        Whether every prototype a plain property read on a value of *value_type* consults is
        unmodified — `EffectModel.read_chain_intact`, or its surface-free half where the oracle has
        vouched for the anchor. The question the for-in and for-of walks and the inherited-read
        arms ask; without an effect model there is no chain to consult and the caller owns the
        assumption.
        """
        effects = self._effects
        if effects is None:
            return True
        if self._builtins_intact():
            return effects.chain_roots_unwritten(value_type)
        return effects.read_chain_intact(value_type)

    def _callback_is_contained(self, callback: JsFunctionNode) -> bool:
        """
        Whether running *callback* for its return values alone loses nothing. A higher-order method is
        evaluated here for the value it produces, so a callback that also writes a binding outside itself
        has an effect the resulting literal cannot carry: `[1,2].map(function (x) { n += x; return x; })`
        yields the right array while leaving `n` unchanged.

        It asks `is_effect_free_when_discarded` rather than `is_pure`, which is stricter than this
        position needs: a mutation the callback confines to a fresh local it returns is tolerated, while
        a throw the summary records in `throws` is not. Purity alone is not sufficient either — a write
        to a *script-scope* `var` reports `writes_captured=False`, slipping through every purity flag —
        so `written_bindings`, which records outer bindings by identity, is checked too.
        """
        effects = self._effects
        if effects is None:
            return True
        summary = effects.summary_of(callback)
        return summary.is_effect_free_when_discarded and not summary.written_bindings

    def _eval_array_hof(self, arr: list, method: str, args: list[Value]) -> Value:
        if not args:
            raise InterpreterError
        callback = args[0]
        if not isinstance(
            callback,
            (JsFunctionDeclaration, JsFunctionExpression, JsArrowFunctionExpression)
        ):
            raise InterpreterError
        if not self._callback_is_contained(callback):
            raise InterpreterError
        # Every higher-order method asks the array whether it has a property at each index
        # before it calls, so a hole is a position no callback runs at and no filter keeps.
        # `find` and `findIndex` are the exception: they read the element directly and visit a
        # hole as the `undefined` the read yields.
        if method == 'every':
            for i, item in enumerate(arr):
                if item is JS_HOLE:
                    continue
                self._tick()
                if not to_boolean(self._call_function(callback, [item, i, arr])):
                    return False
            return True
        if method == 'some':
            for i, item in enumerate(arr):
                if item is JS_HOLE:
                    continue
                self._tick()
                if to_boolean(self._call_function(callback, [item, i, arr])):
                    return True
            return False
        if method == 'map':
            mapped: list[Value] = []
            for i, item in enumerate(arr):
                if item is JS_HOLE:
                    mapped.append(JS_HOLE)
                    continue
                self._tick()
                mapped.append(self._call_function(callback, [item, i, arr]))
            return mapped
        if method == 'filter':
            filtered: list[Value] = []
            for i, item in enumerate(arr):
                if item is JS_HOLE:
                    continue
                self._tick()
                if to_boolean(self._call_function(callback, [item, i, arr])):
                    filtered.append(item)
            return filtered
        if method == 'find':
            for i, item in enumerate(arr):
                self._tick()
                if to_boolean(self._call_function(callback, [_element_read(item), i, arr])):
                    return _element_read(item)
            return None
        if method == 'findIndex':
            for i, item in enumerate(arr):
                self._tick()
                if to_boolean(self._call_function(callback, [_element_read(item), i, arr])):
                    return i
            return -1
        if method == 'forEach':
            for i, item in enumerate(arr):
                if item is JS_HOLE:
                    continue
                self._tick()
                self._call_function(callback, [item, i, arr])
            return None
        if method == 'reduce':
            if len(args) >= 2:
                acc: Value = args[1]
                start = 0
            else:
                start = 0
                while start < len(arr) and arr[start] is JS_HOLE:
                    start += 1
                if start == len(arr):
                    _js_throw('TypeError', 'Reduce of empty array with no initial value')
                acc = arr[start]
                start += 1
            for i in range(start, len(arr)):
                if arr[i] is JS_HOLE:
                    continue
                self._tick()
                acc = self._call_function(callback, [acc, arr[i], i, arr])
            return acc
        raise InterpreterError

    def _eval_inline_call(self, func, arguments: list) -> Value:
        args = [self._eval(a) for a in arguments]
        return self._call_function(func, args)

    def _mutates_captured_binding(self, func) -> bool:
        """
        Whether *func* assigns to a name that the calling environment binds but that *func* does not
        declare locally — a write through a closure into a captured outer variable. A nested call runs
        in an isolated child interpreter with only a snapshot of captured values and no write-back, so
        the mutation would be silently lost. Refusing to evaluate leaves the call in place for a real
        engine to run and keeps the fold sound rather than producing a wrong constant.
        """
        body = func.body
        if not isinstance(body, JsBlockStatement):
            return False
        local_names: set[str] = {p.name for p in func.params if isinstance(p, JsIdentifier)}
        if isinstance(func, JsFunctionDeclaration) and isinstance(func.id, JsIdentifier):
            local_names.add(func.id.name)
        for node in walk_scope(body):
            if isinstance(node, JsVariableDeclaration):
                for decl in node.declarations:
                    if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier):
                        local_names.add(decl.id.name)
            elif isinstance(node, JsFunctionDeclaration) and isinstance(node.id, JsIdentifier):
                local_names.add(node.id.name)
        for node in walk_scope(body):
            if isinstance(node, JsAssignmentExpression) and isinstance(node.left, JsIdentifier):
                name = node.left.name
            elif isinstance(node, JsUpdateExpression) and isinstance(node.argument, JsIdentifier):
                name = node.argument.name
            else:
                continue
            if name not in local_names and name in self._env:
                return True
        return False

    def _call_function(self, func: JsFunctionNode, args: list[Value]) -> Value:
        """
        The value a call to *func* answers, computed by a child interpreter. What a call answers at
        all is `execute`'s question and is refused there, so that the refusal covers the entry a
        caller outside this class reaches as well.
        """
        if self._depth >= self.max_recursion:
            raise InterpreterError
        if self._mutates_captured_binding(func):
            raise InterpreterError
        callee_closure = self._closure_env.get(id(func)) or {}
        child = JsInterpreter(
            max_iterations=max(1, self.max_iterations - self._iterations),
            max_string_len=self.max_string_len,
            max_array_len=self.max_array_len,
            max_recursion=self.max_recursion,
            effects=self._effects,
            model=self._model,
            anchor=self._anchor,
            tampering=self._tampering,
            closure=callee_closure,
            closure_env=self._closure_env,
            established=self._established,
            depth=self._depth + 1,
        )
        try:
            result = child.execute(func, args)
        finally:
            self._iterations += child._iterations
        return result

    def _eval_member(self, node: JsMemberExpression) -> Value:
        if isinstance(node.object, JsIdentifier) and node.object.name in STATIC_OBJECTS:
            raise InterpreterError
        obj = self._eval(node.object)
        if node.optional and (obj is None or obj is JS_NULL):
            return None
        key = self._member_key(node)
        if isinstance(obj, JsArguments):
            return self._read_arguments_element(node, obj, key)
        return self._get_property(obj, key)

    def _read_arguments_element(
        self, node: JsMemberExpression, arguments: JsArguments, key: str,
    ) -> Value:
        """
        The element or the element count the arguments object answers for *key*. Every other key
        refuses rather than guessing, for the same reason a method read off any other value does:
        the answer lives on the prototype chain, on a `callee` no value domain here holds, or at
        an index past the end the chain may supply, and none of those is this object's to decide.
        """
        if key == 'length':
            return len(arguments.values)
        index = canonical_array_index(key)
        if index is not None and 0 <= index < len(arguments.values):
            return arguments.values[index]
        raise IrreducibleExpression(node)

    def _eval_template(self, node: JsTemplateLiteral) -> Value:
        """
        A template denotes the text of its runs with what its holes evaluate to between them. A run
        that denotes nothing makes the whole literal denote nothing: the language refuses such a
        template rather than reading it, so there is no string to hand back and computing one would
        answer for a script no engine will run.

        A hole's value is converted to a string, so it asks the coercion guard first: a program
        that installed a conversion on the object the hole evaluates to decides what the template
        spells, and that is a program this interpreter refuses to answer for.
        """
        parts: list[str] = []
        for i, quasi in enumerate(node.quasis):
            if quasi.value is None:
                raise IrreducibleExpression(node)
            parts.append(quasi.value)
            if i < len(node.expressions):
                value = self._eval(node.expressions[i])
                self._require_uninterceptable(value)
                parts.append(to_string(value))
        result = ''.join(parts)
        if len(result) > self.max_string_len:
            raise InterpreterError
        return result

    def _eval_object(self, node: JsObjectExpression) -> Value:
        """
        Evaluate an object literal into a dict of its own data properties. A literal that installs a
        prototype through the plain `__proto__:` form has a member surface this dict cannot express —
        the installed object's properties are inherited, not owned — so it aborts interpretation rather
        than record `__proto__` as an ordinary key, which would invent an own property JavaScript does
        not create. `object_sets_prototype` decides which spellings do that; the computed form and a
        shorthand or method define an ordinary own property and evaluate normally.
        """
        if object_sets_prototype(node):
            raise InterpreterError
        result: dict[str, Value] = {}
        for prop in node.properties:
            if not isinstance(prop, JsProperty):
                raise InterpreterError
            if prop.kind != JsPropertyKind.INIT:
                raise InterpreterError
            key: str
            if prop.computed:
                key = to_string(self._eval(prop.key))
            elif isinstance(prop.key, JsIdentifier):
                key = prop.key.name
            elif isinstance(prop.key, JsStringLiteral):
                if prop.key.value is None:
                    raise InterpreterError
                key = prop.key.value
            elif isinstance(prop.key, JsNumericLiteral):
                key = to_string(prop.key.value)
            else:
                raise InterpreterError
            result[key] = self._eval(prop.value)
        return result

    def _member_key(self, node: JsMemberExpression) -> str:
        if node.computed:
            val = self._eval(node.property)
            self._require_uninterceptable(val)
            return to_string(val)
        if isinstance(node.property, JsIdentifier):
            return node.property.name
        raise InterpreterError

    def _get_property(self, obj: Value, key: str) -> Value:
        """
        Read property *key* off *obj*, as a property *read* rather than a call. A method name yields the
        method itself, which this interpreter has no value domain for — a JS function value has an
        observable identity, `name`, `length`, and source text — so reading one aborts interpretation
        instead of guessing. Crucially it must not *invoke* the method: `typeof 'abc'.charAt` is
        `'function'`, not the `typeof` of what `charAt()` would return.

        Which keys the value itself decides is `read_data_property`'s to say, so that an emulated
        execution and a fold read a property the same way by construction. What is left here is the
        half that needs this interpreter: a nullish receiver throws before any key is looked at, and
        a key the value does not own is `undefined` only while the prototype chain is intact.

        An index past the end of an array is such a key and not a value of its own. Writing
        `Array.prototype[5]` puts a value where the array has no slot, so a read of `a[5]` answers
        it, and both of the outcomes the value declines to answer therefore ask the same question:
        the one that names an index is not the shorter road to `undefined` it looks like.
        """
        if obj is None or obj is JS_NULL:
            _js_throw('TypeError', F"Cannot read properties of {to_string(obj)} (reading '{key}')")
        outcome, value = read_data_property(obj, key)
        if outcome is MemberRead.FOUND:
            return value
        if self._property_is_absent(obj, key):
            return None
        raise InterpreterError

    def _property_is_absent(self, obj: Value, key: str) -> bool:
        """
        Whether *key* provably does not exist anywhere on *obj*'s prototype chain, making a read of it
        `undefined`. `property_provably_absent` decides it, so that an emulated execution and a fold
        answer an inherited read the same way by construction; what is left here is naming the type
        this interpreter's value is of and handing over the effect model when there is one. Where
        the oracle has vouched for the anchor, the chain is asked its attributed writes alone
        (`property_absent_from_written_chain`) rather than refused on a reflective surface.
        """
        if self._builtins_intact():
            return property_absent_from_written_chain(self._effects, type(obj), key)
        return property_provably_absent(self._effects, type(obj), key)

    def _set_property(self, obj: Value, key: str, value: Value) -> None:
        """
        Store *value* at *key* on *obj*, or refuse when this interpreter cannot model what the store means.

        An array accepts only `length` and a canonical index. Refusing every *named* property on one is not
        merely a gap in coverage: it is what keeps a function that writes `constructor` or `__proto__` on an
        array unfoldable. Those two properties decide what `slice` and its neighbours return — the read goes
        through ArraySpeciesCreate — so a program that sets them can make an apparently fresh array be a
        shared object, or make the call throw by leaving a primitive there. `EffectModel` refuses such a
        receiver as well, and both refusals are wanted: a widening here that started modelling named array
        properties would need that reasoning restated, not silently dropped.

        Growing the array — storing past its end, or writing a larger `length` — happens in place, filling
        the positions the store skipped over with holes, because the array another name holds must grow
        with it: `var v = u; u[87] = 5` leaves `v.length` at 88, so a copy is not a modelling of that
        store. How far an index or a length may reach is capped, which bounds the memory one store may
        ask for; a store the cap refuses still refuses here.
        """
        if isinstance(obj, dict):
            if key == PROTO_KEY and key not in obj:
                # Writing `__proto__` runs the accessor Object.prototype carries, which installs a
                # prototype instead of creating an own property. An existing own `__proto__` entry
                # shadows that accessor, and then the write does land in the data slot.
                raise InterpreterError
            obj[key] = value
            return
        if isinstance(obj, list):
            if key in SEQUENCE_DATA_PROPERTIES:
                new_len = _to_array_length(value)
                if new_len > len(obj):
                    if new_len > self.max_array_len:
                        raise InterpreterError
                    obj.extend([JS_HOLE] * (new_len - len(obj)))
                    return
                del obj[new_len:]
                return
            index = canonical_array_index(key)
            if index is not None:
                if index >= self.max_array_len:
                    raise InterpreterError
                if index > len(obj):
                    obj.extend([JS_HOLE] * (index - len(obj)))
                if index == len(obj):
                    obj.append(value)
                else:
                    obj[index] = value
                return
        raise InterpreterError

    @staticmethod
    def _strict_equal(a: Value, b: Value) -> bool:
        return js_strict_equal(a, b)

    @staticmethod
    def _loose_equal(a: Value, b: Value) -> bool:
        """
        Replicate the ECMA-262 abstract-equality (`==`) algorithm. `null` and `undefined` are equal to
        each other and to nothing else; a boolean coerces to a number; two objects compare by
        reference, while an object compared with a primitive coerces to a primitive first; a number
        compared with a string compares by numeric value.
        """
        a_nullish = a is None or a is JS_NULL
        b_nullish = b is None or b is JS_NULL
        if a_nullish or b_nullish:
            return a_nullish and b_nullish
        if isinstance(a, bool):
            a = 1 if a else 0
        if isinstance(b, bool):
            b = 1 if b else 0
        a_object = isinstance(a, _OBJECT_VALUE_TYPES)
        b_object = isinstance(b, _OBJECT_VALUE_TYPES)
        if a_object and b_object:
            return js_strict_equal(a, b)
        if a_object:
            a = _to_primitive(a)
        elif b_object:
            b = _to_primitive(b)
        if isinstance(a, str) and isinstance(b, str):
            return a == b
        a_num = isinstance(a, (int, float))
        b_num = isinstance(b, (int, float))
        if a_num and b_num:
            return a == b
        if (a_num and isinstance(b, str)) or (isinstance(a, str) and b_num):
            return to_number(a) == to_number(b)
        return js_strict_equal(a, b)

    def eval_expression(self, expr) -> Value:
        return self._eval(expr)

Methods

def execute(self, func, arguments)

The value a call to func with arguments answers, which is the value its body returned only where a call answers that value at all. An async function answers a promise and a generator answers a generator object whose body has not run, so wraps_return ends the interpretation rather than reporting a value the call never had. InterpreterError is what ends it and not IrreducibleExpression: the latter hands the caller the body's return expression to splice into the call site, which is the same wrong answer written a second way.

A body declaring a function anywhere but among its own statements ends it too. This interpreter has one environment per call and none per block, so it holds such a function from the entry of the body, where the language holds it in the block it is written in and, in sloppy code, in the enclosing scope only from the point the declaration runs. A read before that point is the difference, and it is one this answers with the function where a program answers undefined.

Expand source code Browse git
def execute(
    self,
    func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression,
    arguments: list[Value],
) -> Value:
    """
    The value a call to *func* with *arguments* answers, which is the value its body returned
    only where a call answers that value at all. An `async` function answers a promise and a
    generator answers a generator object whose body has not run, so `wraps_return` ends the
    interpretation rather than reporting a value the call never had. `InterpreterError` is what
    ends it and not `IrreducibleExpression`: the latter hands the caller the body's return
    expression to splice into the call site, which is the same wrong answer written a second
    way.

    A body declaring a function anywhere but among its own statements ends it too. This
    interpreter has one environment per call and none per block, so it holds such a function
    from the entry of the body, where the language holds it in the block it is written in and,
    in sloppy code, in the enclosing scope only from the point the declaration runs. A read
    before that point is the difference, and it is one this answers with the function where a
    program answers `undefined`.
    """
    if wraps_return(func):
        raise InterpreterError
    if _declares_a_function_inside_a_block(func):
        raise InterpreterError
    params = func.params
    param_names: list[str] = []
    for p in params:
        if not isinstance(p, JsIdentifier):
            raise InterpreterError
        param_names.append(p.name)
    self._env = {}
    for i, name in enumerate(param_names):
        self._env[name] = arguments[i] if i < len(arguments) else None
    body = func.body
    for name in self._collect_hoisted_var_names(body):
        if name == 'arguments' and not isinstance(func, JsArrowFunctionExpression):
            # The call has already bound `arguments` to the argument object before any
            # statement runs, so an entry holding `undefined` would answer a read with a
            # value the call never made — including under a `var` that displaces it, where
            # leaving the name unbound refuses instead.
            continue
        self._env.setdefault(name, None)
    arguments_object = self._arguments_object(func, arguments)
    if arguments_object is not None:
        self._env['arguments'] = arguments_object
    for name, value in self._closure.items():
        if name not in self._env:
            self._env[name] = _deep_copy_value(value)
    self._iterations = 0
    if isinstance(body, JsBlockStatement):
        try:
            self._exec_statements(body.body)
        except _ReturnSignal as r:
            return r.value
        except _ReturnIrreducible as r:
            raise IrreducibleExpression(r.node)
        except IrreducibleExpression:
            raise InterpreterError
        except _ThrowSignal:
            if self._depth == 0:
                raise InterpreterError
            raise
        return None
    if body is not None:
        try:
            return self._eval(body)
        except IrreducibleExpression:
            raise IrreducibleExpression(body)
        except _ThrowSignal:
            if self._depth == 0:
                raise InterpreterError
            raise
    return None
def eval_expression(self, expr)
Expand source code Browse git
def eval_expression(self, expr) -> Value:
    return self._eval(expr)