Module refinery.lib.scripts.js.deobfuscation.helpers

Shared utilities for JavaScript deobfuscation transforms, and the runtime value domain they and the interpreter agree on: what a JavaScript value is (Value, JS_NULL, JsBuffer), the ECMA-262 conversions between values (to_number(), to_string(), to_boolean(), js_typeof()), the operator tables over them (UNARY_OPS, BINARY_OPS), the own-property read on them (read_data_property()), and the two bridges to the syntax tree (extract_literal_value(), value_to_node()).

That domain lives below the interpreter rather than inside it, because a static fold and an emulated execution must answer an operator the same way — if ~NaN were implemented once for each, only one of the two would be wrong at a time and nothing would say which. The interpreter is a consumer here like any transform is.

Expand source code Browse git
"""
Shared utilities for JavaScript deobfuscation transforms, and the runtime value domain they and the
interpreter agree on: what a JavaScript value is (`Value`, `JS_NULL`, `JsBuffer`), the ECMA-262
conversions between values (`to_number`, `to_string`, `to_boolean`, `js_typeof`), the operator tables
over them (`UNARY_OPS`, `BINARY_OPS`), the own-property read on them (`read_data_property`), and the
two bridges to the syntax tree (`extract_literal_value`, `value_to_node`).

That domain lives below the interpreter rather than inside it, because a static fold and an emulated
execution must answer an operator the same way — if `~NaN` were implemented once for each, only one of
the two would be wrong at a time and nothing would say which. The interpreter is a consumer here like
any transform is.
"""
from __future__ import annotations

import math
import operator
import re

from collections import Counter
from enum import Enum, auto
from typing import TYPE_CHECKING, Callable, Iterator, NamedTuple, Sequence

if TYPE_CHECKING:
    from typing import TypeAlias

    from refinery.lib.scripts.js.analysis.effects import EffectModel
    from refinery.lib.scripts.js.model import JsArrowFunctionExpression as _Arrow
    from refinery.lib.scripts.js.model import JsFunctionDeclaration as _FuncDecl
    from refinery.lib.scripts.js.model import JsFunctionExpression as _FuncExpr

    LiteralValue: TypeAlias = str | int | float | bool | list | dict | None
    Value: TypeAlias = str | float | bool | list | dict | _FuncDecl | _FuncExpr | _Arrow | None

from refinery.lib.scripts import (
    Expression,
    Node,
    Statement,
    Transformer,
    _clone_node,
    _compute_children,
    _remove_from_parent,
    _replace_in_parent,
    set_body,
    set_child,
    set_value,
    tree_root,
)
from refinery.lib.scripts.js.analysis.cache import model_cache
from refinery.lib.scripts.js.analysis.effects import side_effect_free
from refinery.lib.scripts.js.analysis.model import (
    FUNCTION_NODES,
    Binding,
    Role,
    SemanticModel,
    build_semantic_model,
    is_invocation_target,
    is_use_position,
    reference_role,
    walk_receiver_scope,
)
from refinery.lib.scripts.js.deobfuscation.options import (
    is_host_entrypoint,
    module_execution,
)
from refinery.lib.scripts.js.model import (
    JsArrayExpression,
    JsArrowFunctionExpression,
    JsAssignmentExpression,
    JsBinaryExpression,
    JsBlockStatement,
    JsBooleanLiteral,
    JsCallExpression,
    JsClassDeclaration,
    JsClassExpression,
    JsConditionalExpression,
    JsExportSpecifier,
    JsForInStatement,
    JsForOfStatement,
    JsFunctionDeclaration,
    JsFunctionExpression,
    JsFunctionNode,
    JsIdentifier,
    JsLogicalExpression,
    JsMemberExpression,
    JsNewExpression,
    JsNullLiteral,
    JsNumericLiteral,
    JsObjectExpression,
    JsParenthesizedExpression,
    JsProperty,
    JsReturnStatement,
    JsScript,
    JsSequenceExpression,
    JsSpreadElement,
    JsStringLiteral,
    JsTaggedTemplateExpression,
    JsThisExpression,
    JsUnaryExpression,
    JsVariableDeclaration,
    JsVariableDeclarator,
    JsVarKind,
    JsWhileStatement,
    callee_form_sensitive,
    strip_parens,
    wraps_return,
)
from refinery.lib.scripts.js.numbers import (
    canonical_array_index,
    is_negative_zero,
    js_number_to_string,
    js_string_to_number,
    to_js_number,
)
from refinery.lib.scripts.js.strict import (
    directive_prologue,
    is_prologue_host,
    keeping_directives,
    statement_list,
)
from refinery.lib.scripts.js.token import FUTURE_RESERVED, KEYWORDS
from refinery.lib.scripts.js.utf16 import SURROGATE_PAIR, code_units, from_code_units

SIMPLE_IDENTIFIER = re.compile(r'^[a-zA-Z_$][a-zA-Z_$0-9]*$')

JS_RESERVED = frozenset(set(KEYWORDS) | FUTURE_RESERVED | {'undefined'})

#: The spellings of the global object that name *this* realm's, which is the one a file's own
#: top-level declarations are properties of. `refinery.lib.scripts.js.analysis.model` knows two
#: more, `top` and `frames`, deliberately absent here: which document's global object each names
#: depends on where the file runs — `top` is another document's in a framed one, and both are read
#: by the document a frame is embedded in — so a removal must not trust either to be this realm's,
#: since deleting a write for want of a reader in this file deletes one another document reads. A
#: removal keys on this set; a reading of what code may reach keys on the wider one.
SAME_REALM_GLOBAL_OBJECT_ALIASES: frozenset[str] = frozenset({
    'globalThis',
    'global',
    'window',
    'self',
})
VOID_LITERAL_OPERANDS = (JsNumericLiteral, JsStringLiteral, JsBooleanLiteral, JsNullLiteral)

OBJECT_PROTOTYPE_MEMBERS = frozenset({
    '__defineGetter__',
    '__defineSetter__',
    '__lookupGetter__',
    '__lookupSetter__',
    '__proto__',
    'constructor',
    'hasOwnProperty',
    'isPrototypeOf',
    'propertyIsEnumerable',
    'toLocaleString',
    'toString',
    'valueOf',
})
"""
The members every plain object inherits from `Object.prototype`. An access of one of these names on
an object that does not own it resolves through the prototype rather than to `undefined`, so a fold
that treats an absent own-property as `undefined` must leave these intact.
"""

STRING_PROTOTYPE_METHODS = frozenset({
    'anchor', 'at', 'big', 'blink', 'bold', 'charAt', 'charCodeAt', 'codePointAt', 'concat',
    'endsWith', 'fixed', 'fontcolor', 'fontsize', 'includes', 'indexOf', 'isWellFormed', 'italics',
    'lastIndexOf', 'link', 'localeCompare', 'match', 'matchAll', 'normalize', 'padEnd', 'padStart',
    'repeat', 'replace', 'replaceAll', 'search', 'slice', 'small', 'split', 'startsWith', 'strike',
    'sub', 'substr', 'substring', 'sup', 'toLocaleLowerCase', 'toLocaleUpperCase', 'toLowerCase',
    'toString', 'toUpperCase', 'toWellFormed', 'trim', 'trimEnd', 'trimLeft', 'trimRight',
    'trimStart', 'valueOf',
})

ARRAY_PROTOTYPE_METHODS = frozenset({
    'at', 'concat', 'copyWithin', 'entries', 'every', 'fill', 'filter', 'find', 'findIndex',
    'findLast', 'findLastIndex', 'flat', 'flatMap', 'forEach', 'includes', 'indexOf', 'join',
    'keys', 'lastIndexOf', 'map', 'pop', 'push', 'reduce', 'reduceRight', 'reverse', 'shift',
    'slice', 'some', 'sort', 'splice', 'toLocaleString', 'toReversed', 'toSorted', 'toSpliced',
    'toString', 'unshift', 'values', 'with',
})
"""
The callable members of `String.prototype` and `Array.prototype`, enumerated from a real engine
rather than from the subset this package implements. Reading one of these names yields the method
itself, so a reader that answers `undefined` for the ones we cannot evaluate would contradict
`typeof`; membership and evaluability are separate questions.
"""

SEQUENCE_DATA_PROPERTIES = frozenset({'length'})
"""
The non-callable inherited properties of a string or array. `length` is the only one, which is why
it must never be reached through a method registry: `'abc'.length` is the number `3` and
`'abc'.length()` is a `TypeError`, whereas a registry entry would answer `3` to both.
"""

PROTOTYPE_CHAIN_PROPERTIES = frozenset({'__proto__', 'constructor'})
"""
The two properties that expose the prototype chain itself. Both exist on every value, so answering
`undefined` for them is wrong, but modelling them would hand out the `Function` constructor that
`[].constructor.constructor('...')()` reflection depends on. They are therefore left unevaluated.
"""


class _JsNull:
    """
    Singleton sentinel for the JavaScript `null` value. The interpreter uses Python `None` for
    `undefined` (the value of missing/absent things), so a distinct object is required to keep `null`
    and `undefined` apart where JavaScript treats them differently: `Number(null)` is `0` but
    `Number(undefined)` is `NaN`, `typeof null` is `'object'`, `null === undefined` is `false`, and
    `String(null)` is `'null'`.
    """
    __slots__ = ()

    def __repr__(self) -> str:
        return 'JS_NULL'


JS_NULL = _JsNull()

GLOBAL_VALUE_NAMES: dict[str, Value] = {
    'undefined': None,
    'NaN': float('nan'),
    'Infinity': float('inf'),
}
"""
The three global names that carry a value no literal spells, so that an operand written with one of
them holds a value `extract_literal_value` cannot report and a caller that wants it looks here.

What makes reading them safe is *not* the specification. ES5 made them non-writable and
non-configurable, but ES3 did not — its own Annex E lists the change as an intentional
incompatibility — and ES3 is what Windows Script Host runs, which is the dialect of the `.js`, `.wsf`
and `.hta` droppers this tool exists for. Measured under `cscript`, JScript 11.0: `undefined =
'CLOBBERED'` sticks, and `typeof undefined` becomes `'string'` afterwards. Clobbering `undefined` is
a live obfuscation technique precisely because it breaks a naive `=== undefined` check.

What makes reading them safe is the binding analysis. A program that assigns one of these names at
top level creates an `IMPLICIT_GLOBAL` binding the model records, and `denoted_value` — the only
reader that may consult this table — refuses any name the model resolves. All three are also
ordinary identifiers as far as scoping goes: a parameter, a `let` or a `var` of the same name shadows
them, and that is the same refusal. Reading the table without asking the model is a bug.
"""

PROTO_KEY = '__proto__'
"""
The one property key whose plain spelling in an object literal does not denote an own property.
`{__proto__: v}` and `{'__proto__': v}` install `v` as the prototype and leave the object with no own
property at all, whereas `{['__proto__']: v}` — and `JSON.parse` — create an ordinary own property of
that name. A runtime object modelled as a Python dict holds only own data properties, so a
`__proto__` entry in such a dict can only have come from one of the latter two, and may only be
rendered back as the computed form.
"""


class JsBuffer(list):
    """
    Thin wrapper around `list` to distinguish a Node.js Buffer (byte array) from a plain JS Array in
    the interpreter's type-based method dispatch. It lives beside `JS_NULL` because both are members
    of the interpreter's value domain that a plain Python type cannot express, and every consumer of
    that domain — most importantly `value_to_node`, which must not render a Buffer as an array
    literal — has to be able to tell them apart.
    """
    pass


def converts_uninterceptably(value: Value) -> bool:
    """
    Whether converting *value* to a string — or to the key of a property access, which converts it
    the same way — is an internal operation no program can replace. Every primitive is: `String(1)`
    and `o[1]` answer what the specification says whatever the file did to the prototypes. An array
    and an object are not, because their conversion runs `Array.prototype.join` and
    `Object.prototype.toString`, so a file that replaces either decides what `o[[1]]` reads. This is
    the rule `concat_string` states for the operand of a `+`, asked of a value rather than a node,
    and a fold that converts a value without asking it computes a key the engine never would.
    """
    return value is None or value is JS_NULL or isinstance(value, (str, int, float, bool))


class MemberRead(Enum):
    """
    What reading a key off a value found. `FOUND` carries the value the read answers with. `ABSENT`
    is an index past the end of a string or array: the value holds no such slot, so the read is
    `undefined` unless the prototype chain supplies one. `NOT_DATA` is every other key, which is a
    name the chain has to be consulted about before anything can be said.

    The two ways of not finding a value are kept apart because the callers part company on them. An
    emulated execution answers `undefined` for an index past the end, having no reason to doubt the
    chain of a value it is holding; a fold has to leave that read standing, because the file it is
    rewriting may install an index on `String.prototype` before it runs. Reporting both as one
    outcome would force the caller that cares to ask `canonical_array_index` a second time, which is
    the duplication this function exists to remove.
    """
    FOUND = auto()
    ABSENT = auto()
    NOT_DATA = auto()


def own_property_keys(obj: dict) -> list[str]:
    """
    The own keys of *obj* in the order JavaScript enumerates them: the array indices first, ascending
    by value, then every remaining key in the order it was created.

    A `dict` already preserves creation order, which is the whole of the second half of the rule and
    the reason enumeration reads as insertion order for as long as no index is present. An index is
    what breaks it, and the break is not a detail of ordering: a lookup table written with numeric
    keys comes back in an order the source does not show, so a program that walks one and a fold that
    walks the same one must agree about which key is first.

    Which keys are indices is the distinction `refinery.lib.scripts.js.numbers.canonical_array_index`
    draws, so a key an array could not have used as an index — `'01'`, `'-1'`, `'4294967295'` — sorts
    with the names and not with the numbers.

    This is the own half of enumeration, like `read_data_property` is the own half of a read: it
    answers about the keys *obj* holds and nothing about the ones a prototype would contribute.
    """
    indices: list[tuple[int, str]] = []
    names: list[str] = []
    for key in obj:
        index = canonical_array_index(key)
        if index is None:
            names.append(key)
        else:
            indices.append((index, key))
    indices.sort()
    return [key for _, key in indices] + names


def read_data_property(obj: Value, key: str) -> tuple[MemberRead, Value]:
    """
    Read *key* off *obj* as far as the value itself decides it: the own data properties of a string,
    an array, and a plain object, which are `length`, a canonical index, and a present key. Every
    other key is `NOT_DATA` — a method name, an inherited name, a missing key of an object, or any
    key at all of a value with no own slots to read, such as a number.

    The read is the *own* half of a property access and answers nothing about the prototype chain,
    which is where the outcome is decided for a `NOT_DATA` key and is why this takes no model. What
    it does answer, it answers alone: `length` and an index within range are own properties of a
    string or array, so no prototype can be consulted for them and none can shadow them.

    A list holds a value at every index it has, which is what makes the second of those answerable
    without a model. An array literal's elision, a `length` grown past its end and a store to an
    index beyond it all leave a slot the prototype answers for and the value does not, so a list
    holding one would make `FOUND` a lie: `[1, , 3][1]` reads `Array.prototype[1]` where
    `[1, undefined, 3][1]` reads the element. Nothing here can tell those apart, which is why the
    producer refuses to build such a list at all rather than this reporting on one.

    *obj* must hold a string the way JavaScript does, as UTF-16 code units — the form the lexer gives
    a literal and the builtin registry gives a produced string. A string of code points read here
    counts an astral character once where JavaScript counts it twice, and answers `length` and every
    index after it one too low.
    """
    if isinstance(obj, (str, list)):
        if key in SEQUENCE_DATA_PROPERTIES:
            return MemberRead.FOUND, len(obj)
        index = canonical_array_index(key)
        if index is not None:
            if 0 <= index < len(obj):
                return MemberRead.FOUND, obj[index]
            return MemberRead.ABSENT, None
    elif isinstance(obj, dict) and key in obj:
        return MemberRead.FOUND, obj[key]
    return MemberRead.NOT_DATA, None


def property_absent_from_written_chain(
    effects: EffectModel | None,
    value_type: type,
    key: str,
) -> bool:
    """
    `property_provably_absent` for a caller whose cost of refusing is a whole pass rather than one
    fold. The chain is asked `EffectModel.chain_roots_unwritten` instead of
    `EffectModel.read_chain_intact`, so a file carrying a reflective surface is not treated as one
    that wrote a prototype. See the note on `chain_roots_unwritten` for what that trade is; the
    short of it is that such a surface is what the real obfuscated files carry, and clearing it is
    what the gated pass would have done.

    It is a separate function rather than a parameter because the choice is the caller's to justify
    and has to be readable where it is made, and because there are only two callers entitled to it:
    namespace flattening and the dispatcher unwrapper, the two whose refusal costs the pipeline a
    pass rather than an expression. Every caller that folds one expression takes
    `property_provably_absent`, which keeps both arms and gains nothing by the weaker question.
    """
    if key in OBJECT_PROTOTYPE_MEMBERS or key in PROTOTYPE_CHAIN_PROPERTIES:
        return False
    if issubclass(value_type, JsBuffer):
        return False
    if effects is not None and not effects.chain_roots_unwritten(value_type):
        return False
    if issubclass(value_type, str):
        return key not in STRING_PROTOTYPE_METHODS and key not in SEQUENCE_DATA_PROPERTIES
    if issubclass(value_type, list):
        return key not in ARRAY_PROTOTYPE_METHODS and key not in SEQUENCE_DATA_PROPERTIES
    return issubclass(value_type, dict)


def property_is_inherited_from_an_intact_chain(
    effects: EffectModel | None,
    value_type: type,
    key: str,
) -> bool:
    """
    Whether *key* names a member the prototype chain of *value_type* supplies **and** that chain is
    still the one the language describes, so a value of that type has the member whether or not it
    owns one.

    This is `property_is_inherited` with the question that one cannot answer added to it. The tables
    say what the language installs; only the model can say whether the file took it away, and
    `delete Object.prototype.toString` removes a name every one of those tables lists. A caller with
    no effect model gets the tables alone and owns that assumption itself.

    It asks `EffectModel.read_chain_intact` and not the write arm, so that it refuses wherever
    `property_provably_absent` refuses. The two are the halves of one read, and a half that answers
    under a reflective surface the other half declines under is a read decided by which half
    happened to be asked: an unresolvable `eval` that deleted `toString` would leave
    `'toString' in o` folding to `true` while `'zz' in o` refused.
    """
    if not property_is_inherited(value_type, key):
        return False
    return effects is None or effects.read_chain_intact(value_type)


def property_is_inherited(value_type: type, key: str) -> bool:
    """
    Whether *key* names a member the prototype chain of *value_type* supplies, so a value of that
    type has one whether or not it owns one. This is the yes-side of the question
    `property_provably_absent` answers the no-side of, and the two are not each other's negation:
    between them lies every name the tables do not list, which a value has only if the file put it
    there.

    The asymmetry was once the point: writing a prototype adds a name to a chain, so a name the
    language already puts there was taken to be still there afterwards, while a name it does not put
    there is one only the file can account for. That reasoning is true of a write and **false of a
    `delete`** — `delete Object.prototype.toString` removes a name every table here lists — so this
    side needs the model as much as the other one does, and a caller must pair it with
    `EffectModel.read_chain_intact` or `EffectModel.chain_roots_unwritten` exactly as
    `property_provably_absent` does. A caller that does not is answering `true` for a name the
    program removed.
    """
    if key in OBJECT_PROTOTYPE_MEMBERS or key in PROTOTYPE_CHAIN_PROPERTIES:
        return True
    if issubclass(value_type, JsBuffer):
        return False
    if issubclass(value_type, str):
        return key in STRING_PROTOTYPE_METHODS
    if issubclass(value_type, list):
        return key in ARRAY_PROTOTYPE_METHODS
    return False


def property_provably_absent(effects: EffectModel | None, value_type: type, key: str) -> bool:
    """
    Whether reading *key* off a value of *value_type* that does not own *key* provably yields
    `undefined`, so a caller may answer that in place of the read. This is the inherited half of a
    property access where `read_data_property` is the own half, and it is one function because every
    caller is deciding the same thing — what the prototype chain says about a key the value itself
    does not answer for — and each of them was deciding it alone with a different clause missing.

    Two questions have to answer together. The name must be one no prototype in the chain holds,
    which is what the tables above enumerate from a real engine rather than from the subset this
    package can evaluate: `normalize` is a function whether or not we can run it, and answering
    `undefined` for it would contradict `typeof`. And the chain has to still be the one the language
    describes, which only *effects* can say — `Object.prototype.z = 9` puts a name there that no
    table here lists, so deciding from the tables alone answers `undefined` where the program
    answers `9`.

    A caller with no effect model has nothing to consult and owns that assumption itself, the same
    way `EffectModel.trusted_intrinsic` leaves it with one. A `JsBuffer` is refused outright: its
    surface is over a hundred methods that vary between Node versions, so nothing is provably absent
    on one. So is every receiver this file enumerates no surface for — a function, a number — since
    the arms below name the three that have one and nothing else falls through them.
    """
    if key in OBJECT_PROTOTYPE_MEMBERS or key in PROTOTYPE_CHAIN_PROPERTIES:
        return False
    if issubclass(value_type, JsBuffer):
        return False
    if effects is not None and not effects.read_chain_intact(value_type):
        return False
    if issubclass(value_type, str):
        return key not in STRING_PROTOTYPE_METHODS and key not in SEQUENCE_DATA_PROPERTIES
    if issubclass(value_type, list):
        return key not in ARRAY_PROTOTYPE_METHODS and key not in SEQUENCE_DATA_PROPERTIES
    return issubclass(value_type, dict)


def utf16_code_units(text: str) -> list[str]:
    """
    Split *text* into its UTF-16 code units, which is what JavaScript indexing and `split('')` operate
    on. Python strings are sequences of code points, so a character outside the BMP is one Python
    character but two JavaScript ones: `'\U0001F600'.split('')` has length 2 in JS, and each half is a
    lone surrogate. Iterating the Python string directly would under-count it.
    """
    units: list[str] = []
    for char in text:
        units.extend(code_units(ord(char)))
    return units


def _to_int32(v: int | float) -> int:
    """
    Replicate the ECMA-262 ToInt32 abstract operation: `NaN`, `+Infinity`, and `-Infinity` all
    coerce to `0`, finite floats truncate towards zero, the result is taken mod 2^32 and
    sign-extended to the int32 range.
    """
    if isinstance(v, float):
        if v != v or v == float('inf') or v == float('-inf'):
            return 0
        v = int(v) if v >= 0 else -int(-v)
    v = v & 0xFFFFFFFF
    return v - 0x100000000 if v >= 0x80000000 else v


def _to_uint32(v: int | float) -> int:
    """
    Replicate the ECMA-262 ToUint32 abstract operation.
    """
    if isinstance(v, float):
        if v != v or v == float('inf') or v == float('-inf'):
            return 0
        v = int(v) if v >= 0 else -int(-v)
    return v & 0xFFFFFFFF


def to_boolean(value: Value) -> bool:
    """
    Apply the ECMA-262 ToBoolean abstract operation. This is the value-domain counterpart of the
    AST-node `is_truthy`; the two must agree on which values are falsy (`undefined`, `null`, `0`,
    `NaN`, `''`) so that interpreted and statically-folded conditionals stay consistent.
    """
    if value is None or value is JS_NULL:
        return False
    if isinstance(value, bool):
        return value
    if isinstance(value, (int, float)):
        return value != 0 and value == value
    if isinstance(value, str):
        return len(value) > 0
    if isinstance(value, list):
        return True
    if isinstance(value, dict):
        return True
    if isinstance(value, (JsFunctionDeclaration, JsFunctionExpression, JsArrowFunctionExpression)):
        return True
    return False


def to_number(value: Value) -> float:
    """
    Apply the ECMA-262 ToNumber abstract operation, which is a dispatch on the type of a value. The
    string case is the only one with a grammar behind it, and that grammar is `js_string_to_number`
    in the Number domain, where the reading of a Number belongs; asking Python's `float` here
    instead would answer a different question, its own grammar being wider than the language's in
    several places at once.
    """
    if isinstance(value, bool):
        return 1.0 if value else 0.0
    if isinstance(value, (int, float)):
        return to_js_number(value)
    if isinstance(value, str):
        return js_string_to_number(value)
    if value is JS_NULL:
        return 0.0
    if isinstance(value, list):
        return to_number(to_string(value))
    return float('nan')


def to_string(value: Value) -> str:
    if isinstance(value, str):
        return value
    if value is None:
        return 'undefined'
    if value is JS_NULL:
        return 'null'
    if isinstance(value, bool):
        return 'true' if value else 'false'
    if isinstance(value, (int, float)):
        return js_number_to_string(value)
    if isinstance(value, list):
        return ','.join(_array_element_string(v) for v in value)
    return '[object Object]'


def _array_element_string(value: Value) -> str:
    """
    Stringify an array element for `Array.prototype.toString` / `join`. JavaScript renders `null` and
    `undefined` elements as the empty string (e.g. `[1, null, 2].toString()` is `'1,,2'`), unlike a
    top-level `String(null)` which is `'null'`.
    """
    if value is None or value is JS_NULL:
        return ''
    return to_string(value)


def _to_int(value: Value) -> int:
    n = to_number(value)
    if n != n or math.isinf(n):
        return 0
    return int(n)


def js_typeof(value: Value) -> str:
    """
    Apply the `typeof` operator to a value. Total over the domain, and the reason `typeof null` is
    `'object'` falls out of the ordering rather than being stated: `JS_NULL` is not any of the
    primitive types tested for, so it reaches the same answer every object does.
    """
    if value is None:
        return 'undefined'
    if isinstance(value, bool):
        return 'boolean'
    if isinstance(value, (int, float)):
        return 'number'
    if isinstance(value, str):
        return 'string'
    if isinstance(value, (JsFunctionDeclaration, JsFunctionExpression, JsArrowFunctionExpression)):
        return 'function'
    return 'object'


def _js_div(a: int | float, b: int | float) -> int | float:
    if b == 0:
        if a == 0 or a != a:
            return float('nan')
        negative = (a < 0) != (math.copysign(1.0, b) < 0)
        return float('-inf') if negative else float('inf')
    return a / b


def _js_mul(a: int | float, b: int | float) -> int | float:
    """
    Multiply two JavaScript numbers, preserving the IEEE-754 sign of a zero product: a product of
    magnitude zero is negative zero exactly when the operands have opposite signs. Python integer
    multiplication cannot represent `-0`, so `0 * -5` would otherwise silently lose the sign.
    """
    result = a * b
    if result == 0 and (math.copysign(1.0, a) < 0) != (math.copysign(1.0, b) < 0):
        return -0.0
    return result


def _js_mod(a: int | float, b: int | float) -> int | float:
    if b == 0 or a != a or b != b:
        return float('nan')
    if a == float('inf') or a == float('-inf'):
        return float('nan')
    if b == float('inf') or b == float('-inf'):
        return a
    return math.fmod(a, b)


def _js_pow(base: int | float, exp: int | float) -> float:
    """
    Replicate JavaScript exponentiation (`**` / `Math.pow`). JavaScript numbers are IEEE-754 doubles,
    so this diverges from Python in cases that matter: `anything ** 0` is `1` (even `NaN ** 0`); a base
    of `1` or `-1` with an infinite exponent is `NaN` (Python: `1.0`); a negative base with a
    non-integer exponent is a complex number in Python (JS: `NaN`); a zero base with a negative
    exponent is `Infinity` (with the sign rule for `-0`); and a magnitude beyond the double range is
    `Infinity`, whereas Python's arbitrary-precision `int ** int` returns an exact bignum.

    An infinite exponent is decided from the base's *magnitude* alone and never from its sign, which
    is why it is answered before the negative-base rule rather than folded into it. Raising `-2` to
    `Infinity` is `Infinity` and raising `-0.5` to it is `0`, where reading an infinite exponent as
    a non-integer one would call both `NaN`.

    Both operands are coerced here and not only at `eval_binary_op`, because this is the one
    operator that answers an unbounded amount of work when handed Python integers, and it is
    reachable through `BINARY_OPS` without passing that function.
    """
    base = to_js_number(base)
    exp = to_js_number(exp)
    inf = float('inf')
    if exp == 0:
        return 1.0
    if base != base or exp != exp:
        return float('nan')
    if exp in (inf, -inf):
        magnitude = abs(base)
        if magnitude == 1:
            return float('nan')
        return inf if (magnitude > 1) == (exp == inf) else 0.0
    is_int_exp = exp == int(exp)
    if base == 0 and exp < 0:
        if is_int_exp and int(exp) % 2 != 0 and math.copysign(1.0, base) < 0:
            return -inf
        return inf
    if base < 0 and base != -inf and not is_int_exp:
        return float('nan')
    try:
        result = base ** exp
    except OverflowError:
        return -inf if (base < 0 and is_int_exp and int(exp) % 2 != 0) else inf
    except (ValueError, ZeroDivisionError):
        return float('nan')
    return result


BINARY_OPS: dict[str, Callable] = {
    '+'  : operator.add,
    '-'  : operator.sub,
    '*'  : _js_mul,
    '/'  : _js_div,
    '%'  : _js_mod,
    '**' : _js_pow,
    '|'  : lambda a, b: float(_to_int32(a) | _to_int32(b)),
    '&'  : lambda a, b: float(_to_int32(a) & _to_int32(b)),
    '^'  : lambda a, b: float(_to_int32(a) ^ _to_int32(b)),
    '<<' : lambda a, b: float(_to_int32(_to_int32(a) << (_to_int32(b) & 0x1F))),
    '>>' : lambda a, b: float(_to_int32(a) >> (_to_int32(b) & 0x1F)),
}
"""
JavaScript's binary operators over two *numbers*. Every entry assumes both operands have already been coerced,
which is why `+` is `operator.add` here: on numbers that is what `+` means, but on a value that may be a string
or an object `+` is a different operator entirely — it applies ToPrimitive to both sides and concatenates when
either is a string. A caller holding uncoerced values must not reach this table for `+`; the interpreter routes
every operator through `JsInterpreter._apply_binary`, which resolves the string cases before delegating here.
"""

UNARY_OPS: dict[str, Callable[[Value], Value]] = {
    '-'     : lambda v: -to_number(v),
    '+'     : to_number,
    '~'     : lambda v: float(_to_int32(~_to_int(v))),
    '!'     : lambda v: not to_boolean(v),
    'void'  : lambda v: None,
    'typeof': js_typeof,
}
"""
JavaScript's unary operators that are functions of their operand's *value* alone. Each is total over
the value domain — the coercions answer for every value rather than refusing any — so a caller holding
a value needs no per-operator guard, and a caller holding a syntax tree needs only to obtain the value.

`delete` is absent because it is not one of these: its result depends on the operand's *reference*
rather than its value, and evaluating it changes the object it names. A pass that wants to fold a
`delete` has to reason about that object, which is a question about the program and not about a value,
so the absence is what keeps this table from being asked it.

`void` is a member despite discarding its operand, because discarding a value is still a function of
it. The caller remains responsible for evaluating the operand: `void f()` is `undefined` and calls `f`,
and this table only supplies the first half.
"""

RELATIONAL_OPS: dict[str, Callable] = {
    '<' : operator.lt,
    '>' : operator.gt,
    '<=': operator.le,
    '>=': operator.ge,
}

LOGICAL_ASSIGNMENT_OPS = frozenset({'&&=', '||=', '??='})
"""
The assignment operators that short-circuit. Unlike every other compound assignment, these evaluate their
right operand only when the target's existing value does not already decide the result, and perform no store
when it does — a distinction JavaScript makes observable through a setter or a frozen object. They therefore
have no entry in `BINARY_OPS`, whose members are total functions of both operands.
"""


def eval_binary_op(op: str, left: float, right: float) -> float | bool | None:
    """
    Evaluate a JavaScript binary operator on two numeric operands. Returns the result value, or
    `None` when the operator is unknown or the computation overflows/divides by zero. Handles
    arithmetic, bitwise, relational, equality, and the unsigned right shift `>>>`.

    Both operands are coerced here so that every operator below sees the Number the caller meant and
    not whichever Python type happened to carry it. That is a normalization and not the bound on the
    work: `refinery.lib.scripts.js.deobfuscation.stringarray` reaches `BINARY_OPS` without passing
    through this function, so the operator that can be asked for unbounded work — `**`, where Python
    integers build a number of half a billion digits for what a double answers in one operation —
    coerces for itself as well.
    """
    left = to_js_number(left)
    right = to_js_number(right)
    if op in ('===', '=='):
        return left == right
    if op in ('!==', '!='):
        return left != right
    rel = RELATIONAL_OPS.get(op)
    if rel is not None:
        return rel(left, right)
    if op == '>>>':
        a = _to_uint32(left)
        b = _to_uint32(right) & 0x1F
        return float((a >> b) & 0xFFFFFFFF)
    fn = BINARY_OPS.get(op)
    if fn is None:
        return None
    try:
        return fn(left, right)
    except (ZeroDivisionError, OverflowError, ValueError):
        return None


def _escape_residue(m: re.Match[str]):
    cp = ord(m.group())
    if cp > 0xFF:
        return F'\\u{cp:04X}'
    return F'\\x{cp:02x}'


def spell_astral_characters(value: str) -> str:
    """
    Write the pairs of code units that name a character above the basic plane as that character. A
    value is held as the code units a JavaScript string is made of, which is what a program asking
    about its length or its halves has to be answered from; but a file is written in characters, so
    printing the units back would spell an emoji as two escapes nobody wrote.

    A surrogate standing alone is left alone. It names no character, so there is nothing to write it
    as, and an escape is the only spelling a file has for it.
    """
    return from_code_units(value)


def code_points(value: str) -> list[str]:
    """
    The code points of a string, each kept as the code units that spell it. A JavaScript string is
    indexed by code unit but iterated by code point — a `for ... of`, a spread, `Array.from` — so a
    well-formed surrogate pair is one element here, and every other code unit is its own. A lone
    surrogate stands as a code point of its own, because a string may hold one and the split may not
    invent the partner it lacks.
    """
    points: list[str] = []
    index = 0
    length = len(value)
    while index < length:
        step = 2 if SURROGATE_PAIR.match(value, index) else 1
        points.append(value[index:index + step])
        index += step
    return points


def escape_js_string(value: str, quote: str = "'") -> str:
    """
    Escape a string for use in a JavaScript string literal. Returns the escaped body without
    surrounding quotes. Backslash is escaped first to avoid double-escaping. Control characters
    not covered by named escapes are emitted as `\\xHH`; a surrogate that names no character on its
    own as `\\uXXXX`, and a pair of them as the character they name.

    A NUL is `\\0`, except where a digit stands behind it: `\\0` followed by `0` through `7` is one
    legacy octal escape and would swallow the digit into a different character, and followed by `8`
    or `9` it is an escape strict code refuses. A NUL a digit follows is spelled `\\x00` instead, so
    the character behind it stays the character the value held.
    """
    value = spell_astral_characters(value)
    value = value.replace('\\', r'\\')
    value = value.replace('\n', r'\n')
    value = value.replace('\r', r'\r')
    value = value.replace('\t', r'\t')
    value = re.sub(r'\x00(?=[0-9])', r'\\x00', value)
    value = value.replace('\0', r'\0')
    value = value.replace(quote, F'\\{quote}')
    return re.sub(r'[\x01-\x1f\ud800-\udfff]', _escape_residue, value)


def escape_js_template_text(value: str) -> str:
    """
    Escape a string so that it spells itself inside a template literal. Three characters end a run
    of template text rather than standing in it — the backtick that closes the literal, the `${`
    that opens a hole, and the backslash that would eat what follows it.

    A line feed stands as itself, because a template is the one literal that may span lines. A
    carriage return does not: every line terminator sequence a template is written with denotes a
    line feed, so a return written into the text would come back as one and the string would not
    be the string. A lone surrogate has to be spelled too, for the same reason a string spells one
    — there is no encoding of the file that carries it.
    """
    value = spell_astral_characters(value)
    value = value.replace('\\', r'\\')
    value = value.replace('\r', r'\r')
    value = value.replace('`', r'\`')
    value = value.replace('${', r'\${')
    return re.sub(r'[\x00-\x08\x0b-\x1f\ud800-\udfff]', _escape_residue, value)


def string_value(node: Expression | None) -> str | None:
    """
    The text a literal denotes, where it is a literal that denotes one. A literal the source never
    closed is not, and answering with the text it would have denoted is how a fold repairs it: the
    text goes into a fresh literal that carries the closing quote nobody wrote, and a file that no
    engine reads comes back as a program that runs.
    """
    if isinstance(node, JsStringLiteral) and node.terminated:
        return node.value
    return None


def property_key(prop: JsProperty) -> str | None:
    """
    Extract the string key from a property node. Handles both string-literal keys and plain
    identifier keys. Returns `None` for computed keys.
    """
    if prop.computed:
        return None
    if isinstance(prop.key, JsStringLiteral):
        return prop.key.value
    if isinstance(prop.key, JsIdentifier):
        return prop.key.name
    return None


def access_key(node: JsMemberExpression) -> str | None:
    """
    Extract the string key from a member-access expression. Handles both computed (`obj['key']`)
    and dot (`obj.key`) accesses.
    """
    if node.computed:
        return string_value(node.property)
    if isinstance(node.property, JsIdentifier):
        return node.property.name
    return None


def names_this_realms_global_object(model: SemanticModel, node: Node | None) -> bool:
    """
    Whether *node* denotes this realm's global object: it is spelled with one of
    `SAME_REALM_GLOBAL_OBJECT_ALIASES` and nothing binds that name where it stands. Both halves are
    needed by every pass that acts on a property written through such a spelling, because a
    declaration of the name binds it and the access then reads an ordinary object the program may
    read back through any second name for it.

    The two questions are one predicate because a pass asking only the first is the shape of a
    defect rather than of a policy: `refinery.lib.scripts.js.deobfuscation.unused` deleted a write
    on the object a `var self = {}` held, and
    `refinery.lib.scripts.js.deobfuscation.scramble.JsScrambleStringDecoder` deleted the
    installation of a decoder a later call still reached through the same object.

    A pass that needs the name the access designates asks
    `refinery.lib.scripts.js.analysis.model.SemanticModel.global_alias_member_name` instead, which
    answers both questions at once for a statically spelled key. This one is for a caller that reads
    its key some other way.
    """
    base = strip_parens(node)
    if not isinstance(base, JsIdentifier) or base.name not in SAME_REALM_GLOBAL_OBJECT_ALIASES:
        return False
    return model.lookup(base.name, model.scope_of(base)) is None


def make_string_literal(value: str) -> JsStringLiteral:
    escaped = escape_js_string(value)
    raw = F"'{escaped}'"
    return JsStringLiteral(value=value, raw=raw)


def numeric_value(node: Expression) -> float | None:
    if isinstance(node, JsNumericLiteral):
        return node.value
    return None


def make_numeric_literal(value: int | float) -> JsNumericLiteral | None:
    """
    Spell a Number as a literal, or refuse with `None` when it has none. `NaN` is the only Number
    without one, and a caller that can produce it must spell it through `value_to_node`.

    The infinities do have one. ECMA-262 defines the mathematical value of a decimal literal and then
    rounds it to the nearest Number, and a value too large to round to a finite one rounds to the
    infinity — so `1e999` is a numeric literal denoting `+Infinity` exactly as `1` is one denoting
    one. That matters because the alternative spelling, the identifier `Infinity`, is an ordinary
    global binding that the program being deobfuscated may have rebound, whereas a literal denotes
    its value in every scope.

    The spelling is otherwise `Number.prototype.toString`, with one deliberate deviation: that
    algorithm reads the mathematical value, so it prints negative zero as `0`, but a literal `0`
    denotes *positive* zero and the two are distinguishable — `1 / -0` is `-Infinity`. Negative zero
    is therefore spelled `-0`. That spelling, like `-1e999` and the one every other negative value
    gets, is a negation applied to a literal rather than a literal, so the node binds like the unary
    operator it starts with. That is a fact about the spelling, which
    `refinery.lib.scripts.js.precedence` therefore reads from the `raw` rather than from the class.
    """
    value = to_js_number(value)
    if value != value:
        return None
    if value == float('inf'):
        return JsNumericLiteral(value=value, raw='1e999')
    if value == float('-inf'):
        return JsNumericLiteral(value=value, raw='-1e999')
    if is_negative_zero(value):
        return JsNumericLiteral(value=value, raw='-0')
    return JsNumericLiteral(value=value, raw=js_number_to_string(value))


def make_undefined_expression() -> JsUnaryExpression:
    """
    The expression that spells `undefined`. That value has no literal, and the global name that
    denotes it is an ordinary binding which any scope may rebind, so it is written as an operator
    applied to a literal instead: `void 0` denotes it wherever it stands.
    """
    return JsUnaryExpression(operator='void', operand=JsNumericLiteral(value=0, raw='0'))


def make_nan_expression() -> JsBinaryExpression:
    """
    The expression that spells `NaN`, which has no literal either, for the same reason and by the
    same means: `0 / 0`.
    """
    return JsBinaryExpression(
        operator='/',
        left=JsNumericLiteral(value=0, raw='0'),
        right=JsNumericLiteral(value=0, raw='0'),
    )


def denotes_nan(node: Node) -> bool:
    """
    Whether *node* is the expression `make_nan_expression` builds. A zero literal divided by a zero
    literal is `NaN` however either zero happens to be written, so the test reads the two values
    rather than the text.
    """
    return (
        isinstance(node, JsBinaryExpression)
        and node.operator == '/'
        and isinstance(node.left, JsNumericLiteral)
        and isinstance(node.right, JsNumericLiteral)
        and node.left.value == 0
        and node.right.value == 0
    )


def extract_literal_value(node: Node) -> tuple[bool, LiteralValue]:
    """
    Extract a Python value from a literal AST node. Returns `(True, value)` on success or
    `(False, None)` when the node is not a recognized literal form. Handles string, numeric,
    boolean, null literals, `void expr`, negative numerics, `!0`/`!1`, `0 / 0`, and array
    expressions where all elements are themselves literals.

    The two forms that are operator expressions rather than literals, `void 0` and `0 / 0`, are here
    because they are what `undefined` and `NaN` have instead of a literal: an expression built from
    an operator, which no scope can rebind, rather than from one of the global names, which any
    scope can. Recognizing them is what lets those two values survive a round trip through the tree,
    and this must stay paired with `value_to_node`, its declared inverse.
    """
    if isinstance(node, JsStringLiteral):
        return (True, node.value) if node.terminated else (False, None)
    if isinstance(node, JsNumericLiteral):
        return True, node.value
    if isinstance(node, JsBooleanLiteral):
        return True, node.value
    if isinstance(node, JsNullLiteral):
        return True, JS_NULL
    if isinstance(node, JsUnaryExpression):
        if node.operator == 'void' and isinstance(node.operand, VOID_LITERAL_OPERANDS):
            return True, None
        if node.operator == '-' and isinstance(node.operand, JsNumericLiteral):
            return True, -node.operand.value
        if node.operator == '+' and isinstance(node.operand, JsNumericLiteral):
            return True, node.operand.value
        if node.operator == '!' and isinstance(node.operand, JsNumericLiteral):
            return True, not bool(node.operand.value)
    if denotes_nan(node):
        return True, float('nan')
    if isinstance(node, JsArrayExpression):
        items: list[LiteralValue] = []
        for el in node.elements:
            if el is None:
                return False, None
            ok, val = extract_literal_value(el)
            if not ok:
                return False, None
            items.append(val)
        return True, items
    return False, None


def value_to_node(value: object) -> Expression | None:
    """
    Convert a Python value to the corresponding AST literal node, or `None` when the value has no
    literal form that denotes it faithfully. Refusing is always sound — the caller leaves the original
    expression in place — whereas rendering an approximation silently changes what the program means,
    so every case here either round-trips exactly or returns `None`.

    A number is spelled by `make_numeric_literal` whatever its sign, so that a negative one is a single
    literal carrying its sign in the `raw` and not a negation applied to its magnitude. Those two nodes
    synthesize to the same text, which is what let the second spelling go unnoticed, but only the first
    is a `JsNumericLiteral`: the fold that reads an operand with `numeric_value` sees a number in one
    and nothing in the other.

    `NaN` and `undefined` are the only values this returns a compound node for, because they are the
    only ones no literal denotes. Neither is spelled with the global name that names it. Those names
    are ordinary bindings, and this function does not know the scope it is writing into: a fold that
    happens under `function (NaN) { … }` would otherwise emit text meaning the parameter.
    """
    if isinstance(value, str):
        return make_string_literal(value)
    if isinstance(value, bool):
        return JsBooleanLiteral(value=value)
    if isinstance(value, (int, float)):
        number = to_js_number(value)
        if number != number:
            return make_nan_expression()
        return make_numeric_literal(number)
    if isinstance(value, JsBuffer):
        return None
    if isinstance(value, list):
        elements: list[Expression | None] = []
        for item in value:
            el = value_to_node(item)
            if el is None:
                return None
            elements.append(el)
        return JsArrayExpression(elements=elements)
    if isinstance(value, dict):
        properties = []
        for k, v in value.items():
            if not isinstance(k, str):
                return None
            val_node = value_to_node(v)
            if val_node is None:
                return None
            properties.append(JsProperty(
                key=make_string_literal(k),
                value=val_node,
                computed=k == PROTO_KEY,
            ))
        return JsObjectExpression(properties=properties)
    if value is JS_NULL:
        return JsNullLiteral()
    if value is None:
        return make_undefined_expression()
    return None


def is_literal(node: Node) -> bool:
    """
    Whether *node* is a constant expression whose value the tree carries in full — the test a pass
    applies before cloning it to another position. `void 0` and `0 / 0` count for the same reason
    `extract_literal_value` reads them: they are what `undefined` and `NaN` have instead of a
    literal, and an operator applied to literals is as constant as a literal is.
    """
    if isinstance(node, JsStringLiteral):
        return node.terminated
    if isinstance(node, (JsNumericLiteral, JsBooleanLiteral, JsNullLiteral)):
        return True
    if isinstance(node, JsUnaryExpression):
        if node.operator == 'void' and isinstance(node.operand, VOID_LITERAL_OPERANDS):
            return True
        if node.operator == '-' and isinstance(node.operand, JsNumericLiteral):
            return True
    return denotes_nan(node)


def member_key(node: JsMemberExpression) -> str | None:
    """
    Flatten a chain of property accesses into a dot-separated key string. Handles both dot
    notation and computed access with string-literal keys. Returns `None` if the chain contains
    a dynamic computed access that cannot be resolved to a static key.
    """
    parts: list[str] = []
    cursor: Expression | None = node
    while isinstance(cursor, JsMemberExpression):
        key = access_key(cursor)
        if key is None:
            return None
        parts.append(key)
        cursor = cursor.object
    if not isinstance(cursor, JsIdentifier):
        return None
    parts.append(cursor.name)
    parts.reverse()
    return '.'.join(parts)


def is_while_true(node: JsWhileStatement) -> bool:
    """
    Check whether the while-loop condition is `true`, `!![]`, or `!0` — the forms the
    obfuscator uses for infinite loops.
    """
    test = node.test
    if isinstance(test, JsBooleanLiteral) and test.value is True:
        return True
    if not isinstance(test, JsUnaryExpression) or test.operator != '!':
        return False
    inner = test.operand
    if isinstance(inner, JsNumericLiteral) and inner.value == 0:
        return True
    if isinstance(inner, JsUnaryExpression) and inner.operator == '!':
        return True
    return False


def is_valid_identifier(name: str) -> bool:
    return bool(SIMPLE_IDENTIFIER.match(name)) and name not in JS_RESERVED


def is_valid_property_key(name: str) -> bool:
    return bool(SIMPLE_IDENTIFIER.match(name))


def is_simple_expression(node: Node) -> bool:
    """
    Check whether a node is a side-effect-free leaf expression: a literal value, an identifier, or
    a unary operator applied to a literal (e.g. `-42`).
    """
    if is_literal(node) or isinstance(node, JsIdentifier):
        return True
    if isinstance(node, JsUnaryExpression) and node.operand is not None:
        return is_literal(node.operand)
    return False


def is_write_target(node: JsIdentifier) -> bool:
    """
    Return whether this identifier is a write target: the left-hand side of an assignment
    expression, or the iteration variable of a `for-in` / `for-of` statement.
    """
    p = node.parent
    if isinstance(p, JsAssignmentExpression) and p.left is node:
        return True
    if isinstance(p, (JsForInStatement, JsForOfStatement)) and p.left is node:
        return True
    return False


def is_binding_site(node: JsIdentifier) -> bool:
    """
    Return whether this identifier is in a binding position (variable declarator id or function
    declaration name) rather than a reference/read position.
    """
    p = node.parent
    if isinstance(p, JsVariableDeclarator) and p.id is node:
        return True
    if isinstance(p, JsFunctionDeclaration) and p.id is node:
        return True
    return False


def is_reference(node: JsIdentifier) -> bool:
    """
    Whether this identifier reads or writes a binding rather than declaring one or naming something
    the program cannot refer to. `is_use_position` answers the second half and is the one statement
    of it; what is added here are the declarations `is_binding_site` recognizes by shape, so that
    this is the syntactic approximation of `SemanticModel.is_reference` and not a second opinion
    about what a name is.

    Every position naming a property, a label or something across a module boundary is excluded
    exactly as the model excludes it, and the local half of a sourceless export list reads exactly
    as the model reads it. The approximation is in the declarations, and it is a *permissive* one:
    a name bound by a destructuring pattern is written like a read and `is_binding_site` sees only
    a declarator id and a function declaration name, so `var { a } = o` is answered `True` here and
    `False` by the model. A caller that acts on a `True` answer — one that substitutes or renames
    rather than one that grows a conservative set — needs the model.
    """
    return not is_binding_site(node) and is_use_position(node)


def name_is_unbound(node: JsIdentifier, model: SemanticModel) -> bool:
    """
    Whether nothing in the program can have given *node*'s name a meaning of its own, so that it still
    denotes whatever the host supplies under that name. This is the question behind every table of
    well-known names the tool keeps — the values in `GLOBAL_VALUE_NAMES`, the built-ins in
    `BUILTIN_REGISTRY` — because what such a table records is a fact about the *host*, and whether the
    name still reaches the host is a fact about the *scope*.

    There are three ways a name can mean something else, and `resolve` reports only the first:

    - a declaration binds it, anywhere from a parameter or a `catch` clause to an assignment at top
      level, which the model records as an `IMPLICIT_GLOBAL`
    - the lookup crosses a `with` body, where the object may carry a property of that name and reading
      it may even run a getter. `resolve` answers `None` here as well, so `read_has_dynamic_effect` is
      what separates the two cases
    - a direct `eval` declared it, which no reference records at all;
      `free_name_reachable_by_direct_eval` reports the positions that could see such a binding
    """
    return (
        model.resolve(node) is None
        and not model.read_has_dynamic_effect(node)
        and not model.free_name_reachable_by_direct_eval(node)
    )


def names_global_value(node: JsIdentifier, model: SemanticModel) -> bool:
    """
    Whether *node* is one of `GLOBAL_VALUE_NAMES` still denoting its value. Every reader of those
    names has to come through here.
    """
    return node.name in GLOBAL_VALUE_NAMES and name_is_unbound(node, model)


def denoted_value(node: Node | None, model: SemanticModel) -> tuple[bool, Value]:
    """
    The value *node* denotes, as `(True, value)`, or `(False, None)` when nothing decides it. This is
    `extract_literal_value` widened by the two things a literal cannot express: a name that still
    denotes one of the global values, and an operator standing in front of either.
    """
    node = strip_parens(node)
    if node is None:
        return False, None
    if isinstance(node, JsIdentifier):
        if not names_global_value(node, model):
            return False, None
        return True, GLOBAL_VALUE_NAMES[node.name]
    if isinstance(node, JsUnaryExpression):
        apply = UNARY_OPS.get(node.operator)
        if apply is None:
            return False, None
        known, value = denoted_value(node.operand, model)
        return (True, apply(value)) if known else (False, None)
    return extract_literal_value(node)


def allocated_object_type(node: Node | None) -> str | None:
    """
    The `typeof` of the object *node* allocates, when it is a form that always evaluates to a freshly
    created one — `None` otherwise. Such a node has no value this module can extract: an object or
    function expression denotes an identity no literal reproduces, and an array whose elements are
    not themselves literals is the same. Its *type* is nevertheless fixed by the syntax alone, and so
    is its truthiness, since every object is truthy — which is why `!{}`, `typeof {}` and
    `if ([f()])` are all answerable from this one fact.

    Deciding an operand from its allocation says nothing about whether evaluating it is free of
    effects; `[f()]` allocates an array and calls `f`. A caller that discards the operand has to ask
    that separately.
    """
    node = strip_parens(node)
    if isinstance(node, (JsObjectExpression, JsArrayExpression)):
        return 'object'
    if isinstance(node, (JsFunctionExpression, JsArrowFunctionExpression, JsClassExpression)):
        return 'function'
    return None


def is_truthy(node: Node, model: SemanticModel) -> bool | None:
    """
    The JavaScript truthiness of *node*, or `None` when nothing decides it. The AST-node counterpart
    of the value-domain `to_boolean`, which it answers by asking wherever a value is known: the two
    used to agree by inspection, and now agree by construction.

    An allocation is the one case with no value to ask about, and it needs none — every object is
    truthy. This does not gate that on the allocation being effect-free, because deciding truthiness
    does not by itself discard the operand; the caller that goes on to drop it is the one that has to
    keep its effects.
    """
    known, value = denoted_value(node, model)
    if known:
        return to_boolean(value)
    return True if allocated_object_type(node) is not None else None


def is_nullish(node: Node, model: SemanticModel) -> bool | None:
    """
    Whether *node* denotes `null` or `undefined` — the two values `??` treats as absent — or `None`
    when the value it denotes is not decided. A caller has to tell that third answer from `False`:
    `a ?? b` keeps `a` when `a` is known not to be nullish, and must be left alone when nothing is
    known about it at all.
    """
    known, value = denoted_value(node, model)
    if not known:
        return None
    return value is None or value is JS_NULL


def insert_after_prologue(host: Node, statements: list[Statement]) -> None:
    """
    Insert *statements* into the body of *host* directly behind its Directive Prologue, adopting them
    and advancing the tree's mutation counter the one way every splice does.

    Index zero is where a hoisted declaration wants to go and the one place a directive cannot
    survive: a statement written ahead of `'use strict'` ends the prologue before it is reached, and
    the body quietly becomes sloppy — an assignment to an undeclared name stops throwing and starts
    creating a global instead. Behind the prologue is the same position for every purpose a hoist has,
    a directive declaring a mode and binding nothing.

    The whole prologue is stepped over and not merely the Use Strict Directive. A directive the
    language does not recognize is a directive all the same, and a statement wedged in front of one
    ends the run for everything standing behind it.

    *host* is taken rather than its statement list precisely so that this cannot be called the raw
    way: a caller holding only the list can reach `insert` and would not be asking this question.

    The list is read through `statement_list`, which answers for every node a prologue can open —
    including a class static block, which `get_body` does not know. Reading it through a narrower
    accessor than `is_prologue_host` accepts would make this a silent no-op for a host it advertises,
    and a hoist that vanishes leaves the references a pass already rewrote bound to nothing.
    """
    body = statement_list(host)
    if body is None:
        return
    index = len(directive_prologue(host)) if is_prologue_host(host) else 0
    set_body(host, [*body[:index], *statements, *body[index:]])


def get_body(node: Node) -> list[Statement] | None:
    """
    Return the statement body list of a node if it has one (JsScript or JsBlockStatement).
    """
    if isinstance(node, (JsScript, JsBlockStatement)):
        return node.body
    return None


def remove_declarator(declarator: JsVariableDeclarator) -> None:
    """
    Remove a `refinery.lib.scripts.js.model.JsVariableDeclarator` from its parent
    `refinery.lib.scripts.js.model.JsVariableDeclaration`. If the declaration has no remaining
    declarators afterward, remove it from the body as well.
    """
    var_decl = declarator.parent
    _remove_from_parent(declarator)
    if isinstance(var_decl, JsVariableDeclaration) and not var_decl.declarations:
        _remove_from_parent(var_decl)


def extract_identifier_params(params: list) -> list[str] | None:
    """
    Extract plain identifier names from a function's parameter list. Returns `None` if any parameter
    is not a simple `refinery.lib.scripts.js.model.JsIdentifier` (e.g. destructuring or rest
    patterns).
    """
    names: list[str] = []
    for p in params:
        if not isinstance(p, JsIdentifier):
            return None
        names.append(p.name)
    return names


def is_closed_expression(node: Node, allowed_names: set[str]) -> bool:
    """
    Check whether every leaf in the expression tree is either a literal or an identifier whose
    name is in *allowed_names*. This ensures the expression has no free variables.
    """
    children = list(node.children())
    if not children:
        if isinstance(node, JsIdentifier):
            return node.name in allowed_names
        return is_simple_expression(node)
    return all(is_closed_expression(child, allowed_names) for child in children)


def _chain_short_circuits(node: Node | None) -> bool:
    """
    Whether the member/call spine at *node* holds an optional link, so that evaluation can
    short-circuit past every position above it: in `a?.b[c]` the read of `c` happens only when `a`
    is not nullish, and in `f?.().x[c]` only when `f` is not.
    """
    while isinstance(node, (JsMemberExpression, JsCallExpression)):
        if node.optional:
            return True
        node = node.object if isinstance(node, JsMemberExpression) else node.callee
    return False


def _collect_unconditional_evaluation(expr: Node) -> list[str | None]:
    """
    Walk *expr* in evaluation order, descending only into children that are unconditionally
    evaluated — not short-circuit branches, not ternary arms, not a logical assignment's right
    side, and not the positions past an optional link's short-circuit. Return the identifier names
    encountered, in evaluation order,
    interleaved with `None` for every operation that can run code or raise where it applies: a call,
    a `new`, a tagged template, a member access (its getter or setter), and an operator's coercion
    of its operands. An argument substituted at a name after such a marker no longer evaluates
    before the body's operations the way it did at the call site.
    """
    events: list[str | None] = []
    stack: list[Node | None] = [expr]
    while stack:
        node = stack.pop()
        if node is None:
            events.append(None)
            continue
        if isinstance(node, JsIdentifier):
            events.append(node.name)
            continue
        if isinstance(node, (JsCallExpression, JsNewExpression, JsTaggedTemplateExpression)):
            events.append(None)
            continue
        children: list[Node | None]
        if isinstance(node, JsAssignmentExpression):
            children = [c for c in (node.left, node.right) if c is not None]
            if node.operator in ('&&=', '||=', '??='):
                children = children[:1]
            children.append(None)
        elif isinstance(node, JsBinaryExpression):
            children = [c for c in (node.left, node.right) if c is not None]
            children.append(None)
        elif isinstance(node, JsUnaryExpression):
            children = [node.operand] if node.operand is not None else []
            children.append(None)
        elif isinstance(node, JsLogicalExpression):
            children = [node.left] if node.left is not None else []
        elif isinstance(node, JsConditionalExpression):
            children = [node.test] if node.test is not None else []
        elif isinstance(node, JsSequenceExpression):
            children = list(node.expressions)
        elif isinstance(node, JsMemberExpression):
            children = [node.object] if node.object is not None else []
            if (
                node.computed
                and node.property is not None
                and not node.optional
                and not _chain_short_circuits(node.object)
            ):
                children.append(node.property)
            children.append(None)
        else:
            continue
        for child in reversed(children):
            stack.append(child)
    return events


def _param_written(expr: Node, param_names: set[str]) -> bool:
    """
    Whether any of *param_names* occurs at a write position — an assignment, compound-assignment, or
    update target — within *expr*. Such a parameter is not read-only, so substituting the call
    argument for it would place the argument at a write target: assigning to a value (`(11 = 'x')`)
    or, for an lvalue argument, mutating the caller's binding. A wrapper with a written parameter is
    therefore not a pure function of its arguments and must not be inlined by substitution.
    """
    return any(
        isinstance(node, JsIdentifier)
        and node.name in param_names
        and reference_role(node) is not Role.READ
        for node in expr.walk()
    )


class ReturnedExpression(NamedTuple):
    """
    The one expression a call to a function answers, and the names its parameters bind.
    """
    return_expression: Expression
    param_names: list[str]


def arguments_substitutable(arguments: Sequence[Node], param_names: Sequence[str]) -> bool:
    """
    Whether *arguments* stand one for one against *param_names*, so that each parameter has exactly
    one node to be replaced by.

    A spread element denotes however many values the iterable behind it holds, which is not one and
    is not a count the syntax states. Counting it as one argument binds a parameter to the spread
    itself, and substituting that into an expression writes `...xs` where a value belongs.

    A duplicate parameter name breaks the correspondence from the other side: `function (a, a)`
    reads only the last `a`, so substitution by name drops every earlier argument along with its
    evaluation, and any per-name accounting over the parameters counts two positions as one.
    """
    if len(arguments) != len(param_names):
        return False
    if len(set(param_names)) != len(param_names):
        return False
    return not any(isinstance(argument, JsSpreadElement) for argument in arguments)


def expression_a_call_answers(func: JsFunctionNode) -> ReturnedExpression | None:
    """
    The expression a call to *func* answers, where a call answers an expression at all: a body that
    is one `return` of something, parameters that are all plain names, none of them written by that
    expression, and a call that answers the value rather than a wrapper around it.

    Whether that expression may be lifted to any particular call site is a different question and the
    caller's: which names it is allowed to be closed over, and which arguments may be substituted for
    which parameter, differ per site and are not decided here.
    """
    if wraps_return(func):
        return None
    body = func.body
    if not isinstance(body, JsBlockStatement) or len(body.body) != 1:
        return None
    stmt = body.body[0]
    if not isinstance(stmt, JsReturnStatement) or stmt.argument is None:
        return None
    param_names = extract_identifier_params(func.params)
    if param_names is None:
        return None
    if _param_written(stmt.argument, set(param_names)):
        return None
    return ReturnedExpression(stmt.argument, param_names)


def names_used_under_a_nested_scope(expr: Node) -> frozenset[str]:
    """
    The identifier names read at a use position inside a function or class nested in *expr* — or
    in all of *expr*, where *expr* is itself a function or class. A substitution there does not
    evaluate the argument where the call site did: the nested body runs later, any number of times,
    so a non-literal argument would be re-read per run — a binding's later value instead of the
    one the call captured, or a fresh allocation per run where the site produced one value.
    """
    names: set[str] = set()
    for node in expr.walk():
        if isinstance(node, (
            JsFunctionExpression,
            JsArrowFunctionExpression,
            JsFunctionDeclaration,
            JsClassExpression,
            JsClassDeclaration,
        )):
            names.update(
                n.name for n in node.walk()
                if isinstance(n, JsIdentifier) and is_use_position(n)
            )
    return frozenset(names)


def is_safe_iife_inline(
    expr: Node,
    param_names: Sequence[str],
    call_args: Sequence[Node],
    call_pure: Callable[..., bool] | None = None,
    read_effect: Callable[[Node], bool] | None = None,
    call_established: Callable[..., bool] | None = None,
) -> bool:
    """
    Verify that substituting IIFE arguments into the body expression preserves evaluation semantics.
    An argument used more than once must be a simple, identity-stable expression — a literal or a bare
    identifier: duplicating a fresh array/object/function literal (or a call) would split one value into
    distinct copies and break an identity comparison such as `x === x`. An effectful argument must
    additionally be used exactly once, in an unconditionally-evaluated position, and in declaration
    order relative to other effectful arguments, so its side effect is neither dropped, duplicated, nor
    reordered.

    A parameter read inside a function or class nested in the body admits only a literal argument:
    the nested body runs after the call, any number of times, so any other argument would be
    re-evaluated per run — `function (a) { return () => a; }` called with `x` must answer a
    closure over the value `x` held at the call, not a live read of `x`, and called with `[1]` must
    answer the one array the site produced, not a fresh one per run.

    An effect is also ordered against everything that can observe it. Once any argument is
    effectful, every argument that is not a literal is held to the same evaluation-order discipline
    — at the call site each was evaluated once, before the body and in declaration order, and an
    identifier read moved across another argument's write reads a different value (`o.m(x, x = 5)`
    with a body of `b + a`). And every effectful argument must evaluate before the body's first own
    operation — a call, a member access, an operator's coercion — because at the call site all
    arguments ran before the body did, and a body operation is code the substitution cannot see
    across (`o.m(f, g())` with a body of `a() + b` would run `f` before `g`). When *call_pure* is
    given (an
    `refinery.lib.scripts.js.analysis.effects.EffectModel.is_pure_call`), a call argument it proves pure
    counts as side-effect-free for the ordering rules — but only when *call_established* also certifies
    its callee is in place before the call runs, and, being a call, it is not simple, so it is still not
    duplicated. When *read_effect* is given (a
    `refinery.lib.scripts.js.analysis.model.SemanticModel.read_has_dynamic_effect`), an argument reading
    a bare name through a `with` body's dynamic scope counts as effectful — the read may fire the `with`
    object's getter or throw — so it too must not be dropped or reordered.

    Identity stability under duplication is the *may*-allocate direction and must not be merged with
    `EffectModel._fresh_kind`, which is *must*-allocate: a fresh literal is the thing this refuses to
    duplicate and the thing that predicate admits. The two agree on the syntax and disagree on the verdict,
    which is exactly why sharing one predicate between them would be wrong.
    """
    if _param_written(expr, set(param_names)):
        return False
    use_counts = Counter(
        n.name for n in expr.walk()
        if isinstance(n, JsIdentifier) and is_use_position(n)
    )
    for i, arg in enumerate(call_args):
        if use_counts[param_names[i]] > 1 and not is_simple_expression(arg):
            return False
    deferred = names_used_under_a_nested_scope(expr)
    for i, arg in enumerate(call_args):
        if param_names[i] in deferred and not is_literal(arg):
            return False
    effectful_indices = [
        i for i, arg in enumerate(call_args)
        if not side_effect_free(
            arg, call_pure=call_pure, read_effect=read_effect, call_established=call_established,
        )
    ]
    if not effectful_indices:
        return True
    effectful = set(effectful_indices)
    for i in effectful_indices:
        if use_counts[param_names[i]] != 1:
            return False
    events = _collect_unconditional_evaluation(expr)
    param_order = {name: i for i, name in enumerate(param_names)}
    exposed = {i for i, arg in enumerate(call_args) if not is_literal(arg)}
    order: list[int] = []
    first_operation: int | None = None
    for event in events:
        if event is None:
            if first_operation is None:
                first_operation = len(order)
            continue
        index = param_order.get(event)
        if index is not None and index in exposed:
            order.append(index)
    for i in exposed:
        expected = 1 if i in effectful else use_counts[param_names[i]]
        if sum(1 for k in order if k == i) != expected:
            return False
    for p, i in enumerate(order):
        if i in effectful and first_operation is not None and p >= first_operation:
            return False
        for q, j in enumerate(order):
            if j in effectful and i != j and (i < j) != (p < q):
                return False
    return True


def substitute_params(
    expression: Node,
    params: Sequence[Node],
    arguments: Sequence[Node],
    transformer: Transformer | None = None,
) -> Node:
    """
    Deep-clone *expression* and replace every reference to one of the function parameters *params* with
    a clone of the positionally corresponding node from *arguments*. Only identifiers the parameter
    actually binds are replaced: a non-computed property key (the `a` in `b.a`) names a property, and a
    function or class nested in *expression* that reintroduces a parameter's name keeps its own
    identifiers rather than the outer parameter's. When *expression* nests no scope, no name under it
    can be rebound, so a parameter's references are exactly the use-position identifiers carrying its
    name and are substituted directly; only when it does nest a scope is a semantic model built to
    resolve each occurrence against the binding it reads. When *transformer* is given, that model is
    taken from its shared analysis cache; otherwise it is built standalone.
    """
    cloned = _clone_node(expression)
    mapping = {
        param.name: argument
        for param, argument in zip(params, arguments)
        if isinstance(param, JsIdentifier)
    }
    if isinstance(expression, JsIdentifier):
        if expression.name in mapping and is_use_position(expression):
            return _clone_node(mapping[expression.name])
        return cloned
    if not _introduces_nested_scope(expression):
        for node in list(cloned.walk()):
            if isinstance(node, JsIdentifier) and node.name in mapping and is_use_position(node):
                substitute_use_position(node, _clone_node(mapping[node.name]))
        return cloned
    root = expression
    while root.parent is not None:
        root = root.parent
    assert isinstance(root, JsScript)
    if transformer is None:
        model = build_semantic_model(root)
    else:
        model = model_cache(transformer, root).model
    bindings = {
        param.name: model.binding_of(param)
        for param in params
        if isinstance(param, JsIdentifier)
    }
    for original, clone in zip(list(expression.walk()), list(cloned.walk())):
        if not isinstance(original, JsIdentifier) or original.name not in mapping:
            continue
        binding = bindings.get(original.name)
        if binding is None or model.resolve(original) is not binding:
            continue
        if isinstance(clone, JsIdentifier) and clone.name == original.name:
            substitute_use_position(clone, _clone_node(mapping[original.name]))
    return cloned


def _introduces_nested_scope(node: Node) -> bool:
    """
    Whether the subtree at *node* contains a function or class — a scope in which an enclosing
    function's parameter name could be rebound. When it does not, no identifier under *node* can shadow
    such a parameter, so the parameter's references are exactly the use-position identifiers that carry
    its name.
    """
    return any(
        isinstance(child, (
            JsFunctionExpression,
            JsArrowFunctionExpression,
            JsFunctionDeclaration,
            JsClassExpression,
            JsClassDeclaration,
        ))
        for child in node.walk()
    )


def spelled_for_the_callee_position(position: Node, replacement: Node) -> Node:
    """
    The expression to write where *position* stands so that *replacement* reaches its value there
    without changing the call it lands in. Where *position* is a call's callee (or a tagged
    template's tag) and *replacement*'s own spelling as a callee would mean something a neutral
    spelling does not — a member access binds `this` to its object, a bare `eval` runs its text in
    the caller's own scope — the value is reached behind `(0, ...)`, which invokes it with no
    receiver and no direct-eval effect, exactly as the name that stood there did. Anywhere else, and
    for any other value, *replacement* is written as it is.

    Every pass that drops a value into a slot another node occupied shares this, so a member or a
    bare `eval` cannot become a receiver-bound or direct call at one substitution site while being
    neutralized at another.
    """
    if is_invocation_target(position) and callee_form_sensitive(replacement):
        assert isinstance(replacement, Expression)
        return JsSequenceExpression(expressions=[
            JsNumericLiteral(value=0, raw='0'),
            replacement,
        ])
    return replacement


def substitute_use_position(node: JsIdentifier, replacement: Node, *, as_spelled: bool = False) -> bool:
    """
    Put *replacement* where *node* reads a binding, and report whether it did. That *node* reads one
    is the caller's to establish, and it takes a model: a name bound by a destructuring pattern is
    written exactly like a read, so `SemanticModel.is_reference` answers it and the syntactic
    `is_reference` only approximates it, in the permissive direction.

    A name the program cannot refer to is not such a read and is left alone. `is_use_position` says
    which positions those are — the four that spell a property, and the label, the import specifier
    halves, the re-export name and the name an export list exports under besides — and substituting
    into one of them is not a rename but a different program: a replacement with no identifier
    spelling leaves text no engine parses (`o.5`, `{ -2: 1 }`), and a numeral put where a label
    stood leaves `5: while (0) break 5;`.

    The local half of an export list without a `from` clause is a read that predicate does record,
    and still no slot a replacement can stand in, because a list exports bindings and never values:
    `export { 1 };` is a module no engine links. It is declined here instead, which is what keeps
    the declaration the list reads alive under an inliner that substitutes every read it can.

    A shorthand property is the one position that is both at once. `{ a }` means `{ a: a }`, so the
    read is the value half and the key must keep the name it wrote; the property is written out in
    full and only the value replaced. Which half a caller hands over depends on where the tree came
    from — the parser builds one node for both and a clone builds two — so the value is asked for
    by identity rather than assumed, and a caller walking both halves substitutes once whichever
    order it visits them in. A shorthand carrying a computed key is a shape no source spells, which
    the parser builds only where it read a program no engine reads, and it is refused rather than
    written out as one of the two things it might have meant.

    `{ __proto__ }` is the one shorthand that does not mean `{ __proto__: __proto__ }`: written out
    with the colon it sets the object's prototype and gives it no property of that name at all, so
    `Object.keys({ __proto__ })` answers one name and `Object.keys({ __proto__: v })` answers none.
    Writing that one out is a different program, so it is left as it stands.

    A replacement standing where a callee stands is written behind `(0, ...)` where its own spelling
    as a callee would change the call, which `spelled_for_the_callee_position` decides and every
    substituting pass shares: the name that stood there invoked its value with no receiver and no
    direct-eval effect, whatever the value was, and the sequence spells exactly that call. That is
    the reading for a value put where a read stood. A caller that instead re-spells the reference
    itself — the flattening recovery qualifying a name to the namespaced home it was recovered
    from, whose member form is the very call being restored — passes `as_spelled=True` and takes
    the form it wrote.

    The answer is what a caller announcing a change has to read. A pass that reports one for a
    substitution this declined is a pass that reports one every round, and the fixpoint it sits in
    never reaches one. Nothing is written until the slot the replacement goes into is known, so a
    declined substitution leaves the tree exactly as it was.
    """
    parent = node.parent
    if not is_use_position(node):
        return False
    if isinstance(parent, JsExportSpecifier):
        return False
    if isinstance(parent, JsProperty) and parent.shorthand:
        if parent.computed or parent.value is not node:
            return False
        key = parent.key
        if isinstance(key, JsIdentifier) and key.name == PROTO_KEY:
            return False
        if key is node:
            set_child(parent, 'key', _clone_node(node))
        set_child(parent, 'value', replacement)
        set_value(parent, 'shorthand', False)
        return True
    if not as_spelled:
        replacement = spelled_for_the_callee_position(node, replacement)
    return _replace_in_parent(node, replacement)


def _effect_oracles(
    transformer: Transformer,
    node: Node,
) -> tuple[
    Callable[[JsCallExpression | JsNewExpression], bool] | None,
    Callable[[Node], bool] | None,
    Callable[[JsCallExpression | JsNewExpression], bool] | None,
]:
    """
    The purity, dynamic-read, and establishment oracles `is_safe_iife_inline` sharpens its
    side-effect reading with, answering from *transformer*'s shared analysis cache over the script
    holding *node* — or three `None` when *node* stands in no script, leaving the purely syntactic
    reading. Each oracle reads the cache at the moment it is invoked rather than binding a model
    here: the models are built only when an argument actually needs one, and a caller that mutates
    the tree between inline attempts pays for a rebuild only where an oracle is consulted after the
    mutation.
    """
    root = tree_root(node)
    if not isinstance(root, JsScript):
        return None, None, None

    def call_pure(call: JsCallExpression | JsNewExpression) -> bool:
        return model_cache(transformer, root).effects.is_pure_call(call)

    def read_effect(read: Node) -> bool:
        return model_cache(transformer, root).model.read_has_dynamic_effect(read)

    def call_established(call: JsCallExpression | JsNewExpression) -> bool:
        return model_cache(transformer, root).call_established(call)

    return call_pure, read_effect, call_established


def try_inline_trivial_function(
    func: JsFunctionExpression,
    call_args: Sequence[Node],
    *,
    transformer: Transformer,
) -> Node | None:
    """
    If *func* is a trivial wrapper (single return whose expression uses only the function's
    parameters), substitute call-site arguments into a clone of the return expression. Returns the
    inlined expression, or `None` when the function is not such a wrapper or when substituting the
    arguments would change what they do.

    Admission is `is_safe_iife_inline`, which is where the rule against dropping, duplicating,
    conditionalizing, or reordering an argument's evaluation lives; the effect oracles sharpening
    it are taken from *transformer*'s shared analysis cache, admitting a provably pure call
    argument the syntactic reading counts as effectful. The object fold and the IIFE fold both
    inline through this function. It is not the only substitution of call arguments into a body:
    the call-wrapper inliner and the evaluator's irreducible-call splice admit under their own,
    differently shaped rules.

    Which functions have a return expression to inline at all is `expression_a_call_answers`, which
    is where the refusal to inline an async function or a generator lives.
    """
    answered = expression_a_call_answers(func)
    if answered is None:
        return None
    expr, param_names = answered
    if not arguments_substitutable(call_args, param_names):
        return None
    if not is_closed_expression(expr, set(param_names)):
        return None
    call_pure, read_effect, call_established = _effect_oracles(transformer, func)
    if not is_safe_iife_inline(
        expr, param_names, call_args, call_pure, read_effect, call_established,
    ):
        return None
    return substitute_params(expr, func.params, call_args, transformer=transformer)


def walk_scope(root: Node, *, include_root_body: bool = False) -> Iterator[Node]:
    """
    Walk the AST under *root* without descending into nested function bodies. Function boundary
    nodes are yielded (so their identifiers can be inspected) but their subtrees are suppressed.
    Children are visited in source order.

    When *include_root_body* is True and *root* is itself a function, its body IS traversed (only
    inner functions are skipped). This is useful when *root* represents the scope being analyzed.
    """
    stack: list[Node] = [root]
    while stack:
        node = stack.pop()
        yield node
        if isinstance(node, FUNCTION_NODES):
            if not (include_root_body and node is root):
                continue
        cc = _compute_children(node)
        stack.extend(reversed(cc))


def collect_identifier_names(node: Node) -> set[str]:
    """
    Collect the names of all `refinery.lib.scripts.js.model.JsIdentifier` nodes in the subtree
    rooted at *node*.
    """
    return {n.name for n in node.walk() if isinstance(n, JsIdentifier)}


def find_enclosing_body(node: Node) -> list[Statement] | None:
    """
    Walk up parent pointers from *node* to find the body list that directly contains it. Returns the
    `body` attribute of the nearest `refinery.lib.scripts.js.model.JsBlockStatement` or
    `refinery.lib.scripts.js.model.JsScript` ancestor whose body list includes *node* (or an
    ancestor of *node*).
    """
    child = node
    parent = node.parent
    while parent is not None:
        if isinstance(parent, (JsBlockStatement, JsScript)):
            if child in parent.body:
                return parent.body
        child = parent
        parent = parent.parent
    return None


def function_binds_name(func: Node, name: str) -> bool:
    """
    Check if a function creates a local binding for `name` (parameter, function name, or var
    declaration anywhere in its body — excluding nested functions).
    """
    if isinstance(func, JsFunctionDeclaration) and func.id is not None and func.id.name == name:
        return True
    for p in (getattr(func, 'params', None) or []):
        if isinstance(p, JsIdentifier) and p.name == name:
            return True
    body = getattr(func, 'body', None)
    if not isinstance(body, JsBlockStatement):
        return False
    stack: list[Node] = [body]
    while stack:
        node = stack.pop()
        if isinstance(node, FUNCTION_NODES):
            continue
        if isinstance(node, JsVariableDeclaration) and node.kind == JsVarKind.VAR:
            for decl in node.declarations:
                if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier):
                    if decl.id.name == name:
                        return True
        for child in node.children():
            stack.append(child)
    return False


def references_receiver_this(root: Node) -> bool:
    """
    Return whether relocating *root* would change the meaning of a `this` or `super` reference bound
    to its current receiver. Both are receiver-bound: `this` to the call's receiver and `super` to the
    method's home object, and `super` is a syntax error outside a method, so a value that uses either
    cannot be detached from its containing method. The receiver boundary is `walk_receiver_scope`:
    arrow functions inherit both lexically and are traversed; regular and generator functions nested
    below *root* rebind `this` (and cannot name the outer `super`) and are not descended into; a class
    rebinds `this` for its method bodies and field initializers, so only its `extends` clause and
    computed member keys are traversed. An identifier `super` that merely names a property (`x.super`)
    or an object-literal key is not a receiver-bound reference, so it is gated on `is_use_position` and
    does not count.
    """
    return any(
        isinstance(node, JsThisExpression)
        or (isinstance(node, JsIdentifier) and node.name == 'super' and is_use_position(node))
        for node in walk_receiver_scope(root)
    )


def is_receiver_binding_call(member: Node) -> bool:
    """
    Return whether *member* is evaluated in a position where the resulting call binds its object as the
    call's `this` receiver: the callee of a call `m(...)` (including an optional call `m?.(...)`) or the
    tag of a tagged template. Transparent parentheses are seen through; a comma-sequence callee
    `(0, m)()` yields a value rather than a Reference and so detaches the receiver, as does `new m()`
    (a constructor receives a fresh `this`). This is the dual of `references_receiver_this`: detaching
    *member* from its object preserves `this` unless the call site binds a receiver and the callee
    observes one.
    """
    node = member
    parent = node.parent
    while isinstance(parent, JsParenthesizedExpression):
        node, parent = parent, parent.parent
    if isinstance(parent, JsCallExpression) and parent.callee is node:
        return True
    if isinstance(parent, JsTaggedTemplateExpression) and parent.tag is node:
        return True
    return False


def rewrite_receiver_this_to_global(root: Node) -> bool:
    """
    Replace every `this` bound to *root*'s own receiver with a `globalThis` identifier, returning whether
    any replacement was made. The rewrite descends the receiver boundary `walk_receiver_scope` defines —
    through arrow functions and a class's `extends` clause and computed keys, but not into a nested
    regular or generator function, whose `this` is its own — so only *root*'s own `this` is rewritten.
    A caller uses this where *root* is invoked with no receiver, so its `this` is the global object: a
    `Function`-constructed body, or a recognized global-object finder whose `… || this` fallback yields
    the global. The synthesized `globalThis` identifiers stay subject to whatever free-name or shadow
    check the caller applies, so a binding named `globalThis` in scope declines the rewrite at the
    caller's discretion.
    """
    changed = False
    for node in list(walk_receiver_scope(root)):
        if isinstance(node, JsThisExpression):
            _replace_in_parent(node, JsIdentifier(name='globalThis'))
            changed = True
    return changed


def binding_has_references(
    model: SemanticModel,
    binding: Binding | None,
    *,
    exclude: Node | None = None,
    exclude_ids: set[int] | None = None,
) -> bool:
    """
    Whether *binding* is still read or written outside an excluded region. Resolution is
    binding-precise: only references that actually resolve to *binding* count, so a same-named
    variable in another scope never keeps it alive — this subsumes the name-based shadow check that
    `has_remaining_references` performs textually. A `None` binding (a name the model cannot resolve
    to a declaration) is conservatively reported as still referenced. References within the subtree
    of *exclude*, or whose node identity is in *exclude_ids*, are not counted.
    """
    if binding is None:
        return True
    for ref in model.references(binding, exclude=exclude):
        if exclude_ids and id(ref) in exclude_ids:
            continue
        return True
    return False


def a_host_reaches_the_binding(model: SemanticModel, binding: Binding, options: object) -> bool:
    """
    Whether *binding* is one the analyst declared a host reaches by name — a function the host
    invokes, or a global the host reads or may have rewritten — and one a host could actually reach:
    a top-level declaration under the script execution model. A pattern alone does not decide it:
    `refinery.lib.scripts.js.analysis.model.SemanticModel.reaches_global_object` is what keeps a
    pattern from protecting a nested binding, a `let`, or anything at all under the module model,
    where a top-level declaration never becomes a property of the global object.

    A pass that could delete such a declaration, fold a read of one into its value, or relocate one
    out of the global scope asks here before doing so, so that the answer is one predicate's rather
    than each pass's own. A pass whose removals only ever fall on obfuscation machinery — a string
    table, a control-flow state, an anti-debug guard, none of which an analyst names an entrypoint —
    never reaches a binding this protects, and does not consult it.
    """
    if not is_host_entrypoint(options, binding.name):
        return False
    return model.reaches_global_object(binding, module_scope=module_execution(options))


def nothing_still_names(model: SemanticModel, removed: Sequence[Node]) -> bool:
    """
    Whether deleting the nodes of *removed* would leave nothing naming what it takes away. A binding
    whose declarations all lie inside these nodes ceases to exist with them, so a reference to one
    from outside is a name the output would no longer declare, while a reference from within is one
    the deletion carries off and does not count. A name that is declared elsewhere as well survives
    the deletion and is not asked about.

    This is the question a pass asks before deleting the machinery it has finished reading: the calls
    it could answer are gone, and what is left decides whether the machinery may go too. Asking it of
    the model rather than of the call shapes the pass recognizes is what makes the answer cover a call
    the pass could not resolve, a name handed to something else, and an alias taken through a form the
    pass does not match. None of those is a call the pass would find, and each of them is a reference
    the model reports.

    A name inside a `with` body is asked for separately, because it resolves to no binding at all:
    the object supplies it or the binding does, and which one is a runtime question. It is counted as
    a reference here, since a removal made on the strength of it denoting the object is a removal that
    strands it whenever the object does not carry the property.
    """
    inside = {id(node) for root in removed for node in root.walk()}
    asked: set[Binding] = set()
    for root in removed:
        for node in root.walk():
            if not isinstance(node, JsIdentifier):
                continue
            binding = model.binding_of(node)
            if binding is None or binding in asked:
                continue
            asked.add(binding)
            if any(id(site) not in inside for site in binding.declarations):
                continue
            if binding_has_references(model, binding, exclude_ids=inside):
                return False
            if any(id(ref) not in inside for ref in model.dynamic_references(binding)):
                return False
    return True


class BodyProcessingTransformer(Transformer):
    """
    Intermediate base for JS deobfuscation transformers that process the statement list (body) of
    `refinery.lib.scripts.js.model.JsScript` and `refinery.lib.scripts.js.model.JsBlockStatement`
    nodes after visiting children. Subclasses override `_process_body`.
    """

    def visit_JsScript(self, node: JsScript):
        self.generic_visit(node)
        self._process_body(node, node.body)
        return None

    def visit_JsBlockStatement(self, node: JsBlockStatement):
        self.generic_visit(node)
        self._process_body(node, node.body)
        return None

    def _process_body(self, parent: Node, body: list[Statement]) -> None:
        raise NotImplementedError

    def _replace_body(self, parent: Node, replacement: list[Statement]) -> None:
        """
        Replace the body of *parent* with *replacement* through `refinery.lib.scripts.set_body`, so
        the adoption of the new statements and the advance of the tree's mutation counter happen the
        one way every splice performs them, and mark the transformer as changed.

        A Use Strict Directive the old body opened with is carried over to the head of the new one. A
        replacement drops a directive without removing anything — nothing is deleted, the statement
        simply is not among the statements handed in — so the rule that no removal may drop one has to
        be stated here as well, and stated as a repair rather than a refusal: a pass that has already
        rewritten the references it is about to install cannot be declined at this point without
        shipping a half-edited tree.
        """
        set_body(parent, keeping_directives(parent, replacement))
        self.mark_changed()


class ScopeProcessingTransformer(Transformer):
    """
    Base for transforms that process at function-scope boundaries. Visits
    `refinery.lib.scripts.js.model.JsScript` and each function body
    (`refinery.lib.scripts.js.model.JsFunctionDeclaration`,
    `refinery.lib.scripts.js.model.JsFunctionExpression`,
    `refinery.lib.scripts.js.model.JsArrowFunctionExpression`). Subclasses may override either
    `_process_scope` or `_process_scope_body`.
    """

    def visit_JsScript(self, node: JsScript):
        self.generic_visit(node)
        self._process_scope(node)
        return None

    def visit_JsFunctionDeclaration(self, node: JsFunctionDeclaration):
        self.generic_visit(node)
        if isinstance(node.body, JsBlockStatement):
            self._process_scope(node.body)
        return None

    def visit_JsFunctionExpression(self, node: JsFunctionExpression):
        self.generic_visit(node)
        if isinstance(node.body, JsBlockStatement):
            self._process_scope(node.body)
        return None

    def visit_JsArrowFunctionExpression(self, node: JsArrowFunctionExpression):
        self.generic_visit(node)
        if isinstance(node.body, JsBlockStatement):
            self._process_scope(node.body)
        return None

    def _process_scope(self, scope: Node) -> None:
        """
        Receives the raw scope node (`refinery.lib.scripts.js.model.JsScript` or
        `refinery.lib.scripts.js.model.JsBlockStatement`).
        """
        body = get_body(scope)
        if body is not None:
            self._process_scope_body(scope, body)

    def _process_scope_body(self, scope: Node, body: list) -> None:
        """
        Receives the scope node and its `body` list. The `_process_scope` method extracts the body
        and delegates here.
        """
        raise NotImplementedError


class ScriptLevelTransformer(Transformer):
    """
    Base for transforms that process the entire script manually rather than using the recursive
    visitor. Subclasses override `_process_script`.
    """

    def visit_JsScript(self, node: JsScript):
        self._process_script(node)
        return None

    def generic_visit(self, node: Node):
        pass

    def _process_script(self, node: JsScript) -> None:
        raise NotImplementedError

Global variables

var SAME_REALM_GLOBAL_OBJECT_ALIASES

The spellings of the global object that name this realm's, which is the one a file's own top-level declarations are properties of. refinery.lib.scripts.js.analysis.model knows two more, top and frames, deliberately absent here: which document's global object each names depends on where the file runs — top is another document's in a framed one, and both are read by the document a frame is embedded in — so a removal must not trust either to be this realm's, since deleting a write for want of a reader in this file deletes one another document reads. A removal keys on this set; a reading of what code may reach keys on the wider one.

var OBJECT_PROTOTYPE_MEMBERS

The members every plain object inherits from Object.prototype. An access of one of these names on an object that does not own it resolves through the prototype rather than to undefined, so a fold that treats an absent own-property as undefined must leave these intact.

var ARRAY_PROTOTYPE_METHODS

The callable members of String.prototype and Array.prototype, enumerated from a real engine rather than from the subset this package implements. Reading one of these names yields the method itself, so a reader that answers undefined for the ones we cannot evaluate would contradict typeof; membership and evaluability are separate questions.

var SEQUENCE_DATA_PROPERTIES

The non-callable inherited properties of a string or array. length is the only one, which is why it must never be reached through a method registry: 'abc'.length is the number 3 and 'abc'.length() is a TypeError, whereas a registry entry would answer 3 to both.

var PROTOTYPE_CHAIN_PROPERTIES

The two properties that expose the prototype chain itself. Both exist on every value, so answering undefined for them is wrong, but modelling them would hand out the Function constructor that [].constructor.constructor('...')() reflection depends on. They are therefore left unevaluated.

var GLOBAL_VALUE_NAMES

The three global names that carry a value no literal spells, so that an operand written with one of them holds a value extract_literal_value() cannot report and a caller that wants it looks here.

What makes reading them safe is not the specification. ES5 made them non-writable and non-configurable, but ES3 did not — its own Annex E lists the change as an intentional incompatibility — and ES3 is what Windows Script Host runs, which is the dialect of the .js, .wsf and .hta droppers this tool exists for. Measured under cscript, JScript 11.0: undefined = 'CLOBBERED'<code> sticks, and </code>typeof undefined<code> becomes </code>'string'<code> afterwards. Clobbering </code>undefined is a live obfuscation technique precisely because it breaks a naive === undefined check.

What makes reading them safe is the binding analysis. A program that assigns one of these names at top level creates an IMPLICIT_GLOBAL binding the model records, and denoted_value() — the only reader that may consult this table — refuses any name the model resolves. All three are also ordinary identifiers as far as scoping goes: a parameter, a let or a var of the same name shadows them, and that is the same refusal. Reading the table without asking the model is a bug.

var PROTO_KEY

The one property key whose plain spelling in an object literal does not denote an own property. {__proto__: v} and {'__proto__': v} install v as the prototype and leave the object with no own property at all, whereas {['__proto__']: v} — and JSON.parse — create an ordinary own property of that name. A runtime object modelled as a Python dict holds only own data properties, so a __proto__ entry in such a dict can only have come from one of the latter two, and may only be rendered back as the computed form.

var BINARY_OPS

JavaScript's binary operators over two numbers. Every entry assumes both operands have already been coerced, which is why + is operator.add here: on numbers that is what + means, but on a value that may be a string or an object + is a different operator entirely — it applies ToPrimitive to both sides and concatenates when either is a string. A caller holding uncoerced values must not reach this table for +; the interpreter routes every operator through JsInterpreter._apply_binary, which resolves the string cases before delegating here.

var UNARY_OPS

JavaScript's unary operators that are functions of their operand's value alone. Each is total over the value domain — the coercions answer for every value rather than refusing any — so a caller holding a value needs no per-operator guard, and a caller holding a syntax tree needs only to obtain the value.

delete is absent because it is not one of these: its result depends on the operand's reference rather than its value, and evaluating it changes the object it names. A pass that wants to fold a delete has to reason about that object, which is a question about the program and not about a value, so the absence is what keeps this table from being asked it.

void is a member despite discarding its operand, because discarding a value is still a function of it. The caller remains responsible for evaluating the operand: void f() is undefined and calls f, and this table only supplies the first half.

var LOGICAL_ASSIGNMENT_OPS

The assignment operators that short-circuit. Unlike every other compound assignment, these evaluate their right operand only when the target's existing value does not already decide the result, and perform no store when it does — a distinction JavaScript makes observable through a setter or a frozen object. They therefore have no entry in BINARY_OPS, whose members are total functions of both operands.

Functions

def converts_uninterceptably(value)

Whether converting value to a string — or to the key of a property access, which converts it the same way — is an internal operation no program can replace. Every primitive is: String(1) and o[1] answer what the specification says whatever the file did to the prototypes. An array and an object are not, because their conversion runs Array.prototype.join and Object.prototype.toString, so a file that replaces either decides what o[[1]] reads. This is the rule concat_string states for the operand of a +, asked of a value rather than a node, and a fold that converts a value without asking it computes a key the engine never would.

Expand source code Browse git
def converts_uninterceptably(value: Value) -> bool:
    """
    Whether converting *value* to a string — or to the key of a property access, which converts it
    the same way — is an internal operation no program can replace. Every primitive is: `String(1)`
    and `o[1]` answer what the specification says whatever the file did to the prototypes. An array
    and an object are not, because their conversion runs `Array.prototype.join` and
    `Object.prototype.toString`, so a file that replaces either decides what `o[[1]]` reads. This is
    the rule `concat_string` states for the operand of a `+`, asked of a value rather than a node,
    and a fold that converts a value without asking it computes a key the engine never would.
    """
    return value is None or value is JS_NULL or isinstance(value, (str, int, float, bool))
def own_property_keys(obj)

The own keys of obj in the order JavaScript enumerates them: the array indices first, ascending by value, then every remaining key in the order it was created.

A dict already preserves creation order, which is the whole of the second half of the rule and the reason enumeration reads as insertion order for as long as no index is present. An index is what breaks it, and the break is not a detail of ordering: a lookup table written with numeric keys comes back in an order the source does not show, so a program that walks one and a fold that walks the same one must agree about which key is first.

Which keys are indices is the distinction canonical_array_index() draws, so a key an array could not have used as an index — '01', '-1', '4294967295' — sorts with the names and not with the numbers.

This is the own half of enumeration, like read_data_property() is the own half of a read: it answers about the keys obj holds and nothing about the ones a prototype would contribute.

Expand source code Browse git
def own_property_keys(obj: dict) -> list[str]:
    """
    The own keys of *obj* in the order JavaScript enumerates them: the array indices first, ascending
    by value, then every remaining key in the order it was created.

    A `dict` already preserves creation order, which is the whole of the second half of the rule and
    the reason enumeration reads as insertion order for as long as no index is present. An index is
    what breaks it, and the break is not a detail of ordering: a lookup table written with numeric
    keys comes back in an order the source does not show, so a program that walks one and a fold that
    walks the same one must agree about which key is first.

    Which keys are indices is the distinction `refinery.lib.scripts.js.numbers.canonical_array_index`
    draws, so a key an array could not have used as an index — `'01'`, `'-1'`, `'4294967295'` — sorts
    with the names and not with the numbers.

    This is the own half of enumeration, like `read_data_property` is the own half of a read: it
    answers about the keys *obj* holds and nothing about the ones a prototype would contribute.
    """
    indices: list[tuple[int, str]] = []
    names: list[str] = []
    for key in obj:
        index = canonical_array_index(key)
        if index is None:
            names.append(key)
        else:
            indices.append((index, key))
    indices.sort()
    return [key for _, key in indices] + names
def read_data_property(obj, key)

Read key off obj as far as the value itself decides it: the own data properties of a string, an array, and a plain object, which are length, a canonical index, and a present key. Every other key is NOT_DATA — a method name, an inherited name, a missing key of an object, or any key at all of a value with no own slots to read, such as a number.

The read is the own half of a property access and answers nothing about the prototype chain, which is where the outcome is decided for a NOT_DATA key and is why this takes no model. What it does answer, it answers alone: length and an index within range are own properties of a string or array, so no prototype can be consulted for them and none can shadow them.

A list holds a value at every index it has, which is what makes the second of those answerable without a model. An array literal's elision, a length grown past its end and a store to an index beyond it all leave a slot the prototype answers for and the value does not, so a list holding one would make FOUND a lie: [1, , 3][1] reads Array.prototype[1] where [1, undefined, 3][1] reads the element. Nothing here can tell those apart, which is why the producer refuses to build such a list at all rather than this reporting on one.

obj must hold a string the way JavaScript does, as UTF-16 code units — the form the lexer gives a literal and the builtin registry gives a produced string. A string of code points read here counts an astral character once where JavaScript counts it twice, and answers length and every index after it one too low.

Expand source code Browse git
def read_data_property(obj: Value, key: str) -> tuple[MemberRead, Value]:
    """
    Read *key* off *obj* as far as the value itself decides it: the own data properties of a string,
    an array, and a plain object, which are `length`, a canonical index, and a present key. Every
    other key is `NOT_DATA` — a method name, an inherited name, a missing key of an object, or any
    key at all of a value with no own slots to read, such as a number.

    The read is the *own* half of a property access and answers nothing about the prototype chain,
    which is where the outcome is decided for a `NOT_DATA` key and is why this takes no model. What
    it does answer, it answers alone: `length` and an index within range are own properties of a
    string or array, so no prototype can be consulted for them and none can shadow them.

    A list holds a value at every index it has, which is what makes the second of those answerable
    without a model. An array literal's elision, a `length` grown past its end and a store to an
    index beyond it all leave a slot the prototype answers for and the value does not, so a list
    holding one would make `FOUND` a lie: `[1, , 3][1]` reads `Array.prototype[1]` where
    `[1, undefined, 3][1]` reads the element. Nothing here can tell those apart, which is why the
    producer refuses to build such a list at all rather than this reporting on one.

    *obj* must hold a string the way JavaScript does, as UTF-16 code units — the form the lexer gives
    a literal and the builtin registry gives a produced string. A string of code points read here
    counts an astral character once where JavaScript counts it twice, and answers `length` and every
    index after it one too low.
    """
    if isinstance(obj, (str, list)):
        if key in SEQUENCE_DATA_PROPERTIES:
            return MemberRead.FOUND, len(obj)
        index = canonical_array_index(key)
        if index is not None:
            if 0 <= index < len(obj):
                return MemberRead.FOUND, obj[index]
            return MemberRead.ABSENT, None
    elif isinstance(obj, dict) and key in obj:
        return MemberRead.FOUND, obj[key]
    return MemberRead.NOT_DATA, None
def property_absent_from_written_chain(effects, value_type, key)

property_provably_absent() for a caller whose cost of refusing is a whole pass rather than one fold. The chain is asked EffectModel.chain_roots_unwritten instead of EffectModel.read_chain_intact, so a file carrying a reflective surface is not treated as one that wrote a prototype. See the note on chain_roots_unwritten for what that trade is; the short of it is that such a surface is what the real obfuscated files carry, and clearing it is what the gated pass would have done.

It is a separate function rather than a parameter because the choice is the caller's to justify and has to be readable where it is made, and because there are only two callers entitled to it: namespace flattening and the dispatcher unwrapper, the two whose refusal costs the pipeline a pass rather than an expression. Every caller that folds one expression takes property_provably_absent(), which keeps both arms and gains nothing by the weaker question.

Expand source code Browse git
def property_absent_from_written_chain(
    effects: EffectModel | None,
    value_type: type,
    key: str,
) -> bool:
    """
    `property_provably_absent` for a caller whose cost of refusing is a whole pass rather than one
    fold. The chain is asked `EffectModel.chain_roots_unwritten` instead of
    `EffectModel.read_chain_intact`, so a file carrying a reflective surface is not treated as one
    that wrote a prototype. See the note on `chain_roots_unwritten` for what that trade is; the
    short of it is that such a surface is what the real obfuscated files carry, and clearing it is
    what the gated pass would have done.

    It is a separate function rather than a parameter because the choice is the caller's to justify
    and has to be readable where it is made, and because there are only two callers entitled to it:
    namespace flattening and the dispatcher unwrapper, the two whose refusal costs the pipeline a
    pass rather than an expression. Every caller that folds one expression takes
    `property_provably_absent`, which keeps both arms and gains nothing by the weaker question.
    """
    if key in OBJECT_PROTOTYPE_MEMBERS or key in PROTOTYPE_CHAIN_PROPERTIES:
        return False
    if issubclass(value_type, JsBuffer):
        return False
    if effects is not None and not effects.chain_roots_unwritten(value_type):
        return False
    if issubclass(value_type, str):
        return key not in STRING_PROTOTYPE_METHODS and key not in SEQUENCE_DATA_PROPERTIES
    if issubclass(value_type, list):
        return key not in ARRAY_PROTOTYPE_METHODS and key not in SEQUENCE_DATA_PROPERTIES
    return issubclass(value_type, dict)
def property_is_inherited_from_an_intact_chain(effects, value_type, key)

Whether key names a member the prototype chain of value_type supplies and that chain is still the one the language describes, so a value of that type has the member whether or not it owns one.

This is property_is_inherited() with the question that one cannot answer added to it. The tables say what the language installs; only the model can say whether the file took it away, and delete Object.prototype.toString removes a name every one of those tables lists. A caller with no effect model gets the tables alone and owns that assumption itself.

It asks EffectModel.read_chain_intact and not the write arm, so that it refuses wherever property_provably_absent() refuses. The two are the halves of one read, and a half that answers under a reflective surface the other half declines under is a read decided by which half happened to be asked: an unresolvable eval that deleted toString would leave 'toString' in o folding to true while 'zz' in o refused.

Expand source code Browse git
def property_is_inherited_from_an_intact_chain(
    effects: EffectModel | None,
    value_type: type,
    key: str,
) -> bool:
    """
    Whether *key* names a member the prototype chain of *value_type* supplies **and** that chain is
    still the one the language describes, so a value of that type has the member whether or not it
    owns one.

    This is `property_is_inherited` with the question that one cannot answer added to it. The tables
    say what the language installs; only the model can say whether the file took it away, and
    `delete Object.prototype.toString` removes a name every one of those tables lists. A caller with
    no effect model gets the tables alone and owns that assumption itself.

    It asks `EffectModel.read_chain_intact` and not the write arm, so that it refuses wherever
    `property_provably_absent` refuses. The two are the halves of one read, and a half that answers
    under a reflective surface the other half declines under is a read decided by which half
    happened to be asked: an unresolvable `eval` that deleted `toString` would leave
    `'toString' in o` folding to `true` while `'zz' in o` refused.
    """
    if not property_is_inherited(value_type, key):
        return False
    return effects is None or effects.read_chain_intact(value_type)
def property_is_inherited(value_type, key)

Whether key names a member the prototype chain of value_type supplies, so a value of that type has one whether or not it owns one. This is the yes-side of the question property_provably_absent() answers the no-side of, and the two are not each other's negation: between them lies every name the tables do not list, which a value has only if the file put it there.

The asymmetry was once the point: writing a prototype adds a name to a chain, so a name the language already puts there was taken to be still there afterwards, while a name it does not put there is one only the file can account for. That reasoning is true of a write and false of a deletedelete Object.prototype.toString removes a name every table here lists — so this side needs the model as much as the other one does, and a caller must pair it with EffectModel.read_chain_intact or EffectModel.chain_roots_unwritten exactly as property_provably_absent() does. A caller that does not is answering true for a name the program removed.

Expand source code Browse git
def property_is_inherited(value_type: type, key: str) -> bool:
    """
    Whether *key* names a member the prototype chain of *value_type* supplies, so a value of that
    type has one whether or not it owns one. This is the yes-side of the question
    `property_provably_absent` answers the no-side of, and the two are not each other's negation:
    between them lies every name the tables do not list, which a value has only if the file put it
    there.

    The asymmetry was once the point: writing a prototype adds a name to a chain, so a name the
    language already puts there was taken to be still there afterwards, while a name it does not put
    there is one only the file can account for. That reasoning is true of a write and **false of a
    `delete`** — `delete Object.prototype.toString` removes a name every table here lists — so this
    side needs the model as much as the other one does, and a caller must pair it with
    `EffectModel.read_chain_intact` or `EffectModel.chain_roots_unwritten` exactly as
    `property_provably_absent` does. A caller that does not is answering `true` for a name the
    program removed.
    """
    if key in OBJECT_PROTOTYPE_MEMBERS or key in PROTOTYPE_CHAIN_PROPERTIES:
        return True
    if issubclass(value_type, JsBuffer):
        return False
    if issubclass(value_type, str):
        return key in STRING_PROTOTYPE_METHODS
    if issubclass(value_type, list):
        return key in ARRAY_PROTOTYPE_METHODS
    return False
def property_provably_absent(effects, value_type, key)

Whether reading key off a value of value_type that does not own key provably yields undefined, so a caller may answer that in place of the read. This is the inherited half of a property access where read_data_property() is the own half, and it is one function because every caller is deciding the same thing — what the prototype chain says about a key the value itself does not answer for — and each of them was deciding it alone with a different clause missing.

Two questions have to answer together. The name must be one no prototype in the chain holds, which is what the tables above enumerate from a real engine rather than from the subset this package can evaluate: normalize is a function whether or not we can run it, and answering undefined for it would contradict typeof. And the chain has to still be the one the language describes, which only effects can say — Object.prototype.z = 9 puts a name there that no table here lists, so deciding from the tables alone answers undefined where the program answers 9.

A caller with no effect model has nothing to consult and owns that assumption itself, the same way EffectModel.trusted_intrinsic leaves it with one. A JsBuffer is refused outright: its surface is over a hundred methods that vary between Node versions, so nothing is provably absent on one. So is every receiver this file enumerates no surface for — a function, a number — since the arms below name the three that have one and nothing else falls through them.

Expand source code Browse git
def property_provably_absent(effects: EffectModel | None, value_type: type, key: str) -> bool:
    """
    Whether reading *key* off a value of *value_type* that does not own *key* provably yields
    `undefined`, so a caller may answer that in place of the read. This is the inherited half of a
    property access where `read_data_property` is the own half, and it is one function because every
    caller is deciding the same thing — what the prototype chain says about a key the value itself
    does not answer for — and each of them was deciding it alone with a different clause missing.

    Two questions have to answer together. The name must be one no prototype in the chain holds,
    which is what the tables above enumerate from a real engine rather than from the subset this
    package can evaluate: `normalize` is a function whether or not we can run it, and answering
    `undefined` for it would contradict `typeof`. And the chain has to still be the one the language
    describes, which only *effects* can say — `Object.prototype.z = 9` puts a name there that no
    table here lists, so deciding from the tables alone answers `undefined` where the program
    answers `9`.

    A caller with no effect model has nothing to consult and owns that assumption itself, the same
    way `EffectModel.trusted_intrinsic` leaves it with one. A `JsBuffer` is refused outright: its
    surface is over a hundred methods that vary between Node versions, so nothing is provably absent
    on one. So is every receiver this file enumerates no surface for — a function, a number — since
    the arms below name the three that have one and nothing else falls through them.
    """
    if key in OBJECT_PROTOTYPE_MEMBERS or key in PROTOTYPE_CHAIN_PROPERTIES:
        return False
    if issubclass(value_type, JsBuffer):
        return False
    if effects is not None and not effects.read_chain_intact(value_type):
        return False
    if issubclass(value_type, str):
        return key not in STRING_PROTOTYPE_METHODS and key not in SEQUENCE_DATA_PROPERTIES
    if issubclass(value_type, list):
        return key not in ARRAY_PROTOTYPE_METHODS and key not in SEQUENCE_DATA_PROPERTIES
    return issubclass(value_type, dict)
def utf16_code_units(text)

Split text into its UTF-16 code units, which is what JavaScript indexing and split('') operate on. Python strings are sequences of code points, so a character outside the BMP is one Python character but two JavaScript ones: '😀'.split('') has length 2 in JS, and each half is a lone surrogate. Iterating the Python string directly would under-count it.

Expand source code Browse git
def utf16_code_units(text: str) -> list[str]:
    """
    Split *text* into its UTF-16 code units, which is what JavaScript indexing and `split('')` operate
    on. Python strings are sequences of code points, so a character outside the BMP is one Python
    character but two JavaScript ones: `'\U0001F600'.split('')` has length 2 in JS, and each half is a
    lone surrogate. Iterating the Python string directly would under-count it.
    """
    units: list[str] = []
    for char in text:
        units.extend(code_units(ord(char)))
    return units
def to_boolean(value)

Apply the ECMA-262 ToBoolean abstract operation. This is the value-domain counterpart of the AST-node is_truthy(); the two must agree on which values are falsy (undefined, null, 0, NaN, '') so that interpreted and statically-folded conditionals stay consistent.

Expand source code Browse git
def to_boolean(value: Value) -> bool:
    """
    Apply the ECMA-262 ToBoolean abstract operation. This is the value-domain counterpart of the
    AST-node `is_truthy`; the two must agree on which values are falsy (`undefined`, `null`, `0`,
    `NaN`, `''`) so that interpreted and statically-folded conditionals stay consistent.
    """
    if value is None or value is JS_NULL:
        return False
    if isinstance(value, bool):
        return value
    if isinstance(value, (int, float)):
        return value != 0 and value == value
    if isinstance(value, str):
        return len(value) > 0
    if isinstance(value, list):
        return True
    if isinstance(value, dict):
        return True
    if isinstance(value, (JsFunctionDeclaration, JsFunctionExpression, JsArrowFunctionExpression)):
        return True
    return False
def to_number(value)

Apply the ECMA-262 ToNumber abstract operation, which is a dispatch on the type of a value. The string case is the only one with a grammar behind it, and that grammar is js_string_to_number in the Number domain, where the reading of a Number belongs; asking Python's float here instead would answer a different question, its own grammar being wider than the language's in several places at once.

Expand source code Browse git
def to_number(value: Value) -> float:
    """
    Apply the ECMA-262 ToNumber abstract operation, which is a dispatch on the type of a value. The
    string case is the only one with a grammar behind it, and that grammar is `js_string_to_number`
    in the Number domain, where the reading of a Number belongs; asking Python's `float` here
    instead would answer a different question, its own grammar being wider than the language's in
    several places at once.
    """
    if isinstance(value, bool):
        return 1.0 if value else 0.0
    if isinstance(value, (int, float)):
        return to_js_number(value)
    if isinstance(value, str):
        return js_string_to_number(value)
    if value is JS_NULL:
        return 0.0
    if isinstance(value, list):
        return to_number(to_string(value))
    return float('nan')
def to_string(value)
Expand source code Browse git
def to_string(value: Value) -> str:
    if isinstance(value, str):
        return value
    if value is None:
        return 'undefined'
    if value is JS_NULL:
        return 'null'
    if isinstance(value, bool):
        return 'true' if value else 'false'
    if isinstance(value, (int, float)):
        return js_number_to_string(value)
    if isinstance(value, list):
        return ','.join(_array_element_string(v) for v in value)
    return '[object Object]'
def js_typeof(value)

Apply the typeof operator to a value. Total over the domain, and the reason typeof null is 'object' falls out of the ordering rather than being stated: JS_NULL is not any of the primitive types tested for, so it reaches the same answer every object does.

Expand source code Browse git
def js_typeof(value: Value) -> str:
    """
    Apply the `typeof` operator to a value. Total over the domain, and the reason `typeof null` is
    `'object'` falls out of the ordering rather than being stated: `JS_NULL` is not any of the
    primitive types tested for, so it reaches the same answer every object does.
    """
    if value is None:
        return 'undefined'
    if isinstance(value, bool):
        return 'boolean'
    if isinstance(value, (int, float)):
        return 'number'
    if isinstance(value, str):
        return 'string'
    if isinstance(value, (JsFunctionDeclaration, JsFunctionExpression, JsArrowFunctionExpression)):
        return 'function'
    return 'object'
def eval_binary_op(op, left, right)

Evaluate a JavaScript binary operator on two numeric operands. Returns the result value, or None when the operator is unknown or the computation overflows/divides by zero. Handles arithmetic, bitwise, relational, equality, and the unsigned right shift >>>.

Both operands are coerced here so that every operator below sees the Number the caller meant and not whichever Python type happened to carry it. That is a normalization and not the bound on the work: refinery.lib.scripts.js.deobfuscation.stringarray reaches BINARY_OPS without passing through this function, so the operator that can be asked for unbounded work — **, where Python integers build a number of half a billion digits for what a double answers in one operation — coerces for itself as well.

Expand source code Browse git
def eval_binary_op(op: str, left: float, right: float) -> float | bool | None:
    """
    Evaluate a JavaScript binary operator on two numeric operands. Returns the result value, or
    `None` when the operator is unknown or the computation overflows/divides by zero. Handles
    arithmetic, bitwise, relational, equality, and the unsigned right shift `>>>`.

    Both operands are coerced here so that every operator below sees the Number the caller meant and
    not whichever Python type happened to carry it. That is a normalization and not the bound on the
    work: `refinery.lib.scripts.js.deobfuscation.stringarray` reaches `BINARY_OPS` without passing
    through this function, so the operator that can be asked for unbounded work — `**`, where Python
    integers build a number of half a billion digits for what a double answers in one operation —
    coerces for itself as well.
    """
    left = to_js_number(left)
    right = to_js_number(right)
    if op in ('===', '=='):
        return left == right
    if op in ('!==', '!='):
        return left != right
    rel = RELATIONAL_OPS.get(op)
    if rel is not None:
        return rel(left, right)
    if op == '>>>':
        a = _to_uint32(left)
        b = _to_uint32(right) & 0x1F
        return float((a >> b) & 0xFFFFFFFF)
    fn = BINARY_OPS.get(op)
    if fn is None:
        return None
    try:
        return fn(left, right)
    except (ZeroDivisionError, OverflowError, ValueError):
        return None
def spell_astral_characters(value)

Write the pairs of code units that name a character above the basic plane as that character. A value is held as the code units a JavaScript string is made of, which is what a program asking about its length or its halves has to be answered from; but a file is written in characters, so printing the units back would spell an emoji as two escapes nobody wrote.

A surrogate standing alone is left alone. It names no character, so there is nothing to write it as, and an escape is the only spelling a file has for it.

Expand source code Browse git
def spell_astral_characters(value: str) -> str:
    """
    Write the pairs of code units that name a character above the basic plane as that character. A
    value is held as the code units a JavaScript string is made of, which is what a program asking
    about its length or its halves has to be answered from; but a file is written in characters, so
    printing the units back would spell an emoji as two escapes nobody wrote.

    A surrogate standing alone is left alone. It names no character, so there is nothing to write it
    as, and an escape is the only spelling a file has for it.
    """
    return from_code_units(value)
def code_points(value)

The code points of a string, each kept as the code units that spell it. A JavaScript string is indexed by code unit but iterated by code point — a for … of, a spread, Array.from — so a well-formed surrogate pair is one element here, and every other code unit is its own. A lone surrogate stands as a code point of its own, because a string may hold one and the split may not invent the partner it lacks.

Expand source code Browse git
def code_points(value: str) -> list[str]:
    """
    The code points of a string, each kept as the code units that spell it. A JavaScript string is
    indexed by code unit but iterated by code point — a `for ... of`, a spread, `Array.from` — so a
    well-formed surrogate pair is one element here, and every other code unit is its own. A lone
    surrogate stands as a code point of its own, because a string may hold one and the split may not
    invent the partner it lacks.
    """
    points: list[str] = []
    index = 0
    length = len(value)
    while index < length:
        step = 2 if SURROGATE_PAIR.match(value, index) else 1
        points.append(value[index:index + step])
        index += step
    return points
def escape_js_string(value, quote="'")

Escape a string for use in a JavaScript string literal. Returns the escaped body without surrounding quotes. Backslash is escaped first to avoid double-escaping. Control characters not covered by named escapes are emitted as \xHH; a surrogate that names no character on its own as \uXXXX, and a pair of them as the character they name.

A NUL is \0, except where a digit stands behind it: \0 followed by 0 through 7 is one legacy octal escape and would swallow the digit into a different character, and followed by 8 or 9 it is an escape strict code refuses. A NUL a digit follows is spelled \x00 instead, so the character behind it stays the character the value held.

Expand source code Browse git
def escape_js_string(value: str, quote: str = "'") -> str:
    """
    Escape a string for use in a JavaScript string literal. Returns the escaped body without
    surrounding quotes. Backslash is escaped first to avoid double-escaping. Control characters
    not covered by named escapes are emitted as `\\xHH`; a surrogate that names no character on its
    own as `\\uXXXX`, and a pair of them as the character they name.

    A NUL is `\\0`, except where a digit stands behind it: `\\0` followed by `0` through `7` is one
    legacy octal escape and would swallow the digit into a different character, and followed by `8`
    or `9` it is an escape strict code refuses. A NUL a digit follows is spelled `\\x00` instead, so
    the character behind it stays the character the value held.
    """
    value = spell_astral_characters(value)
    value = value.replace('\\', r'\\')
    value = value.replace('\n', r'\n')
    value = value.replace('\r', r'\r')
    value = value.replace('\t', r'\t')
    value = re.sub(r'\x00(?=[0-9])', r'\\x00', value)
    value = value.replace('\0', r'\0')
    value = value.replace(quote, F'\\{quote}')
    return re.sub(r'[\x01-\x1f\ud800-\udfff]', _escape_residue, value)
def escape_js_template_text(value)

Escape a string so that it spells itself inside a template literal. Three characters end a run of template text rather than standing in it — the backtick that closes the literal, the ${ that opens a hole, and the backslash that would eat what follows it.

A line feed stands as itself, because a template is the one literal that may span lines. A carriage return does not: every line terminator sequence a template is written with denotes a line feed, so a return written into the text would come back as one and the string would not be the string. A lone surrogate has to be spelled too, for the same reason a string spells one — there is no encoding of the file that carries it.

Expand source code Browse git
def escape_js_template_text(value: str) -> str:
    """
    Escape a string so that it spells itself inside a template literal. Three characters end a run
    of template text rather than standing in it — the backtick that closes the literal, the `${`
    that opens a hole, and the backslash that would eat what follows it.

    A line feed stands as itself, because a template is the one literal that may span lines. A
    carriage return does not: every line terminator sequence a template is written with denotes a
    line feed, so a return written into the text would come back as one and the string would not
    be the string. A lone surrogate has to be spelled too, for the same reason a string spells one
    — there is no encoding of the file that carries it.
    """
    value = spell_astral_characters(value)
    value = value.replace('\\', r'\\')
    value = value.replace('\r', r'\r')
    value = value.replace('`', r'\`')
    value = value.replace('${', r'\${')
    return re.sub(r'[\x00-\x08\x0b-\x1f\ud800-\udfff]', _escape_residue, value)
def string_value(node)

The text a literal denotes, where it is a literal that denotes one. A literal the source never closed is not, and answering with the text it would have denoted is how a fold repairs it: the text goes into a fresh literal that carries the closing quote nobody wrote, and a file that no engine reads comes back as a program that runs.

Expand source code Browse git
def string_value(node: Expression | None) -> str | None:
    """
    The text a literal denotes, where it is a literal that denotes one. A literal the source never
    closed is not, and answering with the text it would have denoted is how a fold repairs it: the
    text goes into a fresh literal that carries the closing quote nobody wrote, and a file that no
    engine reads comes back as a program that runs.
    """
    if isinstance(node, JsStringLiteral) and node.terminated:
        return node.value
    return None
def property_key(prop)

Extract the string key from a property node. Handles both string-literal keys and plain identifier keys. Returns None for computed keys.

Expand source code Browse git
def property_key(prop: JsProperty) -> str | None:
    """
    Extract the string key from a property node. Handles both string-literal keys and plain
    identifier keys. Returns `None` for computed keys.
    """
    if prop.computed:
        return None
    if isinstance(prop.key, JsStringLiteral):
        return prop.key.value
    if isinstance(prop.key, JsIdentifier):
        return prop.key.name
    return None
def access_key(node)

Extract the string key from a member-access expression. Handles both computed (obj['key']) and dot (obj.key) accesses.

Expand source code Browse git
def access_key(node: JsMemberExpression) -> str | None:
    """
    Extract the string key from a member-access expression. Handles both computed (`obj['key']`)
    and dot (`obj.key`) accesses.
    """
    if node.computed:
        return string_value(node.property)
    if isinstance(node.property, JsIdentifier):
        return node.property.name
    return None
def names_this_realms_global_object(model, node)

Whether node denotes this realm's global object: it is spelled with one of SAME_REALM_GLOBAL_OBJECT_ALIASES and nothing binds that name where it stands. Both halves are needed by every pass that acts on a property written through such a spelling, because a declaration of the name binds it and the access then reads an ordinary object the program may read back through any second name for it.

The two questions are one predicate because a pass asking only the first is the shape of a defect rather than of a policy: refinery.lib.scripts.js.deobfuscation.unused deleted a write on the object a var self = {} held, and JsScrambleStringDecoder deleted the installation of a decoder a later call still reached through the same object.

A pass that needs the name the access designates asks SemanticModel.global_alias_member_name() instead, which answers both questions at once for a statically spelled key. This one is for a caller that reads its key some other way.

Expand source code Browse git
def names_this_realms_global_object(model: SemanticModel, node: Node | None) -> bool:
    """
    Whether *node* denotes this realm's global object: it is spelled with one of
    `SAME_REALM_GLOBAL_OBJECT_ALIASES` and nothing binds that name where it stands. Both halves are
    needed by every pass that acts on a property written through such a spelling, because a
    declaration of the name binds it and the access then reads an ordinary object the program may
    read back through any second name for it.

    The two questions are one predicate because a pass asking only the first is the shape of a
    defect rather than of a policy: `refinery.lib.scripts.js.deobfuscation.unused` deleted a write
    on the object a `var self = {}` held, and
    `refinery.lib.scripts.js.deobfuscation.scramble.JsScrambleStringDecoder` deleted the
    installation of a decoder a later call still reached through the same object.

    A pass that needs the name the access designates asks
    `refinery.lib.scripts.js.analysis.model.SemanticModel.global_alias_member_name` instead, which
    answers both questions at once for a statically spelled key. This one is for a caller that reads
    its key some other way.
    """
    base = strip_parens(node)
    if not isinstance(base, JsIdentifier) or base.name not in SAME_REALM_GLOBAL_OBJECT_ALIASES:
        return False
    return model.lookup(base.name, model.scope_of(base)) is None
def make_string_literal(value)
Expand source code Browse git
def make_string_literal(value: str) -> JsStringLiteral:
    escaped = escape_js_string(value)
    raw = F"'{escaped}'"
    return JsStringLiteral(value=value, raw=raw)
def numeric_value(node)
Expand source code Browse git
def numeric_value(node: Expression) -> float | None:
    if isinstance(node, JsNumericLiteral):
        return node.value
    return None
def make_numeric_literal(value)

Spell a Number as a literal, or refuse with None when it has none. NaN is the only Number without one, and a caller that can produce it must spell it through value_to_node().

The infinities do have one. ECMA-262 defines the mathematical value of a decimal literal and then rounds it to the nearest Number, and a value too large to round to a finite one rounds to the infinity — so 1e999 is a numeric literal denoting +Infinity exactly as 1 is one denoting one. That matters because the alternative spelling, the identifier Infinity, is an ordinary global binding that the program being deobfuscated may have rebound, whereas a literal denotes its value in every scope.

The spelling is otherwise Number.prototype.toString, with one deliberate deviation: that algorithm reads the mathematical value, so it prints negative zero as 0, but a literal 0 denotes positive zero and the two are distinguishable — 1 / -0 is -Infinity. Negative zero is therefore spelled -0. That spelling, like -1e999 and the one every other negative value gets, is a negation applied to a literal rather than a literal, so the node binds like the unary operator it starts with. That is a fact about the spelling, which refinery.lib.scripts.js.precedence therefore reads from the raw rather than from the class.

Expand source code Browse git
def make_numeric_literal(value: int | float) -> JsNumericLiteral | None:
    """
    Spell a Number as a literal, or refuse with `None` when it has none. `NaN` is the only Number
    without one, and a caller that can produce it must spell it through `value_to_node`.

    The infinities do have one. ECMA-262 defines the mathematical value of a decimal literal and then
    rounds it to the nearest Number, and a value too large to round to a finite one rounds to the
    infinity — so `1e999` is a numeric literal denoting `+Infinity` exactly as `1` is one denoting
    one. That matters because the alternative spelling, the identifier `Infinity`, is an ordinary
    global binding that the program being deobfuscated may have rebound, whereas a literal denotes
    its value in every scope.

    The spelling is otherwise `Number.prototype.toString`, with one deliberate deviation: that
    algorithm reads the mathematical value, so it prints negative zero as `0`, but a literal `0`
    denotes *positive* zero and the two are distinguishable — `1 / -0` is `-Infinity`. Negative zero
    is therefore spelled `-0`. That spelling, like `-1e999` and the one every other negative value
    gets, is a negation applied to a literal rather than a literal, so the node binds like the unary
    operator it starts with. That is a fact about the spelling, which
    `refinery.lib.scripts.js.precedence` therefore reads from the `raw` rather than from the class.
    """
    value = to_js_number(value)
    if value != value:
        return None
    if value == float('inf'):
        return JsNumericLiteral(value=value, raw='1e999')
    if value == float('-inf'):
        return JsNumericLiteral(value=value, raw='-1e999')
    if is_negative_zero(value):
        return JsNumericLiteral(value=value, raw='-0')
    return JsNumericLiteral(value=value, raw=js_number_to_string(value))
def make_undefined_expression()

The expression that spells undefined. That value has no literal, and the global name that denotes it is an ordinary binding which any scope may rebind, so it is written as an operator applied to a literal instead: void 0 denotes it wherever it stands.

Expand source code Browse git
def make_undefined_expression() -> JsUnaryExpression:
    """
    The expression that spells `undefined`. That value has no literal, and the global name that
    denotes it is an ordinary binding which any scope may rebind, so it is written as an operator
    applied to a literal instead: `void 0` denotes it wherever it stands.
    """
    return JsUnaryExpression(operator='void', operand=JsNumericLiteral(value=0, raw='0'))
def make_nan_expression()

The expression that spells NaN, which has no literal either, for the same reason and by the same means: 0 / 0.

Expand source code Browse git
def make_nan_expression() -> JsBinaryExpression:
    """
    The expression that spells `NaN`, which has no literal either, for the same reason and by the
    same means: `0 / 0`.
    """
    return JsBinaryExpression(
        operator='/',
        left=JsNumericLiteral(value=0, raw='0'),
        right=JsNumericLiteral(value=0, raw='0'),
    )
def denotes_nan(node)

Whether node is the expression make_nan_expression() builds. A zero literal divided by a zero literal is NaN however either zero happens to be written, so the test reads the two values rather than the text.

Expand source code Browse git
def denotes_nan(node: Node) -> bool:
    """
    Whether *node* is the expression `make_nan_expression` builds. A zero literal divided by a zero
    literal is `NaN` however either zero happens to be written, so the test reads the two values
    rather than the text.
    """
    return (
        isinstance(node, JsBinaryExpression)
        and node.operator == '/'
        and isinstance(node.left, JsNumericLiteral)
        and isinstance(node.right, JsNumericLiteral)
        and node.left.value == 0
        and node.right.value == 0
    )
def extract_literal_value(node)

Extract a Python value from a literal AST node. Returns (True, value) on success or (False, None) when the node is not a recognized literal form. Handles string, numeric, boolean, null literals, void expr, negative numerics, !0/!1, 0 / 0, and array expressions where all elements are themselves literals.

The two forms that are operator expressions rather than literals, void 0 and 0 / 0, are here because they are what undefined and NaN have instead of a literal: an expression built from an operator, which no scope can rebind, rather than from one of the global names, which any scope can. Recognizing them is what lets those two values survive a round trip through the tree, and this must stay paired with value_to_node(), its declared inverse.

Expand source code Browse git
def extract_literal_value(node: Node) -> tuple[bool, LiteralValue]:
    """
    Extract a Python value from a literal AST node. Returns `(True, value)` on success or
    `(False, None)` when the node is not a recognized literal form. Handles string, numeric,
    boolean, null literals, `void expr`, negative numerics, `!0`/`!1`, `0 / 0`, and array
    expressions where all elements are themselves literals.

    The two forms that are operator expressions rather than literals, `void 0` and `0 / 0`, are here
    because they are what `undefined` and `NaN` have instead of a literal: an expression built from
    an operator, which no scope can rebind, rather than from one of the global names, which any
    scope can. Recognizing them is what lets those two values survive a round trip through the tree,
    and this must stay paired with `value_to_node`, its declared inverse.
    """
    if isinstance(node, JsStringLiteral):
        return (True, node.value) if node.terminated else (False, None)
    if isinstance(node, JsNumericLiteral):
        return True, node.value
    if isinstance(node, JsBooleanLiteral):
        return True, node.value
    if isinstance(node, JsNullLiteral):
        return True, JS_NULL
    if isinstance(node, JsUnaryExpression):
        if node.operator == 'void' and isinstance(node.operand, VOID_LITERAL_OPERANDS):
            return True, None
        if node.operator == '-' and isinstance(node.operand, JsNumericLiteral):
            return True, -node.operand.value
        if node.operator == '+' and isinstance(node.operand, JsNumericLiteral):
            return True, node.operand.value
        if node.operator == '!' and isinstance(node.operand, JsNumericLiteral):
            return True, not bool(node.operand.value)
    if denotes_nan(node):
        return True, float('nan')
    if isinstance(node, JsArrayExpression):
        items: list[LiteralValue] = []
        for el in node.elements:
            if el is None:
                return False, None
            ok, val = extract_literal_value(el)
            if not ok:
                return False, None
            items.append(val)
        return True, items
    return False, None
def value_to_node(value)

Convert a Python value to the corresponding AST literal node, or None when the value has no literal form that denotes it faithfully. Refusing is always sound — the caller leaves the original expression in place — whereas rendering an approximation silently changes what the program means, so every case here either round-trips exactly or returns None.

A number is spelled by make_numeric_literal() whatever its sign, so that a negative one is a single literal carrying its sign in the raw and not a negation applied to its magnitude. Those two nodes synthesize to the same text, which is what let the second spelling go unnoticed, but only the first is a JsNumericLiteral: the fold that reads an operand with numeric_value() sees a number in one and nothing in the other.

NaN and undefined are the only values this returns a compound node for, because they are the only ones no literal denotes. Neither is spelled with the global name that names it. Those names are ordinary bindings, and this function does not know the scope it is writing into: a fold that happens under function (NaN) { … } would otherwise emit text meaning the parameter.

Expand source code Browse git
def value_to_node(value: object) -> Expression | None:
    """
    Convert a Python value to the corresponding AST literal node, or `None` when the value has no
    literal form that denotes it faithfully. Refusing is always sound — the caller leaves the original
    expression in place — whereas rendering an approximation silently changes what the program means,
    so every case here either round-trips exactly or returns `None`.

    A number is spelled by `make_numeric_literal` whatever its sign, so that a negative one is a single
    literal carrying its sign in the `raw` and not a negation applied to its magnitude. Those two nodes
    synthesize to the same text, which is what let the second spelling go unnoticed, but only the first
    is a `JsNumericLiteral`: the fold that reads an operand with `numeric_value` sees a number in one
    and nothing in the other.

    `NaN` and `undefined` are the only values this returns a compound node for, because they are the
    only ones no literal denotes. Neither is spelled with the global name that names it. Those names
    are ordinary bindings, and this function does not know the scope it is writing into: a fold that
    happens under `function (NaN) { … }` would otherwise emit text meaning the parameter.
    """
    if isinstance(value, str):
        return make_string_literal(value)
    if isinstance(value, bool):
        return JsBooleanLiteral(value=value)
    if isinstance(value, (int, float)):
        number = to_js_number(value)
        if number != number:
            return make_nan_expression()
        return make_numeric_literal(number)
    if isinstance(value, JsBuffer):
        return None
    if isinstance(value, list):
        elements: list[Expression | None] = []
        for item in value:
            el = value_to_node(item)
            if el is None:
                return None
            elements.append(el)
        return JsArrayExpression(elements=elements)
    if isinstance(value, dict):
        properties = []
        for k, v in value.items():
            if not isinstance(k, str):
                return None
            val_node = value_to_node(v)
            if val_node is None:
                return None
            properties.append(JsProperty(
                key=make_string_literal(k),
                value=val_node,
                computed=k == PROTO_KEY,
            ))
        return JsObjectExpression(properties=properties)
    if value is JS_NULL:
        return JsNullLiteral()
    if value is None:
        return make_undefined_expression()
    return None
def is_literal(node)

Whether node is a constant expression whose value the tree carries in full — the test a pass applies before cloning it to another position. void 0 and 0 / 0 count for the same reason extract_literal_value() reads them: they are what undefined and NaN have instead of a literal, and an operator applied to literals is as constant as a literal is.

Expand source code Browse git
def is_literal(node: Node) -> bool:
    """
    Whether *node* is a constant expression whose value the tree carries in full — the test a pass
    applies before cloning it to another position. `void 0` and `0 / 0` count for the same reason
    `extract_literal_value` reads them: they are what `undefined` and `NaN` have instead of a
    literal, and an operator applied to literals is as constant as a literal is.
    """
    if isinstance(node, JsStringLiteral):
        return node.terminated
    if isinstance(node, (JsNumericLiteral, JsBooleanLiteral, JsNullLiteral)):
        return True
    if isinstance(node, JsUnaryExpression):
        if node.operator == 'void' and isinstance(node.operand, VOID_LITERAL_OPERANDS):
            return True
        if node.operator == '-' and isinstance(node.operand, JsNumericLiteral):
            return True
    return denotes_nan(node)
def member_key(node)

Flatten a chain of property accesses into a dot-separated key string. Handles both dot notation and computed access with string-literal keys. Returns None if the chain contains a dynamic computed access that cannot be resolved to a static key.

Expand source code Browse git
def member_key(node: JsMemberExpression) -> str | None:
    """
    Flatten a chain of property accesses into a dot-separated key string. Handles both dot
    notation and computed access with string-literal keys. Returns `None` if the chain contains
    a dynamic computed access that cannot be resolved to a static key.
    """
    parts: list[str] = []
    cursor: Expression | None = node
    while isinstance(cursor, JsMemberExpression):
        key = access_key(cursor)
        if key is None:
            return None
        parts.append(key)
        cursor = cursor.object
    if not isinstance(cursor, JsIdentifier):
        return None
    parts.append(cursor.name)
    parts.reverse()
    return '.'.join(parts)
def is_while_true(node)

Check whether the while-loop condition is true, !![], or !0 — the forms the obfuscator uses for infinite loops.

Expand source code Browse git
def is_while_true(node: JsWhileStatement) -> bool:
    """
    Check whether the while-loop condition is `true`, `!![]`, or `!0` — the forms the
    obfuscator uses for infinite loops.
    """
    test = node.test
    if isinstance(test, JsBooleanLiteral) and test.value is True:
        return True
    if not isinstance(test, JsUnaryExpression) or test.operator != '!':
        return False
    inner = test.operand
    if isinstance(inner, JsNumericLiteral) and inner.value == 0:
        return True
    if isinstance(inner, JsUnaryExpression) and inner.operator == '!':
        return True
    return False
def is_valid_identifier(name)
Expand source code Browse git
def is_valid_identifier(name: str) -> bool:
    return bool(SIMPLE_IDENTIFIER.match(name)) and name not in JS_RESERVED
def is_valid_property_key(name)
Expand source code Browse git
def is_valid_property_key(name: str) -> bool:
    return bool(SIMPLE_IDENTIFIER.match(name))
def is_simple_expression(node)

Check whether a node is a side-effect-free leaf expression: a literal value, an identifier, or a unary operator applied to a literal (e.g. -42).

Expand source code Browse git
def is_simple_expression(node: Node) -> bool:
    """
    Check whether a node is a side-effect-free leaf expression: a literal value, an identifier, or
    a unary operator applied to a literal (e.g. `-42`).
    """
    if is_literal(node) or isinstance(node, JsIdentifier):
        return True
    if isinstance(node, JsUnaryExpression) and node.operand is not None:
        return is_literal(node.operand)
    return False
def is_write_target(node)

Return whether this identifier is a write target: the left-hand side of an assignment expression, or the iteration variable of a for-in / for-of statement.

Expand source code Browse git
def is_write_target(node: JsIdentifier) -> bool:
    """
    Return whether this identifier is a write target: the left-hand side of an assignment
    expression, or the iteration variable of a `for-in` / `for-of` statement.
    """
    p = node.parent
    if isinstance(p, JsAssignmentExpression) and p.left is node:
        return True
    if isinstance(p, (JsForInStatement, JsForOfStatement)) and p.left is node:
        return True
    return False
def is_binding_site(node)

Return whether this identifier is in a binding position (variable declarator id or function declaration name) rather than a reference/read position.

Expand source code Browse git
def is_binding_site(node: JsIdentifier) -> bool:
    """
    Return whether this identifier is in a binding position (variable declarator id or function
    declaration name) rather than a reference/read position.
    """
    p = node.parent
    if isinstance(p, JsVariableDeclarator) and p.id is node:
        return True
    if isinstance(p, JsFunctionDeclaration) and p.id is node:
        return True
    return False
def is_reference(node)

Whether this identifier reads or writes a binding rather than declaring one or naming something the program cannot refer to. is_use_position answers the second half and is the one statement of it; what is added here are the declarations is_binding_site() recognizes by shape, so that this is the syntactic approximation of SemanticModel.is_reference and not a second opinion about what a name is.

Every position naming a property, a label or something across a module boundary is excluded exactly as the model excludes it, and the local half of a sourceless export list reads exactly as the model reads it. The approximation is in the declarations, and it is a permissive one: a name bound by a destructuring pattern is written like a read and is_binding_site() sees only a declarator id and a function declaration name, so var { a } = o is answered True here and False by the model. A caller that acts on a True answer — one that substitutes or renames rather than one that grows a conservative set — needs the model.

Expand source code Browse git
def is_reference(node: JsIdentifier) -> bool:
    """
    Whether this identifier reads or writes a binding rather than declaring one or naming something
    the program cannot refer to. `is_use_position` answers the second half and is the one statement
    of it; what is added here are the declarations `is_binding_site` recognizes by shape, so that
    this is the syntactic approximation of `SemanticModel.is_reference` and not a second opinion
    about what a name is.

    Every position naming a property, a label or something across a module boundary is excluded
    exactly as the model excludes it, and the local half of a sourceless export list reads exactly
    as the model reads it. The approximation is in the declarations, and it is a *permissive* one:
    a name bound by a destructuring pattern is written like a read and `is_binding_site` sees only
    a declarator id and a function declaration name, so `var { a } = o` is answered `True` here and
    `False` by the model. A caller that acts on a `True` answer — one that substitutes or renames
    rather than one that grows a conservative set — needs the model.
    """
    return not is_binding_site(node) and is_use_position(node)
def name_is_unbound(node, model)

Whether nothing in the program can have given node's name a meaning of its own, so that it still denotes whatever the host supplies under that name. This is the question behind every table of well-known names the tool keeps — the values in GLOBAL_VALUE_NAMES, the built-ins in BUILTIN_REGISTRY — because what such a table records is a fact about the host, and whether the name still reaches the host is a fact about the scope.

There are three ways a name can mean something else, and resolve reports only the first:

  • a declaration binds it, anywhere from a parameter or a catch clause to an assignment at top level, which the model records as an IMPLICIT_GLOBAL
  • the lookup crosses a with body, where the object may carry a property of that name and reading it may even run a getter. resolve answers None here as well, so read_has_dynamic_effect is what separates the two cases
  • a direct eval declared it, which no reference records at all; free_name_reachable_by_direct_eval reports the positions that could see such a binding
Expand source code Browse git
def name_is_unbound(node: JsIdentifier, model: SemanticModel) -> bool:
    """
    Whether nothing in the program can have given *node*'s name a meaning of its own, so that it still
    denotes whatever the host supplies under that name. This is the question behind every table of
    well-known names the tool keeps — the values in `GLOBAL_VALUE_NAMES`, the built-ins in
    `BUILTIN_REGISTRY` — because what such a table records is a fact about the *host*, and whether the
    name still reaches the host is a fact about the *scope*.

    There are three ways a name can mean something else, and `resolve` reports only the first:

    - a declaration binds it, anywhere from a parameter or a `catch` clause to an assignment at top
      level, which the model records as an `IMPLICIT_GLOBAL`
    - the lookup crosses a `with` body, where the object may carry a property of that name and reading
      it may even run a getter. `resolve` answers `None` here as well, so `read_has_dynamic_effect` is
      what separates the two cases
    - a direct `eval` declared it, which no reference records at all;
      `free_name_reachable_by_direct_eval` reports the positions that could see such a binding
    """
    return (
        model.resolve(node) is None
        and not model.read_has_dynamic_effect(node)
        and not model.free_name_reachable_by_direct_eval(node)
    )
def names_global_value(node, model)

Whether node is one of GLOBAL_VALUE_NAMES still denoting its value. Every reader of those names has to come through here.

Expand source code Browse git
def names_global_value(node: JsIdentifier, model: SemanticModel) -> bool:
    """
    Whether *node* is one of `GLOBAL_VALUE_NAMES` still denoting its value. Every reader of those
    names has to come through here.
    """
    return node.name in GLOBAL_VALUE_NAMES and name_is_unbound(node, model)
def denoted_value(node, model)

The value node denotes, as (True, value), or (False, None) when nothing decides it. This is extract_literal_value() widened by the two things a literal cannot express: a name that still denotes one of the global values, and an operator standing in front of either.

Expand source code Browse git
def denoted_value(node: Node | None, model: SemanticModel) -> tuple[bool, Value]:
    """
    The value *node* denotes, as `(True, value)`, or `(False, None)` when nothing decides it. This is
    `extract_literal_value` widened by the two things a literal cannot express: a name that still
    denotes one of the global values, and an operator standing in front of either.
    """
    node = strip_parens(node)
    if node is None:
        return False, None
    if isinstance(node, JsIdentifier):
        if not names_global_value(node, model):
            return False, None
        return True, GLOBAL_VALUE_NAMES[node.name]
    if isinstance(node, JsUnaryExpression):
        apply = UNARY_OPS.get(node.operator)
        if apply is None:
            return False, None
        known, value = denoted_value(node.operand, model)
        return (True, apply(value)) if known else (False, None)
    return extract_literal_value(node)
def allocated_object_type(node)

The typeof of the object node allocates, when it is a form that always evaluates to a freshly created one — None otherwise. Such a node has no value this module can extract: an object or function expression denotes an identity no literal reproduces, and an array whose elements are not themselves literals is the same. Its type is nevertheless fixed by the syntax alone, and so is its truthiness, since every object is truthy — which is why !{}, typeof {} and if ([f()]) are all answerable from this one fact.

Deciding an operand from its allocation says nothing about whether evaluating it is free of effects; [f()] allocates an array and calls f. A caller that discards the operand has to ask that separately.

Expand source code Browse git
def allocated_object_type(node: Node | None) -> str | None:
    """
    The `typeof` of the object *node* allocates, when it is a form that always evaluates to a freshly
    created one — `None` otherwise. Such a node has no value this module can extract: an object or
    function expression denotes an identity no literal reproduces, and an array whose elements are
    not themselves literals is the same. Its *type* is nevertheless fixed by the syntax alone, and so
    is its truthiness, since every object is truthy — which is why `!{}`, `typeof {}` and
    `if ([f()])` are all answerable from this one fact.

    Deciding an operand from its allocation says nothing about whether evaluating it is free of
    effects; `[f()]` allocates an array and calls `f`. A caller that discards the operand has to ask
    that separately.
    """
    node = strip_parens(node)
    if isinstance(node, (JsObjectExpression, JsArrayExpression)):
        return 'object'
    if isinstance(node, (JsFunctionExpression, JsArrowFunctionExpression, JsClassExpression)):
        return 'function'
    return None
def is_truthy(node, model)

The JavaScript truthiness of node, or None when nothing decides it. The AST-node counterpart of the value-domain to_boolean(), which it answers by asking wherever a value is known: the two used to agree by inspection, and now agree by construction.

An allocation is the one case with no value to ask about, and it needs none — every object is truthy. This does not gate that on the allocation being effect-free, because deciding truthiness does not by itself discard the operand; the caller that goes on to drop it is the one that has to keep its effects.

Expand source code Browse git
def is_truthy(node: Node, model: SemanticModel) -> bool | None:
    """
    The JavaScript truthiness of *node*, or `None` when nothing decides it. The AST-node counterpart
    of the value-domain `to_boolean`, which it answers by asking wherever a value is known: the two
    used to agree by inspection, and now agree by construction.

    An allocation is the one case with no value to ask about, and it needs none — every object is
    truthy. This does not gate that on the allocation being effect-free, because deciding truthiness
    does not by itself discard the operand; the caller that goes on to drop it is the one that has to
    keep its effects.
    """
    known, value = denoted_value(node, model)
    if known:
        return to_boolean(value)
    return True if allocated_object_type(node) is not None else None
def is_nullish(node, model)

Whether node denotes null or undefined — the two values ?? treats as absent — or None when the value it denotes is not decided. A caller has to tell that third answer from False: a ?? b keeps a when a is known not to be nullish, and must be left alone when nothing is known about it at all.

Expand source code Browse git
def is_nullish(node: Node, model: SemanticModel) -> bool | None:
    """
    Whether *node* denotes `null` or `undefined` — the two values `??` treats as absent — or `None`
    when the value it denotes is not decided. A caller has to tell that third answer from `False`:
    `a ?? b` keeps `a` when `a` is known not to be nullish, and must be left alone when nothing is
    known about it at all.
    """
    known, value = denoted_value(node, model)
    if not known:
        return None
    return value is None or value is JS_NULL
def insert_after_prologue(host, statements)

Insert statements into the body of host directly behind its Directive Prologue, adopting them and advancing the tree's mutation counter the one way every splice does.

Index zero is where a hoisted declaration wants to go and the one place a directive cannot survive: a statement written ahead of 'use strict' ends the prologue before it is reached, and the body quietly becomes sloppy — an assignment to an undeclared name stops throwing and starts creating a global instead. Behind the prologue is the same position for every purpose a hoist has, a directive declaring a mode and binding nothing.

The whole prologue is stepped over and not merely the Use Strict Directive. A directive the language does not recognize is a directive all the same, and a statement wedged in front of one ends the run for everything standing behind it.

host is taken rather than its statement list precisely so that this cannot be called the raw way: a caller holding only the list can reach insert and would not be asking this question.

The list is read through statement_list, which answers for every node a prologue can open — including a class static block, which get_body() does not know. Reading it through a narrower accessor than is_prologue_host accepts would make this a silent no-op for a host it advertises, and a hoist that vanishes leaves the references a pass already rewrote bound to nothing.

Expand source code Browse git
def insert_after_prologue(host: Node, statements: list[Statement]) -> None:
    """
    Insert *statements* into the body of *host* directly behind its Directive Prologue, adopting them
    and advancing the tree's mutation counter the one way every splice does.

    Index zero is where a hoisted declaration wants to go and the one place a directive cannot
    survive: a statement written ahead of `'use strict'` ends the prologue before it is reached, and
    the body quietly becomes sloppy — an assignment to an undeclared name stops throwing and starts
    creating a global instead. Behind the prologue is the same position for every purpose a hoist has,
    a directive declaring a mode and binding nothing.

    The whole prologue is stepped over and not merely the Use Strict Directive. A directive the
    language does not recognize is a directive all the same, and a statement wedged in front of one
    ends the run for everything standing behind it.

    *host* is taken rather than its statement list precisely so that this cannot be called the raw
    way: a caller holding only the list can reach `insert` and would not be asking this question.

    The list is read through `statement_list`, which answers for every node a prologue can open —
    including a class static block, which `get_body` does not know. Reading it through a narrower
    accessor than `is_prologue_host` accepts would make this a silent no-op for a host it advertises,
    and a hoist that vanishes leaves the references a pass already rewrote bound to nothing.
    """
    body = statement_list(host)
    if body is None:
        return
    index = len(directive_prologue(host)) if is_prologue_host(host) else 0
    set_body(host, [*body[:index], *statements, *body[index:]])
def get_body(node)

Return the statement body list of a node if it has one (JsScript or JsBlockStatement).

Expand source code Browse git
def get_body(node: Node) -> list[Statement] | None:
    """
    Return the statement body list of a node if it has one (JsScript or JsBlockStatement).
    """
    if isinstance(node, (JsScript, JsBlockStatement)):
        return node.body
    return None
def remove_declarator(declarator)

Remove a JsVariableDeclarator from its parent JsVariableDeclaration. If the declaration has no remaining declarators afterward, remove it from the body as well.

Expand source code Browse git
def remove_declarator(declarator: JsVariableDeclarator) -> None:
    """
    Remove a `refinery.lib.scripts.js.model.JsVariableDeclarator` from its parent
    `refinery.lib.scripts.js.model.JsVariableDeclaration`. If the declaration has no remaining
    declarators afterward, remove it from the body as well.
    """
    var_decl = declarator.parent
    _remove_from_parent(declarator)
    if isinstance(var_decl, JsVariableDeclaration) and not var_decl.declarations:
        _remove_from_parent(var_decl)
def extract_identifier_params(params)

Extract plain identifier names from a function's parameter list. Returns None if any parameter is not a simple JsIdentifier (e.g. destructuring or rest patterns).

Expand source code Browse git
def extract_identifier_params(params: list) -> list[str] | None:
    """
    Extract plain identifier names from a function's parameter list. Returns `None` if any parameter
    is not a simple `refinery.lib.scripts.js.model.JsIdentifier` (e.g. destructuring or rest
    patterns).
    """
    names: list[str] = []
    for p in params:
        if not isinstance(p, JsIdentifier):
            return None
        names.append(p.name)
    return names
def is_closed_expression(node, allowed_names)

Check whether every leaf in the expression tree is either a literal or an identifier whose name is in allowed_names. This ensures the expression has no free variables.

Expand source code Browse git
def is_closed_expression(node: Node, allowed_names: set[str]) -> bool:
    """
    Check whether every leaf in the expression tree is either a literal or an identifier whose
    name is in *allowed_names*. This ensures the expression has no free variables.
    """
    children = list(node.children())
    if not children:
        if isinstance(node, JsIdentifier):
            return node.name in allowed_names
        return is_simple_expression(node)
    return all(is_closed_expression(child, allowed_names) for child in children)
def arguments_substitutable(arguments, param_names)

Whether arguments stand one for one against param_names, so that each parameter has exactly one node to be replaced by.

A spread element denotes however many values the iterable behind it holds, which is not one and is not a count the syntax states. Counting it as one argument binds a parameter to the spread itself, and substituting that into an expression writes …xs where a value belongs.

A duplicate parameter name breaks the correspondence from the other side: function (a, a) reads only the last a, so substitution by name drops every earlier argument along with its evaluation, and any per-name accounting over the parameters counts two positions as one.

Expand source code Browse git
def arguments_substitutable(arguments: Sequence[Node], param_names: Sequence[str]) -> bool:
    """
    Whether *arguments* stand one for one against *param_names*, so that each parameter has exactly
    one node to be replaced by.

    A spread element denotes however many values the iterable behind it holds, which is not one and
    is not a count the syntax states. Counting it as one argument binds a parameter to the spread
    itself, and substituting that into an expression writes `...xs` where a value belongs.

    A duplicate parameter name breaks the correspondence from the other side: `function (a, a)`
    reads only the last `a`, so substitution by name drops every earlier argument along with its
    evaluation, and any per-name accounting over the parameters counts two positions as one.
    """
    if len(arguments) != len(param_names):
        return False
    if len(set(param_names)) != len(param_names):
        return False
    return not any(isinstance(argument, JsSpreadElement) for argument in arguments)
def expression_a_call_answers(func)

The expression a call to func answers, where a call answers an expression at all: a body that is one return of something, parameters that are all plain names, none of them written by that expression, and a call that answers the value rather than a wrapper around it.

Whether that expression may be lifted to any particular call site is a different question and the caller's: which names it is allowed to be closed over, and which arguments may be substituted for which parameter, differ per site and are not decided here.

Expand source code Browse git
def expression_a_call_answers(func: JsFunctionNode) -> ReturnedExpression | None:
    """
    The expression a call to *func* answers, where a call answers an expression at all: a body that
    is one `return` of something, parameters that are all plain names, none of them written by that
    expression, and a call that answers the value rather than a wrapper around it.

    Whether that expression may be lifted to any particular call site is a different question and the
    caller's: which names it is allowed to be closed over, and which arguments may be substituted for
    which parameter, differ per site and are not decided here.
    """
    if wraps_return(func):
        return None
    body = func.body
    if not isinstance(body, JsBlockStatement) or len(body.body) != 1:
        return None
    stmt = body.body[0]
    if not isinstance(stmt, JsReturnStatement) or stmt.argument is None:
        return None
    param_names = extract_identifier_params(func.params)
    if param_names is None:
        return None
    if _param_written(stmt.argument, set(param_names)):
        return None
    return ReturnedExpression(stmt.argument, param_names)
def names_used_under_a_nested_scope(expr)

The identifier names read at a use position inside a function or class nested in expr — or in all of expr, where expr is itself a function or class. A substitution there does not evaluate the argument where the call site did: the nested body runs later, any number of times, so a non-literal argument would be re-read per run — a binding's later value instead of the one the call captured, or a fresh allocation per run where the site produced one value.

Expand source code Browse git
def names_used_under_a_nested_scope(expr: Node) -> frozenset[str]:
    """
    The identifier names read at a use position inside a function or class nested in *expr* — or
    in all of *expr*, where *expr* is itself a function or class. A substitution there does not
    evaluate the argument where the call site did: the nested body runs later, any number of times,
    so a non-literal argument would be re-read per run — a binding's later value instead of the
    one the call captured, or a fresh allocation per run where the site produced one value.
    """
    names: set[str] = set()
    for node in expr.walk():
        if isinstance(node, (
            JsFunctionExpression,
            JsArrowFunctionExpression,
            JsFunctionDeclaration,
            JsClassExpression,
            JsClassDeclaration,
        )):
            names.update(
                n.name for n in node.walk()
                if isinstance(n, JsIdentifier) and is_use_position(n)
            )
    return frozenset(names)
def is_safe_iife_inline(expr, param_names, call_args, call_pure=None, read_effect=None, call_established=None)

Verify that substituting IIFE arguments into the body expression preserves evaluation semantics. An argument used more than once must be a simple, identity-stable expression — a literal or a bare identifier: duplicating a fresh array/object/function literal (or a call) would split one value into distinct copies and break an identity comparison such as x === x. An effectful argument must additionally be used exactly once, in an unconditionally-evaluated position, and in declaration order relative to other effectful arguments, so its side effect is neither dropped, duplicated, nor reordered.

A parameter read inside a function or class nested in the body admits only a literal argument: the nested body runs after the call, any number of times, so any other argument would be re-evaluated per run — function (a) { return () => a; } called with x must answer a closure over the value x held at the call, not a live read of x, and called with [1] must answer the one array the site produced, not a fresh one per run.

An effect is also ordered against everything that can observe it. Once any argument is effectful, every argument that is not a literal is held to the same evaluation-order discipline — at the call site each was evaluated once, before the body and in declaration order, and an identifier read moved across another argument's write reads a different value (o.m(x, x = 5) with a body of b + a). And every effectful argument must evaluate before the body's first own operation — a call, a member access, an operator's coercion — because at the call site all arguments ran before the body did, and a body operation is code the substitution cannot see across (o.m(f, g()) with a body of a() + b would run f before g). When call_pure is given (an EffectModel.is_pure_call()), a call argument it proves pure counts as side-effect-free for the ordering rules — but only when call_established also certifies its callee is in place before the call runs, and, being a call, it is not simple, so it is still not duplicated. When read_effect is given (a SemanticModel.read_has_dynamic_effect()), an argument reading a bare name through a with body's dynamic scope counts as effectful — the read may fire the with object's getter or throw — so it too must not be dropped or reordered.

Identity stability under duplication is the may-allocate direction and must not be merged with EffectModel._fresh_kind, which is must-allocate: a fresh literal is the thing this refuses to duplicate and the thing that predicate admits. The two agree on the syntax and disagree on the verdict, which is exactly why sharing one predicate between them would be wrong.

Expand source code Browse git
def is_safe_iife_inline(
    expr: Node,
    param_names: Sequence[str],
    call_args: Sequence[Node],
    call_pure: Callable[..., bool] | None = None,
    read_effect: Callable[[Node], bool] | None = None,
    call_established: Callable[..., bool] | None = None,
) -> bool:
    """
    Verify that substituting IIFE arguments into the body expression preserves evaluation semantics.
    An argument used more than once must be a simple, identity-stable expression — a literal or a bare
    identifier: duplicating a fresh array/object/function literal (or a call) would split one value into
    distinct copies and break an identity comparison such as `x === x`. An effectful argument must
    additionally be used exactly once, in an unconditionally-evaluated position, and in declaration
    order relative to other effectful arguments, so its side effect is neither dropped, duplicated, nor
    reordered.

    A parameter read inside a function or class nested in the body admits only a literal argument:
    the nested body runs after the call, any number of times, so any other argument would be
    re-evaluated per run — `function (a) { return () => a; }` called with `x` must answer a
    closure over the value `x` held at the call, not a live read of `x`, and called with `[1]` must
    answer the one array the site produced, not a fresh one per run.

    An effect is also ordered against everything that can observe it. Once any argument is
    effectful, every argument that is not a literal is held to the same evaluation-order discipline
    — at the call site each was evaluated once, before the body and in declaration order, and an
    identifier read moved across another argument's write reads a different value (`o.m(x, x = 5)`
    with a body of `b + a`). And every effectful argument must evaluate before the body's first own
    operation — a call, a member access, an operator's coercion — because at the call site all
    arguments ran before the body did, and a body operation is code the substitution cannot see
    across (`o.m(f, g())` with a body of `a() + b` would run `f` before `g`). When *call_pure* is
    given (an
    `refinery.lib.scripts.js.analysis.effects.EffectModel.is_pure_call`), a call argument it proves pure
    counts as side-effect-free for the ordering rules — but only when *call_established* also certifies
    its callee is in place before the call runs, and, being a call, it is not simple, so it is still not
    duplicated. When *read_effect* is given (a
    `refinery.lib.scripts.js.analysis.model.SemanticModel.read_has_dynamic_effect`), an argument reading
    a bare name through a `with` body's dynamic scope counts as effectful — the read may fire the `with`
    object's getter or throw — so it too must not be dropped or reordered.

    Identity stability under duplication is the *may*-allocate direction and must not be merged with
    `EffectModel._fresh_kind`, which is *must*-allocate: a fresh literal is the thing this refuses to
    duplicate and the thing that predicate admits. The two agree on the syntax and disagree on the verdict,
    which is exactly why sharing one predicate between them would be wrong.
    """
    if _param_written(expr, set(param_names)):
        return False
    use_counts = Counter(
        n.name for n in expr.walk()
        if isinstance(n, JsIdentifier) and is_use_position(n)
    )
    for i, arg in enumerate(call_args):
        if use_counts[param_names[i]] > 1 and not is_simple_expression(arg):
            return False
    deferred = names_used_under_a_nested_scope(expr)
    for i, arg in enumerate(call_args):
        if param_names[i] in deferred and not is_literal(arg):
            return False
    effectful_indices = [
        i for i, arg in enumerate(call_args)
        if not side_effect_free(
            arg, call_pure=call_pure, read_effect=read_effect, call_established=call_established,
        )
    ]
    if not effectful_indices:
        return True
    effectful = set(effectful_indices)
    for i in effectful_indices:
        if use_counts[param_names[i]] != 1:
            return False
    events = _collect_unconditional_evaluation(expr)
    param_order = {name: i for i, name in enumerate(param_names)}
    exposed = {i for i, arg in enumerate(call_args) if not is_literal(arg)}
    order: list[int] = []
    first_operation: int | None = None
    for event in events:
        if event is None:
            if first_operation is None:
                first_operation = len(order)
            continue
        index = param_order.get(event)
        if index is not None and index in exposed:
            order.append(index)
    for i in exposed:
        expected = 1 if i in effectful else use_counts[param_names[i]]
        if sum(1 for k in order if k == i) != expected:
            return False
    for p, i in enumerate(order):
        if i in effectful and first_operation is not None and p >= first_operation:
            return False
        for q, j in enumerate(order):
            if j in effectful and i != j and (i < j) != (p < q):
                return False
    return True
def substitute_params(expression, params, arguments, transformer=None)

Deep-clone expression and replace every reference to one of the function parameters params with a clone of the positionally corresponding node from arguments. Only identifiers the parameter actually binds are replaced: a non-computed property key (the a in b.a) names a property, and a function or class nested in expression that reintroduces a parameter's name keeps its own identifiers rather than the outer parameter's. When expression nests no scope, no name under it can be rebound, so a parameter's references are exactly the use-position identifiers carrying its name and are substituted directly; only when it does nest a scope is a semantic model built to resolve each occurrence against the binding it reads. When transformer is given, that model is taken from its shared analysis cache; otherwise it is built standalone.

Expand source code Browse git
def substitute_params(
    expression: Node,
    params: Sequence[Node],
    arguments: Sequence[Node],
    transformer: Transformer | None = None,
) -> Node:
    """
    Deep-clone *expression* and replace every reference to one of the function parameters *params* with
    a clone of the positionally corresponding node from *arguments*. Only identifiers the parameter
    actually binds are replaced: a non-computed property key (the `a` in `b.a`) names a property, and a
    function or class nested in *expression* that reintroduces a parameter's name keeps its own
    identifiers rather than the outer parameter's. When *expression* nests no scope, no name under it
    can be rebound, so a parameter's references are exactly the use-position identifiers carrying its
    name and are substituted directly; only when it does nest a scope is a semantic model built to
    resolve each occurrence against the binding it reads. When *transformer* is given, that model is
    taken from its shared analysis cache; otherwise it is built standalone.
    """
    cloned = _clone_node(expression)
    mapping = {
        param.name: argument
        for param, argument in zip(params, arguments)
        if isinstance(param, JsIdentifier)
    }
    if isinstance(expression, JsIdentifier):
        if expression.name in mapping and is_use_position(expression):
            return _clone_node(mapping[expression.name])
        return cloned
    if not _introduces_nested_scope(expression):
        for node in list(cloned.walk()):
            if isinstance(node, JsIdentifier) and node.name in mapping and is_use_position(node):
                substitute_use_position(node, _clone_node(mapping[node.name]))
        return cloned
    root = expression
    while root.parent is not None:
        root = root.parent
    assert isinstance(root, JsScript)
    if transformer is None:
        model = build_semantic_model(root)
    else:
        model = model_cache(transformer, root).model
    bindings = {
        param.name: model.binding_of(param)
        for param in params
        if isinstance(param, JsIdentifier)
    }
    for original, clone in zip(list(expression.walk()), list(cloned.walk())):
        if not isinstance(original, JsIdentifier) or original.name not in mapping:
            continue
        binding = bindings.get(original.name)
        if binding is None or model.resolve(original) is not binding:
            continue
        if isinstance(clone, JsIdentifier) and clone.name == original.name:
            substitute_use_position(clone, _clone_node(mapping[original.name]))
    return cloned
def spelled_for_the_callee_position(position, replacement)

The expression to write where position stands so that replacement reaches its value there without changing the call it lands in. Where position is a call's callee (or a tagged template's tag) and replacement's own spelling as a callee would mean something a neutral spelling does not — a member access binds this to its object, a bare eval runs its text in the caller's own scope — the value is reached behind (0, …), which invokes it with no receiver and no direct-eval effect, exactly as the name that stood there did. Anywhere else, and for any other value, replacement is written as it is.

Every pass that drops a value into a slot another node occupied shares this, so a member or a bare eval cannot become a receiver-bound or direct call at one substitution site while being neutralized at another.

Expand source code Browse git
def spelled_for_the_callee_position(position: Node, replacement: Node) -> Node:
    """
    The expression to write where *position* stands so that *replacement* reaches its value there
    without changing the call it lands in. Where *position* is a call's callee (or a tagged
    template's tag) and *replacement*'s own spelling as a callee would mean something a neutral
    spelling does not — a member access binds `this` to its object, a bare `eval` runs its text in
    the caller's own scope — the value is reached behind `(0, ...)`, which invokes it with no
    receiver and no direct-eval effect, exactly as the name that stood there did. Anywhere else, and
    for any other value, *replacement* is written as it is.

    Every pass that drops a value into a slot another node occupied shares this, so a member or a
    bare `eval` cannot become a receiver-bound or direct call at one substitution site while being
    neutralized at another.
    """
    if is_invocation_target(position) and callee_form_sensitive(replacement):
        assert isinstance(replacement, Expression)
        return JsSequenceExpression(expressions=[
            JsNumericLiteral(value=0, raw='0'),
            replacement,
        ])
    return replacement
def substitute_use_position(node, replacement, *, as_spelled=False)

Put replacement where node reads a binding, and report whether it did. That node reads one is the caller's to establish, and it takes a model: a name bound by a destructuring pattern is written exactly like a read, so SemanticModel.is_reference answers it and the syntactic is_reference() only approximates it, in the permissive direction.

A name the program cannot refer to is not such a read and is left alone. is_use_position says which positions those are — the four that spell a property, and the label, the import specifier halves, the re-export name and the name an export list exports under besides — and substituting into one of them is not a rename but a different program: a replacement with no identifier spelling leaves text no engine parses (o.5, { -2: 1 }), and a numeral put where a label stood leaves 5: while (0) break 5;.

The local half of an export list without a from clause is a read that predicate does record, and still no slot a replacement can stand in, because a list exports bindings and never values: export { 1 }; is a module no engine links. It is declined here instead, which is what keeps the declaration the list reads alive under an inliner that substitutes every read it can.

A shorthand property is the one position that is both at once. { a } means { a: a }, so the read is the value half and the key must keep the name it wrote; the property is written out in full and only the value replaced. Which half a caller hands over depends on where the tree came from — the parser builds one node for both and a clone builds two — so the value is asked for by identity rather than assumed, and a caller walking both halves substitutes once whichever order it visits them in. A shorthand carrying a computed key is a shape no source spells, which the parser builds only where it read a program no engine reads, and it is refused rather than written out as one of the two things it might have meant.

{ __proto__ } is the one shorthand that does not mean { __proto__: __proto__ }: written out with the colon it sets the object's prototype and gives it no property of that name at all, so Object.keys({ __proto__ }) answers one name and Object.keys({ __proto__: v }) answers none. Writing that one out is a different program, so it is left as it stands.

A replacement standing where a callee stands is written behind (0, …) where its own spelling as a callee would change the call, which spelled_for_the_callee_position() decides and every substituting pass shares: the name that stood there invoked its value with no receiver and no direct-eval effect, whatever the value was, and the sequence spells exactly that call. That is the reading for a value put where a read stood. A caller that instead re-spells the reference itself — the flattening recovery qualifying a name to the namespaced home it was recovered from, whose member form is the very call being restored — passes as_spelled=True and takes the form it wrote.

The answer is what a caller announcing a change has to read. A pass that reports one for a substitution this declined is a pass that reports one every round, and the fixpoint it sits in never reaches one. Nothing is written until the slot the replacement goes into is known, so a declined substitution leaves the tree exactly as it was.

Expand source code Browse git
def substitute_use_position(node: JsIdentifier, replacement: Node, *, as_spelled: bool = False) -> bool:
    """
    Put *replacement* where *node* reads a binding, and report whether it did. That *node* reads one
    is the caller's to establish, and it takes a model: a name bound by a destructuring pattern is
    written exactly like a read, so `SemanticModel.is_reference` answers it and the syntactic
    `is_reference` only approximates it, in the permissive direction.

    A name the program cannot refer to is not such a read and is left alone. `is_use_position` says
    which positions those are — the four that spell a property, and the label, the import specifier
    halves, the re-export name and the name an export list exports under besides — and substituting
    into one of them is not a rename but a different program: a replacement with no identifier
    spelling leaves text no engine parses (`o.5`, `{ -2: 1 }`), and a numeral put where a label
    stood leaves `5: while (0) break 5;`.

    The local half of an export list without a `from` clause is a read that predicate does record,
    and still no slot a replacement can stand in, because a list exports bindings and never values:
    `export { 1 };` is a module no engine links. It is declined here instead, which is what keeps
    the declaration the list reads alive under an inliner that substitutes every read it can.

    A shorthand property is the one position that is both at once. `{ a }` means `{ a: a }`, so the
    read is the value half and the key must keep the name it wrote; the property is written out in
    full and only the value replaced. Which half a caller hands over depends on where the tree came
    from — the parser builds one node for both and a clone builds two — so the value is asked for
    by identity rather than assumed, and a caller walking both halves substitutes once whichever
    order it visits them in. A shorthand carrying a computed key is a shape no source spells, which
    the parser builds only where it read a program no engine reads, and it is refused rather than
    written out as one of the two things it might have meant.

    `{ __proto__ }` is the one shorthand that does not mean `{ __proto__: __proto__ }`: written out
    with the colon it sets the object's prototype and gives it no property of that name at all, so
    `Object.keys({ __proto__ })` answers one name and `Object.keys({ __proto__: v })` answers none.
    Writing that one out is a different program, so it is left as it stands.

    A replacement standing where a callee stands is written behind `(0, ...)` where its own spelling
    as a callee would change the call, which `spelled_for_the_callee_position` decides and every
    substituting pass shares: the name that stood there invoked its value with no receiver and no
    direct-eval effect, whatever the value was, and the sequence spells exactly that call. That is
    the reading for a value put where a read stood. A caller that instead re-spells the reference
    itself — the flattening recovery qualifying a name to the namespaced home it was recovered
    from, whose member form is the very call being restored — passes `as_spelled=True` and takes
    the form it wrote.

    The answer is what a caller announcing a change has to read. A pass that reports one for a
    substitution this declined is a pass that reports one every round, and the fixpoint it sits in
    never reaches one. Nothing is written until the slot the replacement goes into is known, so a
    declined substitution leaves the tree exactly as it was.
    """
    parent = node.parent
    if not is_use_position(node):
        return False
    if isinstance(parent, JsExportSpecifier):
        return False
    if isinstance(parent, JsProperty) and parent.shorthand:
        if parent.computed or parent.value is not node:
            return False
        key = parent.key
        if isinstance(key, JsIdentifier) and key.name == PROTO_KEY:
            return False
        if key is node:
            set_child(parent, 'key', _clone_node(node))
        set_child(parent, 'value', replacement)
        set_value(parent, 'shorthand', False)
        return True
    if not as_spelled:
        replacement = spelled_for_the_callee_position(node, replacement)
    return _replace_in_parent(node, replacement)
def try_inline_trivial_function(func, call_args, *, transformer)

If func is a trivial wrapper (single return whose expression uses only the function's parameters), substitute call-site arguments into a clone of the return expression. Returns the inlined expression, or None when the function is not such a wrapper or when substituting the arguments would change what they do.

Admission is is_safe_iife_inline(), which is where the rule against dropping, duplicating, conditionalizing, or reordering an argument's evaluation lives; the effect oracles sharpening it are taken from transformer's shared analysis cache, admitting a provably pure call argument the syntactic reading counts as effectful. The object fold and the IIFE fold both inline through this function. It is not the only substitution of call arguments into a body: the call-wrapper inliner and the evaluator's irreducible-call splice admit under their own, differently shaped rules.

Which functions have a return expression to inline at all is expression_a_call_answers(), which is where the refusal to inline an async function or a generator lives.

Expand source code Browse git
def try_inline_trivial_function(
    func: JsFunctionExpression,
    call_args: Sequence[Node],
    *,
    transformer: Transformer,
) -> Node | None:
    """
    If *func* is a trivial wrapper (single return whose expression uses only the function's
    parameters), substitute call-site arguments into a clone of the return expression. Returns the
    inlined expression, or `None` when the function is not such a wrapper or when substituting the
    arguments would change what they do.

    Admission is `is_safe_iife_inline`, which is where the rule against dropping, duplicating,
    conditionalizing, or reordering an argument's evaluation lives; the effect oracles sharpening
    it are taken from *transformer*'s shared analysis cache, admitting a provably pure call
    argument the syntactic reading counts as effectful. The object fold and the IIFE fold both
    inline through this function. It is not the only substitution of call arguments into a body:
    the call-wrapper inliner and the evaluator's irreducible-call splice admit under their own,
    differently shaped rules.

    Which functions have a return expression to inline at all is `expression_a_call_answers`, which
    is where the refusal to inline an async function or a generator lives.
    """
    answered = expression_a_call_answers(func)
    if answered is None:
        return None
    expr, param_names = answered
    if not arguments_substitutable(call_args, param_names):
        return None
    if not is_closed_expression(expr, set(param_names)):
        return None
    call_pure, read_effect, call_established = _effect_oracles(transformer, func)
    if not is_safe_iife_inline(
        expr, param_names, call_args, call_pure, read_effect, call_established,
    ):
        return None
    return substitute_params(expr, func.params, call_args, transformer=transformer)
def walk_scope(root, *, include_root_body=False)

Walk the AST under root without descending into nested function bodies. Function boundary nodes are yielded (so their identifiers can be inspected) but their subtrees are suppressed. Children are visited in source order.

When include_root_body is True and root is itself a function, its body IS traversed (only inner functions are skipped). This is useful when root represents the scope being analyzed.

Expand source code Browse git
def walk_scope(root: Node, *, include_root_body: bool = False) -> Iterator[Node]:
    """
    Walk the AST under *root* without descending into nested function bodies. Function boundary
    nodes are yielded (so their identifiers can be inspected) but their subtrees are suppressed.
    Children are visited in source order.

    When *include_root_body* is True and *root* is itself a function, its body IS traversed (only
    inner functions are skipped). This is useful when *root* represents the scope being analyzed.
    """
    stack: list[Node] = [root]
    while stack:
        node = stack.pop()
        yield node
        if isinstance(node, FUNCTION_NODES):
            if not (include_root_body and node is root):
                continue
        cc = _compute_children(node)
        stack.extend(reversed(cc))
def collect_identifier_names(node)

Collect the names of all JsIdentifier nodes in the subtree rooted at node.

Expand source code Browse git
def collect_identifier_names(node: Node) -> set[str]:
    """
    Collect the names of all `refinery.lib.scripts.js.model.JsIdentifier` nodes in the subtree
    rooted at *node*.
    """
    return {n.name for n in node.walk() if isinstance(n, JsIdentifier)}
def find_enclosing_body(node)

Walk up parent pointers from node to find the body list that directly contains it. Returns the body attribute of the nearest JsBlockStatement or JsScript ancestor whose body list includes node (or an ancestor of node).

Expand source code Browse git
def find_enclosing_body(node: Node) -> list[Statement] | None:
    """
    Walk up parent pointers from *node* to find the body list that directly contains it. Returns the
    `body` attribute of the nearest `refinery.lib.scripts.js.model.JsBlockStatement` or
    `refinery.lib.scripts.js.model.JsScript` ancestor whose body list includes *node* (or an
    ancestor of *node*).
    """
    child = node
    parent = node.parent
    while parent is not None:
        if isinstance(parent, (JsBlockStatement, JsScript)):
            if child in parent.body:
                return parent.body
        child = parent
        parent = parent.parent
    return None
def function_binds_name(func, name)

Check if a function creates a local binding for name (parameter, function name, or var declaration anywhere in its body — excluding nested functions).

Expand source code Browse git
def function_binds_name(func: Node, name: str) -> bool:
    """
    Check if a function creates a local binding for `name` (parameter, function name, or var
    declaration anywhere in its body — excluding nested functions).
    """
    if isinstance(func, JsFunctionDeclaration) and func.id is not None and func.id.name == name:
        return True
    for p in (getattr(func, 'params', None) or []):
        if isinstance(p, JsIdentifier) and p.name == name:
            return True
    body = getattr(func, 'body', None)
    if not isinstance(body, JsBlockStatement):
        return False
    stack: list[Node] = [body]
    while stack:
        node = stack.pop()
        if isinstance(node, FUNCTION_NODES):
            continue
        if isinstance(node, JsVariableDeclaration) and node.kind == JsVarKind.VAR:
            for decl in node.declarations:
                if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier):
                    if decl.id.name == name:
                        return True
        for child in node.children():
            stack.append(child)
    return False
def references_receiver_this(root)

Return whether relocating root would change the meaning of a this or super reference bound to its current receiver. Both are receiver-bound: this to the call's receiver and super to the method's home object, and super is a syntax error outside a method, so a value that uses either cannot be detached from its containing method. The receiver boundary is walk_receiver_scope: arrow functions inherit both lexically and are traversed; regular and generator functions nested below root rebind this (and cannot name the outer super) and are not descended into; a class rebinds this for its method bodies and field initializers, so only its extends clause and computed member keys are traversed. An identifier super that merely names a property (x.super) or an object-literal key is not a receiver-bound reference, so it is gated on is_use_position and does not count.

Expand source code Browse git
def references_receiver_this(root: Node) -> bool:
    """
    Return whether relocating *root* would change the meaning of a `this` or `super` reference bound
    to its current receiver. Both are receiver-bound: `this` to the call's receiver and `super` to the
    method's home object, and `super` is a syntax error outside a method, so a value that uses either
    cannot be detached from its containing method. The receiver boundary is `walk_receiver_scope`:
    arrow functions inherit both lexically and are traversed; regular and generator functions nested
    below *root* rebind `this` (and cannot name the outer `super`) and are not descended into; a class
    rebinds `this` for its method bodies and field initializers, so only its `extends` clause and
    computed member keys are traversed. An identifier `super` that merely names a property (`x.super`)
    or an object-literal key is not a receiver-bound reference, so it is gated on `is_use_position` and
    does not count.
    """
    return any(
        isinstance(node, JsThisExpression)
        or (isinstance(node, JsIdentifier) and node.name == 'super' and is_use_position(node))
        for node in walk_receiver_scope(root)
    )
def is_receiver_binding_call(member)

Return whether member is evaluated in a position where the resulting call binds its object as the call's this receiver: the callee of a call m(…) (including an optional call m?.(...)) or the tag of a tagged template. Transparent parentheses are seen through; a comma-sequence callee (0, m)() yields a value rather than a Reference and so detaches the receiver, as does new m() (a constructor receives a fresh this). This is the dual of references_receiver_this(): detaching member from its object preserves this unless the call site binds a receiver and the callee observes one.

Expand source code Browse git
def is_receiver_binding_call(member: Node) -> bool:
    """
    Return whether *member* is evaluated in a position where the resulting call binds its object as the
    call's `this` receiver: the callee of a call `m(...)` (including an optional call `m?.(...)`) or the
    tag of a tagged template. Transparent parentheses are seen through; a comma-sequence callee
    `(0, m)()` yields a value rather than a Reference and so detaches the receiver, as does `new m()`
    (a constructor receives a fresh `this`). This is the dual of `references_receiver_this`: detaching
    *member* from its object preserves `this` unless the call site binds a receiver and the callee
    observes one.
    """
    node = member
    parent = node.parent
    while isinstance(parent, JsParenthesizedExpression):
        node, parent = parent, parent.parent
    if isinstance(parent, JsCallExpression) and parent.callee is node:
        return True
    if isinstance(parent, JsTaggedTemplateExpression) and parent.tag is node:
        return True
    return False
def rewrite_receiver_this_to_global(root)

Replace every this bound to root's own receiver with a globalThis identifier, returning whether any replacement was made. The rewrite descends the receiver boundary walk_receiver_scope defines — through arrow functions and a class's extends clause and computed keys, but not into a nested regular or generator function, whose this is its own — so only root's own this is rewritten. A caller uses this where root is invoked with no receiver, so its this is the global object: a Function-constructed body, or a recognized global-object finder whose … || this fallback yields the global. The synthesized globalThis identifiers stay subject to whatever free-name or shadow check the caller applies, so a binding named globalThis in scope declines the rewrite at the caller's discretion.

Expand source code Browse git
def rewrite_receiver_this_to_global(root: Node) -> bool:
    """
    Replace every `this` bound to *root*'s own receiver with a `globalThis` identifier, returning whether
    any replacement was made. The rewrite descends the receiver boundary `walk_receiver_scope` defines —
    through arrow functions and a class's `extends` clause and computed keys, but not into a nested
    regular or generator function, whose `this` is its own — so only *root*'s own `this` is rewritten.
    A caller uses this where *root* is invoked with no receiver, so its `this` is the global object: a
    `Function`-constructed body, or a recognized global-object finder whose `… || this` fallback yields
    the global. The synthesized `globalThis` identifiers stay subject to whatever free-name or shadow
    check the caller applies, so a binding named `globalThis` in scope declines the rewrite at the
    caller's discretion.
    """
    changed = False
    for node in list(walk_receiver_scope(root)):
        if isinstance(node, JsThisExpression):
            _replace_in_parent(node, JsIdentifier(name='globalThis'))
            changed = True
    return changed
def binding_has_references(model, binding, *, exclude=None, exclude_ids=None)

Whether binding is still read or written outside an excluded region. Resolution is binding-precise: only references that actually resolve to binding count, so a same-named variable in another scope never keeps it alive — this subsumes the name-based shadow check that has_remaining_references performs textually. A None binding (a name the model cannot resolve to a declaration) is conservatively reported as still referenced. References within the subtree of exclude, or whose node identity is in exclude_ids, are not counted.

Expand source code Browse git
def binding_has_references(
    model: SemanticModel,
    binding: Binding | None,
    *,
    exclude: Node | None = None,
    exclude_ids: set[int] | None = None,
) -> bool:
    """
    Whether *binding* is still read or written outside an excluded region. Resolution is
    binding-precise: only references that actually resolve to *binding* count, so a same-named
    variable in another scope never keeps it alive — this subsumes the name-based shadow check that
    `has_remaining_references` performs textually. A `None` binding (a name the model cannot resolve
    to a declaration) is conservatively reported as still referenced. References within the subtree
    of *exclude*, or whose node identity is in *exclude_ids*, are not counted.
    """
    if binding is None:
        return True
    for ref in model.references(binding, exclude=exclude):
        if exclude_ids and id(ref) in exclude_ids:
            continue
        return True
    return False
def a_host_reaches_the_binding(model, binding, options)

Whether binding is one the analyst declared a host reaches by name — a function the host invokes, or a global the host reads or may have rewritten — and one a host could actually reach: a top-level declaration under the script execution model. A pattern alone does not decide it: SemanticModel.reaches_global_object() is what keeps a pattern from protecting a nested binding, a let, or anything at all under the module model, where a top-level declaration never becomes a property of the global object.

A pass that could delete such a declaration, fold a read of one into its value, or relocate one out of the global scope asks here before doing so, so that the answer is one predicate's rather than each pass's own. A pass whose removals only ever fall on obfuscation machinery — a string table, a control-flow state, an anti-debug guard, none of which an analyst names an entrypoint — never reaches a binding this protects, and does not consult it.

Expand source code Browse git
def a_host_reaches_the_binding(model: SemanticModel, binding: Binding, options: object) -> bool:
    """
    Whether *binding* is one the analyst declared a host reaches by name — a function the host
    invokes, or a global the host reads or may have rewritten — and one a host could actually reach:
    a top-level declaration under the script execution model. A pattern alone does not decide it:
    `refinery.lib.scripts.js.analysis.model.SemanticModel.reaches_global_object` is what keeps a
    pattern from protecting a nested binding, a `let`, or anything at all under the module model,
    where a top-level declaration never becomes a property of the global object.

    A pass that could delete such a declaration, fold a read of one into its value, or relocate one
    out of the global scope asks here before doing so, so that the answer is one predicate's rather
    than each pass's own. A pass whose removals only ever fall on obfuscation machinery — a string
    table, a control-flow state, an anti-debug guard, none of which an analyst names an entrypoint —
    never reaches a binding this protects, and does not consult it.
    """
    if not is_host_entrypoint(options, binding.name):
        return False
    return model.reaches_global_object(binding, module_scope=module_execution(options))
def nothing_still_names(model, removed)

Whether deleting the nodes of removed would leave nothing naming what it takes away. A binding whose declarations all lie inside these nodes ceases to exist with them, so a reference to one from outside is a name the output would no longer declare, while a reference from within is one the deletion carries off and does not count. A name that is declared elsewhere as well survives the deletion and is not asked about.

This is the question a pass asks before deleting the machinery it has finished reading: the calls it could answer are gone, and what is left decides whether the machinery may go too. Asking it of the model rather than of the call shapes the pass recognizes is what makes the answer cover a call the pass could not resolve, a name handed to something else, and an alias taken through a form the pass does not match. None of those is a call the pass would find, and each of them is a reference the model reports.

A name inside a with body is asked for separately, because it resolves to no binding at all: the object supplies it or the binding does, and which one is a runtime question. It is counted as a reference here, since a removal made on the strength of it denoting the object is a removal that strands it whenever the object does not carry the property.

Expand source code Browse git
def nothing_still_names(model: SemanticModel, removed: Sequence[Node]) -> bool:
    """
    Whether deleting the nodes of *removed* would leave nothing naming what it takes away. A binding
    whose declarations all lie inside these nodes ceases to exist with them, so a reference to one
    from outside is a name the output would no longer declare, while a reference from within is one
    the deletion carries off and does not count. A name that is declared elsewhere as well survives
    the deletion and is not asked about.

    This is the question a pass asks before deleting the machinery it has finished reading: the calls
    it could answer are gone, and what is left decides whether the machinery may go too. Asking it of
    the model rather than of the call shapes the pass recognizes is what makes the answer cover a call
    the pass could not resolve, a name handed to something else, and an alias taken through a form the
    pass does not match. None of those is a call the pass would find, and each of them is a reference
    the model reports.

    A name inside a `with` body is asked for separately, because it resolves to no binding at all:
    the object supplies it or the binding does, and which one is a runtime question. It is counted as
    a reference here, since a removal made on the strength of it denoting the object is a removal that
    strands it whenever the object does not carry the property.
    """
    inside = {id(node) for root in removed for node in root.walk()}
    asked: set[Binding] = set()
    for root in removed:
        for node in root.walk():
            if not isinstance(node, JsIdentifier):
                continue
            binding = model.binding_of(node)
            if binding is None or binding in asked:
                continue
            asked.add(binding)
            if any(id(site) not in inside for site in binding.declarations):
                continue
            if binding_has_references(model, binding, exclude_ids=inside):
                return False
            if any(id(ref) not in inside for ref in model.dynamic_references(binding)):
                return False
    return True

Classes

class JsBuffer (*args, **kwargs)

Thin wrapper around list to distinguish a Node.js Buffer (byte array) from a plain JS Array in the interpreter's type-based method dispatch. It lives beside JS_NULL because both are members of the interpreter's value domain that a plain Python type cannot express, and every consumer of that domain — most importantly value_to_node(), which must not render a Buffer as an array literal — has to be able to tell them apart.

Expand source code Browse git
class JsBuffer(list):
    """
    Thin wrapper around `list` to distinguish a Node.js Buffer (byte array) from a plain JS Array in
    the interpreter's type-based method dispatch. It lives beside `JS_NULL` because both are members
    of the interpreter's value domain that a plain Python type cannot express, and every consumer of
    that domain — most importantly `value_to_node`, which must not render a Buffer as an array
    literal — has to be able to tell them apart.
    """
    pass

Ancestors

  • builtins.list
class MemberRead (*args, **kwds)

What reading a key off a value found. FOUND carries the value the read answers with. ABSENT is an index past the end of a string or array: the value holds no such slot, so the read is undefined unless the prototype chain supplies one. NOT_DATA is every other key, which is a name the chain has to be consulted about before anything can be said.

The two ways of not finding a value are kept apart because the callers part company on them. An emulated execution answers undefined for an index past the end, having no reason to doubt the chain of a value it is holding; a fold has to leave that read standing, because the file it is rewriting may install an index on String.prototype before it runs. Reporting both as one outcome would force the caller that cares to ask canonical_array_index a second time, which is the duplication this function exists to remove.

Expand source code Browse git
class MemberRead(Enum):
    """
    What reading a key off a value found. `FOUND` carries the value the read answers with. `ABSENT`
    is an index past the end of a string or array: the value holds no such slot, so the read is
    `undefined` unless the prototype chain supplies one. `NOT_DATA` is every other key, which is a
    name the chain has to be consulted about before anything can be said.

    The two ways of not finding a value are kept apart because the callers part company on them. An
    emulated execution answers `undefined` for an index past the end, having no reason to doubt the
    chain of a value it is holding; a fold has to leave that read standing, because the file it is
    rewriting may install an index on `String.prototype` before it runs. Reporting both as one
    outcome would force the caller that cares to ask `canonical_array_index` a second time, which is
    the duplication this function exists to remove.
    """
    FOUND = auto()
    ABSENT = auto()
    NOT_DATA = auto()

Ancestors

  • enum.Enum

Class variables

var FOUND

The type of the None singleton.

var ABSENT

The type of the None singleton.

var NOT_DATA

The type of the None singleton.

class ReturnedExpression (return_expression, param_names)

The one expression a call to a function answers, and the names its parameters bind.

Expand source code Browse git
class ReturnedExpression(NamedTuple):
    """
    The one expression a call to a function answers, and the names its parameters bind.
    """
    return_expression: Expression
    param_names: list[str]

Ancestors

  • builtins.tuple

Instance variables

var return_expression

Alias for field number 0

Expand source code Browse git
class ReturnedExpression(NamedTuple):
    """
    The one expression a call to a function answers, and the names its parameters bind.
    """
    return_expression: Expression
    param_names: list[str]
var param_names

Alias for field number 1

Expand source code Browse git
class ReturnedExpression(NamedTuple):
    """
    The one expression a call to a function answers, and the names its parameters bind.
    """
    return_expression: Expression
    param_names: list[str]
class BodyProcessingTransformer

Intermediate base for JS deobfuscation transformers that process the statement list (body) of JsScript and JsBlockStatement nodes after visiting children. Subclasses override _process_body.

Expand source code Browse git
class BodyProcessingTransformer(Transformer):
    """
    Intermediate base for JS deobfuscation transformers that process the statement list (body) of
    `refinery.lib.scripts.js.model.JsScript` and `refinery.lib.scripts.js.model.JsBlockStatement`
    nodes after visiting children. Subclasses override `_process_body`.
    """

    def visit_JsScript(self, node: JsScript):
        self.generic_visit(node)
        self._process_body(node, node.body)
        return None

    def visit_JsBlockStatement(self, node: JsBlockStatement):
        self.generic_visit(node)
        self._process_body(node, node.body)
        return None

    def _process_body(self, parent: Node, body: list[Statement]) -> None:
        raise NotImplementedError

    def _replace_body(self, parent: Node, replacement: list[Statement]) -> None:
        """
        Replace the body of *parent* with *replacement* through `refinery.lib.scripts.set_body`, so
        the adoption of the new statements and the advance of the tree's mutation counter happen the
        one way every splice performs them, and mark the transformer as changed.

        A Use Strict Directive the old body opened with is carried over to the head of the new one. A
        replacement drops a directive without removing anything — nothing is deleted, the statement
        simply is not among the statements handed in — so the rule that no removal may drop one has to
        be stated here as well, and stated as a repair rather than a refusal: a pass that has already
        rewritten the references it is about to install cannot be declined at this point without
        shipping a half-edited tree.
        """
        set_body(parent, keeping_directives(parent, replacement))
        self.mark_changed()

Ancestors

Subclasses

Methods

def visit_JsScript(self, node)
Expand source code Browse git
def visit_JsScript(self, node: JsScript):
    self.generic_visit(node)
    self._process_body(node, node.body)
    return None
def visit_JsBlockStatement(self, node)
Expand source code Browse git
def visit_JsBlockStatement(self, node: JsBlockStatement):
    self.generic_visit(node)
    self._process_body(node, node.body)
    return None

Inherited members

class ScopeProcessingTransformer

Base for transforms that process at function-scope boundaries. Visits JsScript and each function body (JsFunctionDeclaration, JsFunctionExpression, JsArrowFunctionExpression). Subclasses may override either _process_scope or _process_scope_body.

Expand source code Browse git
class ScopeProcessingTransformer(Transformer):
    """
    Base for transforms that process at function-scope boundaries. Visits
    `refinery.lib.scripts.js.model.JsScript` and each function body
    (`refinery.lib.scripts.js.model.JsFunctionDeclaration`,
    `refinery.lib.scripts.js.model.JsFunctionExpression`,
    `refinery.lib.scripts.js.model.JsArrowFunctionExpression`). Subclasses may override either
    `_process_scope` or `_process_scope_body`.
    """

    def visit_JsScript(self, node: JsScript):
        self.generic_visit(node)
        self._process_scope(node)
        return None

    def visit_JsFunctionDeclaration(self, node: JsFunctionDeclaration):
        self.generic_visit(node)
        if isinstance(node.body, JsBlockStatement):
            self._process_scope(node.body)
        return None

    def visit_JsFunctionExpression(self, node: JsFunctionExpression):
        self.generic_visit(node)
        if isinstance(node.body, JsBlockStatement):
            self._process_scope(node.body)
        return None

    def visit_JsArrowFunctionExpression(self, node: JsArrowFunctionExpression):
        self.generic_visit(node)
        if isinstance(node.body, JsBlockStatement):
            self._process_scope(node.body)
        return None

    def _process_scope(self, scope: Node) -> None:
        """
        Receives the raw scope node (`refinery.lib.scripts.js.model.JsScript` or
        `refinery.lib.scripts.js.model.JsBlockStatement`).
        """
        body = get_body(scope)
        if body is not None:
            self._process_scope_body(scope, body)

    def _process_scope_body(self, scope: Node, body: list) -> None:
        """
        Receives the scope node and its `body` list. The `_process_scope` method extracts the body
        and delegates here.
        """
        raise NotImplementedError

Ancestors

Subclasses

Methods

def visit_JsScript(self, node)
Expand source code Browse git
def visit_JsScript(self, node: JsScript):
    self.generic_visit(node)
    self._process_scope(node)
    return None
def visit_JsFunctionDeclaration(self, node)
Expand source code Browse git
def visit_JsFunctionDeclaration(self, node: JsFunctionDeclaration):
    self.generic_visit(node)
    if isinstance(node.body, JsBlockStatement):
        self._process_scope(node.body)
    return None
def visit_JsFunctionExpression(self, node)
Expand source code Browse git
def visit_JsFunctionExpression(self, node: JsFunctionExpression):
    self.generic_visit(node)
    if isinstance(node.body, JsBlockStatement):
        self._process_scope(node.body)
    return None
def visit_JsArrowFunctionExpression(self, node)
Expand source code Browse git
def visit_JsArrowFunctionExpression(self, node: JsArrowFunctionExpression):
    self.generic_visit(node)
    if isinstance(node.body, JsBlockStatement):
        self._process_scope(node.body)
    return None

Inherited members

class ScriptLevelTransformer

Base for transforms that process the entire script manually rather than using the recursive visitor. Subclasses override _process_script.

Expand source code Browse git
class ScriptLevelTransformer(Transformer):
    """
    Base for transforms that process the entire script manually rather than using the recursive
    visitor. Subclasses override `_process_script`.
    """

    def visit_JsScript(self, node: JsScript):
        self._process_script(node)
        return None

    def generic_visit(self, node: Node):
        pass

    def _process_script(self, node: JsScript) -> None:
        raise NotImplementedError

Ancestors

Subclasses

Methods

def visit_JsScript(self, node)
Expand source code Browse git
def visit_JsScript(self, node: JsScript):
    self._process_script(node)
    return None
def generic_visit(self, node)
Expand source code Browse git
def generic_visit(self, node: Node):
    pass

Inherited members