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.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.model import SemanticModel, statement_list_holding
from refinery.lib.scripts.js.deobfuscation.helpers import (
    GLOBAL_VALUE_NAMES,
    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,
    eval_binary_op,
    js_typeof,
    name_is_unbound,
    names_global_value,
    own_property_keys,
    property_is_inherited_from_an_intact_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,
    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 _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
    (verified against Node and Chrome) 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


def _to_primitive(value: Value) -> Value:
    """
    Replicate the ECMA-262 ToPrimitive abstract operation with the default hint, as used by `+`.
    Arrays and plain objects have no useful `valueOf`, so they coerce to their string form; all other
    values are already primitive.
    """
    if isinstance(value, (list, dict)):
        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 arr.pop()
    return None


@_register((list, 'shift'))
def _arr_shift(arr: list, args: list[Value]) -> Value:
    if arr:
        return 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


@_register((list, 'includes'))
def _arr_includes(arr: list, args: list[Value]) -> Value:
    if not args:
        return False
    return any(js_strict_equal(item, args[0]) for item in arr)


@_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:
            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 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_INCOMPLETE_ESCAPE = re.compile(r'%(?![0-9A-Fa-f]{2})')
"""
A percent sign that introduces no complete escape. `decodeURIComponent` scans its argument for `%` and
throws a `URIError` unless two hexadecimal digits follow, so such a sign is not a character the decode
passes through: `decodeURIComponent('100%')` throws where `urllib.parse.unquote` answers `'100%'`.
Searching every `%` decides the whole argument, because the scan advances over the two digits of each
escape it accepts and neither digit can itself be a `%`, so no sign it would have reached is skipped.
"""


@_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)


@_register((None, 'decodeURIComponent'))
def _global_decode_uri_component(args: list[Value]) -> Value:
    if not args:
        raise InterpreterError
    s = to_string(args[0])
    if _RE_INCOMPLETE_ESCAPE.search(s):
        raise InterpreterError
    try:
        result = urllib.parse.unquote(s, encoding='utf-8', errors='surrogatepass')
        if any('\uD800' <= c <= '\uDFFF' for c in result):
            raise InterpreterError
        return result
    except Exception:
        raise InterpreterError


@_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 args and isinstance(args[0], dict):
        return own_property_keys(args[0])
    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):
        return list(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'})

_TYPEOF_FUNCTION_GLOBALS = frozenset({
    'String',
    'Number',
    'Boolean',
    'Array',
    'Object',
    'Function',
    'Symbol',
    'BigInt',
    'Date',
    'RegExp',
    'Error',
    'EvalError',
    'RangeError',
    'ReferenceError',
    'SyntaxError',
    'TypeError',
    'URIError',
    'AggregateError',
    'Promise',
    'Map',
    'Set',
    'WeakMap',
    'WeakSet',
    'WeakRef',
    'Proxy',
    'ArrayBuffer',
    'SharedArrayBuffer',
    'DataView',
    'Int8Array',
    'Uint8Array',
    'Uint8ClampedArray',
    'Int16Array',
    'Uint16Array',
    'Int32Array',
    'Uint32Array',
    'Float32Array',
    'Float64Array',
    'BigInt64Array',
    'BigUint64Array',
    'Buffer',
    'parseInt',
    'parseFloat',
    'isNaN',
    'isFinite',
    'encodeURIComponent',
    'decodeURIComponent',
    'encodeURI',
    'decodeURI',
    'eval',
    'escape',
    'unescape',
    'btoa',
    'atob',
    'setTimeout',
    'setInterval',
    'clearTimeout',
    'clearInterval',
    'setImmediate',
    'queueMicrotask',
})

_TYPEOF_OBJECT_GLOBALS = frozenset({'Math', 'JSON', 'Reflect', 'Atomics', 'globalThis', 'console'})


def _global_typeof(name: str) -> str | None:
    """
    The `typeof` result for a well-known global *name* — a constructor or built-in function is
    `'function'`, a namespace object is `'object'`, `NaN`/`Infinity` are `'number'` and `undefined` is
    `'undefined'` — or `None` when the interpreter does not model *name* and so cannot tell a declared
    global (whose `typeof` is not `'undefined'`) from a genuinely absent one.
    """
    if name in _TYPEOF_FUNCTION_GLOBALS or (None, name) in BUILTIN_REGISTRY:
        return 'function'
    if name in _TYPEOF_OBJECT_GLOBALS:
        return 'object'
    if name in ('NaN', 'Infinity'):
        return 'number'
    if name == 'undefined':
        return 'undefined'
    return None


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,
        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
        """
        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):
            self._env.setdefault(name, None)
        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 _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
        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 = node.cases
        for case in cases:
            if not isinstance(case, JsSwitchCase):
                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 self._effects is not None and not self._effects.read_chain_intact(type(right)):
            raise InterpreterError
        keys: list
        if isinstance(right, dict):
            keys = own_property_keys(right)
        else:
            keys = [str(i) for i in range(len(right))]
        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:
        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 isinstance(right, list):
            items = right
        elif isinstance(right, str):
            items = code_points(right)
        else:
            raise InterpreterError
        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:
                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: the three compound paths used to hand-roll their own
        answers, and the copies disagreed with this one on `-0`, on a zero divisor, and on which operators
        exist at all.

        `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.
        """
        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
        if property_is_inherited_from_an_intact_chain(self._effects, type(right), key):
            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.

        This value domain has one `None` and it means `undefined`, so a list holding a hole would be
        a value every one of those questions is answered wrongly from. The domain excludes it rather
        than growing a second nothing, which is also how the syntax model spells an elision: as no
        element at all.
        """
        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)
            result = _global_typeof(name)
            if result is None:
                raise IrreducibleExpression(node)
            return result
        apply = UNARY_OPS.get(op)
        if apply is None:
            raise InterpreterError
        return apply(self._eval(operand))

    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
            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 = to_number(self._get_property(obj, key))
            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):
            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 _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.
        """
        effects = self._effects
        if effects is None:
            return True
        return effects.call_is_foldable(node)

    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.
        """
        effects = self._effects
        if effects is None:
            return True
        return effects.trusted_prototype(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.

        `is_effect_free_when_discarded` rather than `is_pure`, which is stricter than this position
        needs: it tolerates a mutation the callback confines to a fresh local it returns. It does
        not tolerate a throw — `throws` blocks both — so a callback that reads a name the
        specification does not mandate on the global object is refused here, even though such a
        throw would surface as a real `_ThrowSignal` an emulated `try/catch` observes.

        Purity is not sufficient on its own either. A write to a *script-scope* `var` reports
        `writes_captured=False`, because that binding is not captured from the callback's perspective, so it
        slips through every purity flag. `written_bindings` records outer bindings by identity and catches it.

        This refuses the `reduce` accumulator idiom, `(acc, v) => { acc.push(v); return acc; }`, whose
        `acc.push` sets `calls_unknown` — a method call on a parameter is a callee the summary cannot
        resolve. That is a real fold this declines, and deliberately: the same flag is the only thing
        distinguishing `acc.push(v)` from a mutation of an outer array `s.push(v)`, so admitting one admits
        the other. Separating them needs the callee resolution to see through the parameter to the argument,
        which is a question for the effect model rather than a relaxation here.
        """
        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
        if method == 'every':
            for i, item in enumerate(arr):
                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):
                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):
                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):
                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, [item, i, arr])):
                    return item
            return None
        if method == 'findIndex':
            for i, item in enumerate(arr):
                self._tick()
                if to_boolean(self._call_function(callback, [item, i, arr])):
                    return i
            return -1
        if method == 'forEach':
            for i, item in enumerate(arr):
                self._tick()
                self._call_function(callback, [item, i, arr])
            return None
        if method == 'reduce':
            if len(arr) == 0 and len(args) < 2:
                raise InterpreterError
            if len(args) >= 2:
                acc: Value = args[1]
                start = 0
            else:
                acc = arr[0]
                start = 1
            for i in range(start, len(arr)):
                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,
            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)
        return self._get_property(obj, key)

    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.
        """
        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):
                parts.append(to_string(self._eval(node.expressions[i])))
        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):
                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)
            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.
        """
        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.

        Neither of the two it does accept may leave the array longer than the slots it fills.
        Growing `length`, or storing past the end, opens the holes `_eval_array` refuses to build,
        and a store is no better a place to build one than a literal is. The separate cap on how far
        an index may reach answers how much memory a loop may ask for and is untouched by that.
        """
        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):
                    raise InterpreterError
                del obj[new_len:]
                return
            index = canonical_array_index(key)
            if index is not None:
                if index > len(obj) or index >= self.max_array_len:
                    raise InterpreterError
                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; booleans and objects coerce to numbers/primitives; 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
        if isinstance(a, (list, dict)):
            a = _to_primitive(a)
        if isinstance(b, (list, dict)):
            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:
        """
        Evaluate a single expression AST node and return a Python 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 JsInterpreter (*, max_iterations=100000, max_string_len=1000000, max_array_len=1000000, max_recursion=10, effects=None, model=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,
        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
        """
        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):
            self._env.setdefault(name, None)
        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 _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
        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 = node.cases
        for case in cases:
            if not isinstance(case, JsSwitchCase):
                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 self._effects is not None and not self._effects.read_chain_intact(type(right)):
            raise InterpreterError
        keys: list
        if isinstance(right, dict):
            keys = own_property_keys(right)
        else:
            keys = [str(i) for i in range(len(right))]
        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:
        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 isinstance(right, list):
            items = right
        elif isinstance(right, str):
            items = code_points(right)
        else:
            raise InterpreterError
        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:
                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: the three compound paths used to hand-roll their own
        answers, and the copies disagreed with this one on `-0`, on a zero divisor, and on which operators
        exist at all.

        `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.
        """
        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
        if property_is_inherited_from_an_intact_chain(self._effects, type(right), key):
            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.

        This value domain has one `None` and it means `undefined`, so a list holding a hole would be
        a value every one of those questions is answered wrongly from. The domain excludes it rather
        than growing a second nothing, which is also how the syntax model spells an elision: as no
        element at all.
        """
        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)
            result = _global_typeof(name)
            if result is None:
                raise IrreducibleExpression(node)
            return result
        apply = UNARY_OPS.get(op)
        if apply is None:
            raise InterpreterError
        return apply(self._eval(operand))

    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
            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 = to_number(self._get_property(obj, key))
            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):
            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 _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.
        """
        effects = self._effects
        if effects is None:
            return True
        return effects.call_is_foldable(node)

    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.
        """
        effects = self._effects
        if effects is None:
            return True
        return effects.trusted_prototype(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.

        `is_effect_free_when_discarded` rather than `is_pure`, which is stricter than this position
        needs: it tolerates a mutation the callback confines to a fresh local it returns. It does
        not tolerate a throw — `throws` blocks both — so a callback that reads a name the
        specification does not mandate on the global object is refused here, even though such a
        throw would surface as a real `_ThrowSignal` an emulated `try/catch` observes.

        Purity is not sufficient on its own either. A write to a *script-scope* `var` reports
        `writes_captured=False`, because that binding is not captured from the callback's perspective, so it
        slips through every purity flag. `written_bindings` records outer bindings by identity and catches it.

        This refuses the `reduce` accumulator idiom, `(acc, v) => { acc.push(v); return acc; }`, whose
        `acc.push` sets `calls_unknown` — a method call on a parameter is a callee the summary cannot
        resolve. That is a real fold this declines, and deliberately: the same flag is the only thing
        distinguishing `acc.push(v)` from a mutation of an outer array `s.push(v)`, so admitting one admits
        the other. Separating them needs the callee resolution to see through the parameter to the argument,
        which is a question for the effect model rather than a relaxation here.
        """
        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
        if method == 'every':
            for i, item in enumerate(arr):
                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):
                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):
                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):
                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, [item, i, arr])):
                    return item
            return None
        if method == 'findIndex':
            for i, item in enumerate(arr):
                self._tick()
                if to_boolean(self._call_function(callback, [item, i, arr])):
                    return i
            return -1
        if method == 'forEach':
            for i, item in enumerate(arr):
                self._tick()
                self._call_function(callback, [item, i, arr])
            return None
        if method == 'reduce':
            if len(arr) == 0 and len(args) < 2:
                raise InterpreterError
            if len(args) >= 2:
                acc: Value = args[1]
                start = 0
            else:
                acc = arr[0]
                start = 1
            for i in range(start, len(arr)):
                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,
            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)
        return self._get_property(obj, key)

    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.
        """
        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):
                parts.append(to_string(self._eval(node.expressions[i])))
        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):
                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)
            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.
        """
        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.

        Neither of the two it does accept may leave the array longer than the slots it fills.
        Growing `length`, or storing past the end, opens the holes `_eval_array` refuses to build,
        and a store is no better a place to build one than a literal is. The separate cap on how far
        an index may reach answers how much memory a loop may ask for and is untouched by that.
        """
        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):
                    raise InterpreterError
                del obj[new_len:]
                return
            index = canonical_array_index(key)
            if index is not None:
                if index > len(obj) or index >= self.max_array_len:
                    raise InterpreterError
                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; booleans and objects coerce to numbers/primitives; 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
        if isinstance(a, (list, dict)):
            a = _to_primitive(a)
        if isinstance(b, (list, dict)):
            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:
        """
        Evaluate a single expression AST node and return a Python 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):
        self._env.setdefault(name, None)
    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)

Evaluate a single expression AST node and return a Python value.

Expand source code Browse git
def eval_expression(self, expr) -> Value:
    """
    Evaluate a single expression AST node and return a Python value.
    """
    return self._eval(expr)