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 numbers1and0.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
nameis 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)andfunction 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): passAncestors
- 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 = nodeAncestors
- 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
IrreducibleExpressionwhen 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
asyncfunction answers a promise and a generator answers a generator object whose body has not run, sowraps_returnends the interpretation rather than reporting a value the call never had.InterpreterErroris what ends it and notIrreducibleExpression: 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)