Module refinery.lib.scripts.js.analysis.effects

Per-function effect summaries for JavaScript, computed over the SemanticModel's resolved bindings and call graph. A summary records, conservatively, what observable effects one call of a function may have — writing a global, mutating a binding captured from an enclosing scope, throwing, or invoking code the analysis cannot account for — from which a single is_pure verdict follows.

This is the second layer of the analysis substrate. Like the model it sits on, it is flow-insensitive and conservative by construction: every effect is an over-approximation (when in doubt, an effect is reported), so a function judged pure is pure on every path, and callers may treat a pure call whose result is unused as removable. The summary deliberately does not model termination; purity here means freedom from observable effects, not a guarantee that the call returns.

Purity of a call to a built-in (for example String.fromCharCode) is asserted only under a verified pristine-intrinsics precondition: the whole program must not reassign or monkeypatch any intrinsic the registry trusts, nor contain a reflection surface through which one could be replaced at runtime. When that precondition fails the registry is disregarded and such calls are treated as unknown. A read of a trusted intrinsic-named property off the global object (for example globalThis.Uint8Array) is treated likewise, under a parallel pristine-global precondition: no reflective surface and no accessor installed on the global object, so the read cannot run a user getter.

Symmetrically, an assignment is not counted as a write when nothing can observe it: the assigned binding is read nowhere, or every reference to it is confined to the assigning function so it never escapes. Both rest on the same pristine-global precondition, since a reflective surface or an installed setter could otherwise observe the assignment. This is what lets a function whose only effect is an obfuscator's write-only scratch global be judged pure. A scratch name the function also reads is a separate question, answered by SemanticModel.read_may_throw rather than here: where the name's only binding is an implicit global, the read may run before the assignment that creates it, which this flow-insensitive summary cannot rule out, so the function throws even though the write itself is unobservable.

The public surface — EffectSummary, EffectModel.summary_of(), EffectModel.is_pure_call(), build_effects() — is representation-agnostic and keyed to AST node identity, matching the model's contract so a later control-flow layer can sharpen the same answers without changing callers.

Expand source code Browse git
"""
Per-function effect summaries for JavaScript, computed over the
`refinery.lib.scripts.js.analysis.model.SemanticModel`'s resolved bindings and call graph. A summary
records, conservatively, what observable effects *one call* of a function may have — writing a
global, mutating a binding captured from an enclosing scope, throwing, or invoking code the analysis
cannot account for — from which a single `is_pure` verdict follows.

This is the second layer of the analysis substrate. Like the model it sits on, it is *flow-insensitive*
and conservative by construction: every effect is an over-approximation (when in doubt, an effect is
reported), so a function judged pure is pure on every path, and callers may treat a pure call whose
result is unused as removable. The summary deliberately does not model termination; purity here means
freedom from observable effects, not a guarantee that the call returns.

Purity of a call to a built-in (for example `String.fromCharCode`) is asserted only under a verified
*pristine-intrinsics precondition*: the whole program must not reassign or monkeypatch any intrinsic the
registry trusts, nor contain a reflection surface through which one could be replaced at runtime. When
that precondition fails the registry is disregarded and such calls are treated as unknown. A read of a
trusted intrinsic-named property off the global object (for example `globalThis.Uint8Array`) is treated
likewise, under a parallel *pristine-global precondition*: no reflective surface and no accessor installed
on the global object, so the read cannot run a user getter.

Symmetrically, an assignment is not counted as a write when nothing can observe it: the assigned binding
is read nowhere, or every reference to it is confined to the assigning function so it never escapes. Both
rest on the same pristine-global precondition, since a reflective surface or an installed setter could
otherwise observe the assignment. This is what lets a function whose only effect is an obfuscator's
write-only scratch global be judged pure. A scratch name the function also *reads* is a separate
question, answered by `SemanticModel.read_may_throw` rather than here: where the name's only
binding is an implicit global, the read may run before the assignment that creates it, which this
flow-insensitive summary cannot rule out, so the function throws even though the write itself is
unobservable.

The public surface — `EffectSummary`, `EffectModel.summary_of`, `EffectModel.is_pure_call`,
`build_effects` — is representation-agnostic and keyed to AST node identity, matching the model's
contract so a later control-flow layer can sharpen the same answers without changing callers.
"""
from __future__ import annotations

import enum

from dataclasses import dataclass, field
from typing import Callable, Iterator, NamedTuple, Sequence

from refinery.lib.scripts import Expression, Node
from refinery.lib.scripts.js.analysis.model import (
    FUNCTION_NODES,
    GLOBAL_OBJECT_ALIASES,
    Binding,
    BindingKind,
    ContainerRole,
    Role,
    SemanticModel,
    container_reference_role,
    enclosing_function,
    is_member_write_target,
    reference_role,
)
from refinery.lib.scripts.js.model import (
    JsArrayExpression,
    JsArrowFunctionExpression,
    JsAssignmentExpression,
    JsAwaitExpression,
    JsBinaryExpression,
    JsBooleanLiteral,
    JsCallExpression,
    JsConditionalExpression,
    JsForInStatement,
    JsForOfStatement,
    JsFunctionDeclaration,
    JsFunctionExpression,
    JsIdentifier,
    JsImportExpression,
    JsLogicalExpression,
    JsMemberExpression,
    JsNewExpression,
    JsNullLiteral,
    JsNumericLiteral,
    JsObjectExpression,
    JsParenthesizedExpression,
    JsProperty,
    JsPropertyKind,
    JsRestElement,
    JsReturnStatement,
    JsScript,
    JsSequenceExpression,
    JsSpreadElement,
    JsStringLiteral,
    JsTemplateLiteral,
    JsThrowStatement,
    JsUnaryExpression,
    JsUpdateExpression,
    JsVariableDeclarator,
    JsYieldExpression,
    strip_parens,
    wraps_return,
)

_PURE_INTRINSIC_METHODS = frozenset({
    'String.fromCharCode',
    'Array.isArray',
    'Math.abs',
    'Math.ceil',
    'Math.floor',
    'Math.round',
    'Math.trunc',
    'Math.sign',
    'Math.max',
    'Math.min',
    'Math.pow',
    'Math.sqrt',
    'Math.cbrt',
    'Math.log',
    'Math.log2',
    'Math.log10',
    'Math.exp',
    'Number.isNaN',
    'Number.isFinite',
    'Number.isInteger',
    'Number.isSafeInteger',
    'Number.parseInt',
    'Number.parseFloat',
})

_PURE_GLOBAL_FUNCTIONS = frozenset({
    'parseInt',
    'parseFloat',
    'isNaN',
    'isFinite',
})

_FRESH_ARRAY_RESULT_METHODS = frozenset({
    'slice',
    'concat',
    'map',
    'filter',
    'flat',
    'flatMap',
})
"""
`Array.prototype` methods the specification requires to return a *newly created* array, so a write through the
result cannot be observed through the receiver. Membership turns on that guarantee alone and not on any other
property a method may have: `join` returns a string, `reverse` and `sort` return the receiver itself, and
`splice` returns a fresh array but also mutates the receiver — none of them belong here.

Each of these routes through ArraySpeciesCreate, which reads `constructor[Symbol.species]` off the receiver, so
the guarantee holds only for a receiver whose prototype and `constructor` the program has left alone.
`EffectModel.trusted_prototype` answers the prototype half and `EffectModel._species_written` the per-receiver
half; the interpreter additionally refuses to model a named property write on an array at all.
"""

_SPECIES_KEYS = frozenset({'constructor', '__proto__'})
"""
The two property names that decide what an allocating `Array.prototype` method returns. ArraySpeciesCreate
reads `constructor[Symbol.species]` off the receiver, and `__proto__` replaces the prototype that `constructor`
lookup walks, so writing either can make the "newly created" array be a shared object — or make the call throw,
by leaving a primitive where a constructor is expected.
"""

_SURFACE_KEYS = _SPECIES_KEYS | frozenset({'prototype'})
"""
The property names whose value shares the mutable surface of the intrinsic it was read from, so handing that
value to unanalysable code is as dangerous as handing over the intrinsic itself. `_SPECIES_KEYS` reach the
prototype chain and `prototype` is the chain. Every other key yields something whose properties nobody consults
when the intrinsic is used: patching a property of the number `Math.PI` or of the function `Math.floor` cannot
change what `Math.floor(1.7)` returns, while patching one of `Array.prototype` decides what `[1, 2].join()`
means.

`refinery.lib.scripts.js.deobfuscation.helpers.PROTOTYPE_CHAIN_PROPERTIES` holds the same two species keys for
the same reason, but importing it here would invert the layering this module rests on — the interpreter imports
from `effects`, never the reverse, as `_PROTOTYPE_OWNERS` also records.
"""

_PURE_INTRINSIC_ROOTS = (
    frozenset(name.split('.', 1)[0] for name in _PURE_INTRINSIC_METHODS) | _PURE_GLOBAL_FUNCTIONS
)

_PURE_CONSTRUCTOR_ROOTS = frozenset({'Array'})
"""
Intrinsic roots whose `new`-construction has no observable effect when its arguments are safe. Only
`Array` here: it is already guarded against reassignment/shadowing by `_intrinsics_pristine` (it is a
`_PURE_INTRINSIC_ROOTS` member via `Array.isArray`), and its sole throw is a bad single numeric length.
Adding a root not already in `_PURE_INTRINSIC_ROOTS` (e.g. `Object`) requires first extending the
`_intrinsics_pristine` guard to it, or trusting `new Object()` while `Object` was monkeypatched would be
unsound.
"""

_SPEC_GLOBAL_INTRINSICS = frozenset({
    'Object',
    'Boolean',
    'Symbol',
    'BigInt',
    'Number',
    'Math',
    'Date',
    'String',
    'RegExp',
    'Array',
    'JSON',
    'Promise',
    'Reflect',
    'Proxy',
    'Map',
    'Set',
    'WeakMap',
    'WeakSet',
    'ArrayBuffer',
    'SharedArrayBuffer',
    'DataView',
    'Int8Array',
    'Uint8Array',
    'Uint8ClampedArray',
    'Int16Array',
    'Uint16Array',
    'Int32Array',
    'Uint32Array',
    'Float32Array',
    'Float64Array',
    'BigInt64Array',
    'BigUint64Array',
    'Error',
    'EvalError',
    'RangeError',
    'ReferenceError',
    'SyntaxError',
    'TypeError',
    'URIError',
})
"""
Global properties the ECMAScript specification mandates as writable, configurable *data* properties of
the global object. A read of one runs no user getter and so carries no observable effect, soundly and
without any host assumption.
"""

_HOST_GLOBAL_INTRINSICS = frozenset({
    'TextDecoder',
    'TextEncoder',
    'Buffer',
})
"""
Global intrinsics a standard, non-adversarial host (Node, browsers) exposes as data properties. Trusted
like `_SPEC_GLOBAL_INTRINSICS`, but resting on that host assumption rather than the language standard.
"""

_GLOBAL_DATA_PROPERTIES = _SPEC_GLOBAL_INTRINSICS | _HOST_GLOBAL_INTRINSICS

_POISON_PILL_PROPERTIES = frozenset({'caller', 'arguments', '__proto__'})
"""
Property names whose read is never treated as getter-free even off an otherwise trusted base. `caller`
and `arguments` are the poison-pill accessors on strict and built-in functions — reading one may throw a
`TypeError` — and `__proto__` is an `Object.prototype` accessor rather than an own data slot. A read of
one carries a possible throw or accessor call, so it is not the plain field read the getter-safe base
rule would otherwise clear.
"""


def _is_poison_pill_property(member: JsMemberExpression) -> bool:
    """
    Whether *member* reads a poison-pill property (`.caller`, `.arguments`, `.__proto__`) by name, in
    either the dotted (`f.caller`) or the string-computed (`f['caller']`) form. A computed access whose
    key is not a string literal (`f[k]`) names no fixed property and does not count.
    """
    prop = member.property
    if member.computed:
        return isinstance(prop, JsStringLiteral) and prop.value in _POISON_PILL_PROPERTIES
    return isinstance(prop, JsIdentifier) and prop.name in _POISON_PILL_PROPERTIES


_ACCESSOR_INSTALL_METHODS = frozenset({
    'defineProperty',
    'defineProperties',
    '__defineGetter__',
    '__defineSetter__',
})

_DENOTED_ROOT_DEPTH_LIMIT = 16

_CALLEE_DEPTH_LIMIT = 4
"""
How far `_callee_is_write_free` follows an intrinsic through nested calls before refusing. A chain longer than
this is not proven safe, merely unproven, so exhausting the limit records the write. Four was enough for every
shape measured; the limit exists because the recursion is over call *edges*, which a mutually-recursive pair
makes unbounded.
"""

_VALUE_FORWARDING_NODES = (
    JsParenthesizedExpression,
    JsLogicalExpression,
    JsConditionalExpression,
    JsSequenceExpression,
    JsSpreadElement,
)
"""
The forms that hand an operand's value onward unchanged, so an intrinsic inside one escapes wherever the form
itself does. These are the outward counterpart of the arms `_denoted_roots` looks *into*, and the pairing is not
incidental: a fold that collapses `Math || 0` to `Math` must not change this analysis's answer, which the
pinning contract in `refinery.lib.scripts.js.analysis.cache.ModelCache` requires.
"""

_PROTOTYPE_OWNERS: dict[str, str] = {
    'str': 'String',
    'list': 'Array',
    'JsBuffer': 'Buffer',
    'dict': 'Object',
    'bool': 'Boolean',
    'int': 'Number',
    'float': 'Number',
    'JsFunctionExpression': 'Function',
    'JsArrowFunctionExpression': 'Function',
}
"""
The intrinsic whose prototype supplies the methods of each interpreter value type, keyed by type name so
this module needs no import from the interpreter that depends on it. A method call on a literal receiver
names no global at the call site, so trusting it means asking whether *this* prototype is intact:
`String.prototype.toUpperCase = f` is a write to `String`, and it changes what `'ab'.toUpperCase()` means
even though the expression mentions no identifier at all.

Every type in the interpreter's value domain is named here directly, including `JsBuffer`, whose methods
come from `Buffer.prototype` rather than the `Array.prototype` its `list` base would suggest. A function
value is represented as its own AST node, so the two function node names appear here as value types rather
than as syntax; both inherit from `Function.prototype`. Lookup is exact rather than a walk up the Python
MRO, which would silently answer `Array` for any future `list` subclass instead of refusing. A type with no
entry is never trusted through this route.
"""

_INHERITED_CHAIN_ROOTS = frozenset({'Object'})
"""
The prototypes every value inherits from beyond the one that owns its own methods. `Object.prototype` roots
every chain, so a getter installed there is reached by a plain read on an array literal and on `Math`
alike — which is why reading a property is a strictly stronger requirement than calling a method.
"""

_KEYED_WRITE_ROOTS = (
    _PURE_INTRINSIC_ROOTS
    | _INHERITED_CHAIN_ROOTS
    | frozenset(_PROTOTYPE_OWNERS.values())
)
"""
The global names whose written property keys `EffectModel.global_key_written` bounds. A name outside
this set is one the scan records no key for, so answering `False` about it would read an absence of
evidence as evidence of absence; that predicate answers `True` for every key of such a name instead.

The set is the union of the names this module already watches for a reason: the intrinsics whose
methods are trusted, the roots a plain property read walks through, and the owners of the prototypes
that supply each value type's members. A caller asking about a name outside it is asking a question
this scan was not built to answer, and is told so.
"""

_INTRINSIC_CHAIN_ROOTS = frozenset({'Object', 'Function'})
"""
The prototypes an intrinsic root's own chain can contain. `Math` is a plain object and inherits from
`Object.prototype` alone, while a constructor such as `Array` is a function and inherits from
`Function.prototype` first, so a plain read on either walks one of these two.
"""

_LITERAL_RECEIVER_TYPES: dict[type, type] = {
    JsArrayExpression: list,
    JsStringLiteral: str,
    JsNumericLiteral: float,
    JsBooleanLiteral: bool,
    JsObjectExpression: dict,
}
"""
The runtime type of a receiver whose literal syntax fixes it, mapping an AST node type to the value type
`_PROTOTYPE_OWNERS` is keyed on. A literal is the one receiver form whose prototype is knowable from the
expression alone: `[1, 2].join('-')` has an array receiver whatever the surrounding program does, whereas
`a.join('-')` depends on what `a` holds and on whether anything mutated it in between.

Deciding this here rather than at each caller is what lets a fold ask about a whole chain without walking
it: the recursive case above resolves an inner link, and this resolves the literal the chain starts from.
The `receiver_type` parameter remains for callers that hold an evaluated receiver — a value whose type is
known but whose syntax is not a literal — and is consulted only when the syntax settles nothing.

A function literal is deliberately absent, so `(function () {}).call(x)` is not a trusted callee. The
interpreter's `.call`/`.apply` dispatch carries no prototype guard, unlike its `list` and `str` neighbours,
so admitting one here would fold a dispatch a patched `Function.prototype` had redirected. Reading a
property of a function literal is a different question and has its own table below.
"""

_LITERAL_READ_TYPES: dict[type, type] = dict(_LITERAL_RECEIVER_TYPES)
_LITERAL_READ_TYPES[JsFunctionExpression] = JsFunctionExpression
_LITERAL_READ_TYPES[JsArrowFunctionExpression] = JsArrowFunctionExpression
"""
The value type of a literal whose *property read* must consult a prototype, extending
`_LITERAL_RECEIVER_TYPES` with the two function forms. Reading `f.name` walks `Function.prototype`, which a
program can patch, so the read needs an answer where the method call above must refuse outright: the call
is unsound to fold because the interpreter cannot guard its dispatch, while the read is merely a question
about one named intrinsic. A function node maps to itself, since the interpreter represents a function
value as its own AST node and `_PROTOTYPE_OWNERS` is keyed on that type's name.
"""


class _PureCall:
    """
    Sentinel marking a callee resolved to a known-pure intrinsic, distinct from an unresolved callee.
    """


_PURE = _PureCall()


class _GlobalObject:
    """
    Sentinel denoting the global object as the value an expression evaluates to, distinct from a named
    intrinsic root and from an unresolved value.
    """


GLOBAL_OBJECT = _GlobalObject()


@dataclass
class EffectSummary:
    """
    The observable effects one call of a function may have, each field a conservative over-estimate.
    `writes_global` covers assignment to a global or to a property of an object reached through one;
    `writes_captured` covers assignment to a binding owned by an enclosing function (a closure mutation
    visible after the call returns); `throws` covers a `throw`, an operation that may throw on a
    value the analysis cannot prove safe, or a read of a name that is not certain to denote a binding
    (`SemanticModel.read_may_throw`); `calls_unknown` covers invoking a callee that cannot be
    resolved and summarized. A summary with none of these set is `is_pure`. `mutates_returned_local` is
    held apart from those four: it records a write to a fresh local the function owns whose sole route to
    the caller is the value the call returns. Such a write is a real mutation baked into the returned
    value, so it blocks `is_pure` and `is_expression_replaceable`, but not
    `is_effect_free_when_discarded` — a call whose result is thrown away can never expose it — and not
    `is_literal_replaceable`, whose replacement is a fresh object at every site. `wraps_return` is separate:
    it does not bear on purity but records that a call to the function yields a wrapper (a promise from
    an `async` function, an iterator from a generator) rather than the value of its return expression,
    so the call cannot be replaced by that expression. `written_bindings` names, by identity, the outer
    bindings — captured locals and globals — a call may write where the write resolves to one, so a
    caller can ask which binding a call mutates rather than only whether it mutates some. It is decided
    independently of purity: a write the purity analysis deems unobservable because the binding never
    escapes the function is still recorded here, since a consumer reasoning about a read *inside* that
    function must still see the mutation. A binding written but never read anywhere adds nothing, as no
    read can observe the change; likewise a coarse write with no resolvable binding (a dynamic-scope or
    `globalThis.x =` member write) sets `writes_global` but adds nothing here.

    Four properties read these flags, and they are not a scale from strict to permissive — each answers a
    different question about a *different rewrite*, so a consumer picks by naming the rewrite it is about to
    perform rather than by how many flags a property excludes:

    - `is_pure` — may the call be deleted outright, result and all? Nothing may be lost, so every flag blocks.
    - `is_effect_free_when_discarded` — may the call be deleted when its result is already unused? A mutation
      confined to the returned value is then unreachable, so only that flag is forgiven.
    - `is_literal_replaceable` — may the call become a literal denoting its value? Throwing and unknown reads
      are reproduced by actually evaluating it, and `mutates_returned_local` is forgiven because a literal is a
      fresh object at every site. A rewrite that yields something other than a literal may not use this.
    - `is_expression_replaceable` — may the call become one expression lifted out of its body, with the rest of
      the body discarded? Everything the literal case needs, plus a refusal of `mutates_returned_local`, since
      a lifted expression yields no fresh object.
    """
    writes_global: bool = False
    writes_captured: bool = False
    throws: bool = False
    calls_unknown: bool = False
    mutates_returned_local: bool = False
    wraps_return: bool = False
    written_bindings: set[Binding] = field(default_factory=set)

    @property
    def is_pure(self) -> bool:
        """
        Whether a call to the summarized function produces no observable effect, so it carries no
        consequence the program can detect (termination aside) whether or not its result is used. A
        mutation the function confines to its returned value (`mutates_returned_local`) disqualifies it
        here, since a caller that uses the result observes that mutation; `is_effect_free_when_discarded`
        is the companion test for a call whose result is thrown away, which tolerates it.
        """
        return not (
            self.writes_global
            or self.writes_captured
            or self.throws
            or self.calls_unknown
            or self.mutates_returned_local
        )

    @property
    def is_effect_free_when_discarded(self) -> bool:
        """
        Whether a call to the summarized function, its result discarded, produces no observable effect.
        Identical to `is_pure` except it tolerates `mutates_returned_local`: a write to a fresh local the
        function owns is observable only through the value the call returns, so once that value is thrown
        away the write can never be seen and the call is free to drop. Every other way such a local — or a
        closure over it — reaches the caller is a distinct effect that independently sets a blocking flag
        (a store to a global, a store to an enclosing capture, a leak into an unknown callee, a throw), so
        excluding only `mutates_returned_local` here stays sound.
        """
        return not (self.writes_global or self.writes_captured or self.throws or self.calls_unknown)

    @property
    def is_literal_replaceable(self) -> bool:
        """
        Whether a call to the summarized function may be replaced by a *literal* denoting its computed return
        value. This holds when the call writes no state visible after it returns — neither a global nor a
        captured binding — and returns its value directly rather than wrapped: an `async` function's call is a
        promise and a generator's is an iterator, neither equal to the return expression, so `wraps_return`
        disqualifies it. Unlike `is_pure`, a call that may throw or read unknown state still qualifies: an
        evaluator that actually executes the call to a value reproduces those, and only a *write* would be
        silently lost.

        `mutates_returned_local` is tolerated, and the name of this property is what licenses that. Such a
        mutation is baked into a container the call returns, so the substituted value must be a distinct
        object per call — which a literal is, because `value_to_node` builds a new array or object literal at
        every site it fills. A replacement that is *not* a literal has no such guarantee and must not consult
        this property; see the family note on `EffectSummary`.
        """
        return not (
            self.writes_global
            or self.writes_captured
            or self.wraps_return
        )

    @property
    def is_expression_replaceable(self) -> bool:
        """
        Whether a call to the summarized function may be replaced by a single expression lifted out of its
        body, with everything else the body would have done discarded.

        Everything `is_literal_replaceable` requires is required here, for the same reasons: a write to a
        global or a capture would be lost, and a wrapped return is not the return expression. Throwing and
        unknown reads are tolerated identically — the lifted expression sits at the call site and still
        performs them, so they are reproduced rather than dropped, which is why the discard question is *not*
        the right one to ask even though statements are being discarded.

        What this additionally forbids is `mutates_returned_local`. The literal case tolerates it because
        `value_to_node` builds a new array or object at every site it fills, so the distinct container the
        mutation is baked into survives. A lifted expression is spliced from the body and names whatever the
        body named, guaranteeing nothing about identity, so a mutated container must not travel this way.
        """
        return self.is_literal_replaceable and not self.mutates_returned_local

    def absorb(self, other: EffectSummary):
        """
        Union *other*'s effects into this summary, used to fold a callee's effects into its caller.
        """
        self.writes_global = self.writes_global or other.writes_global
        self.writes_captured = self.writes_captured or other.writes_captured
        self.throws = self.throws or other.throws
        self.calls_unknown = self.calls_unknown or other.calls_unknown
        self.mutates_returned_local = self.mutates_returned_local or other.mutates_returned_local
        self.written_bindings |= other.written_bindings


def _literal_number(node: Node) -> int | float | None:
    """
    The numeric value *node* denotes as a literal, folding a leading `-`/`+` on a numeric literal, or
    `None` when it is not a numeric literal. `void` and any non-literal are not numbers here; `NaN` and
    `Infinity` are identifiers, not literals, so they too return `None`.
    """
    if isinstance(node, JsNumericLiteral):
        return node.value
    if (
        isinstance(node, JsUnaryExpression)
        and node.operator in ('-', '+')
        and isinstance(node.operand, JsNumericLiteral)
    ):
        return -node.operand.value if node.operator == '-' else node.operand.value
    return None


def _array_construct_is_pure(arguments: Sequence[Node]) -> bool:
    """
    Whether `new Array(*arguments)` is a pure allocation — it runs no user code and cannot throw. `Array`
    throws a `RangeError` only for a single numeric argument that is not a valid length
    (`ToUint32(v) != v`, i.e. not an integer in `[0, 2**32)`); a single argument provably not a number
    builds a one-element array; two or more arguments build a list. A spread argument runs the iterable's
    iterator (arbitrary user code) and its element count may be an invalid length, so it is never pure,
    and a single non-literal argument has an unknown value that could be an out-of-range length.
    """
    if any(isinstance(arg, JsSpreadElement) for arg in arguments):
        return False
    if len(arguments) != 1:
        return True
    arg = arguments[0]
    number = _literal_number(arg)
    if number is not None:
        return 0 <= number < 2 ** 32 and number == int(number)
    return isinstance(arg, (
        JsStringLiteral,
        JsBooleanLiteral,
        JsNullLiteral,
        JsArrayExpression,
        JsObjectExpression,
        JsFunctionExpression,
        JsArrowFunctionExpression,
    ))


def container_literal_access_is_plain(node: Node | None) -> bool:
    """
    Whether *node* is a container literal on which a plain member access touches a data slot and nothing
    else: an array or function expression, or an object literal that declares no accessor and installs no
    custom prototype. This is the one shared atom behind every predicate that has to decide what a member
    access on a freshly built value does — `EffectModel._base_is_safe` and `EffectModel._base_getter_safe`
    here, and `strict_divergence._fresh_writable_base`.

    Each of those asks a further question this deliberately does not answer, which is why they remain
    distinct predicates rather than aliases of this one: whether the base can be nullish, whether a
    primitive or a pristine intrinsic also qualifies, whether every slot is *writable* as opposed to
    merely accessor-free. What they must not disagree about is this atom. They did: the accessor veto
    lived in only two of the four copies, and the copy without it cleared a getter-carrying literal as
    effect-free, which deleted the getter call outright.

    It answers only what the *literal* declares, so it is never sufficient on its own for a getter-freeness
    question: `[1, 2]` declares no accessor and still inherits everything on `Array.prototype` and
    `Object.prototype`. A caller asking about a read must pair this with `EffectModel.read_chain_intact`.
    """
    node = strip_parens(node)
    if isinstance(node, JsObjectExpression):
        return not object_member_access_runs_accessor(node)
    return isinstance(node, (JsArrayExpression, JsFunctionExpression, JsArrowFunctionExpression))


def _is_safe_property_base(node: Node, defunct: set[str] | None = None) -> bool:
    """
    Whether a property access on *node* cannot run a custom getter without consulting any model: only an
    identifier in *defunct*, which names a binding being removed, so whatever getters it carries are
    irrelevant to the code that remains.

    No literal qualifies here, though the syntax of one fixes its type. Every property read walks a
    prototype chain the program can patch — `Object.defineProperty(Array.prototype, 'k', { get: … })` makes
    `[1, 2].k` run user code, and `Object.prototype` roots the chain of even a primitive — so the question
    cannot be answered from syntax at all. It needs `_globals_written`, which only a model has, and
    `EffectModel._base_getter_safe` asks it there. `_SideEffectScan` consults that through *member_safe*
    for everything this refuses, so a caller with a model loses nothing; a caller without one keeps the
    read, which is the sound direction.
    """
    return isinstance(node, JsIdentifier) and bool(defunct) and node.name in defunct


_CallPredicate = Callable[[JsCallExpression | JsNewExpression], bool]


class _SideEffectScan:
    """
    The recursive engine behind `side_effect_free`. It holds the fixed policy — the callables and the
    *defunct* set — so that only the per-node `discarded` flag threads down the recursion, and every
    consumed sub-expression is scanned with `discarded` reset to false by default.

    `discarded` is true where the value being scanned is thrown away by the enclosing context, which
    lets a top-level call be cleared by the more permissive *call_pure_discarded* — one that tolerates a
    write the call confines to its own returned value (`EffectSummary.mutates_returned_local`), since a
    discarded result can never expose it. It is propagated only into positions that are themselves
    discarded whenever the whole is: the expression a parenthesis groups, and every element of a
    sequence (each but the last is always discarded; the last inherits the sequence's own fate). It is
    reset for every operand whose value is consumed — a call argument, a unary/binary operand, a member
    base, a conditional branch — because a call nested there does have its result used, so a mutation it
    confines to that result must still count.
    """

    def __init__(
        self,
        defunct: set[str] | None,
        call_pure: _CallPredicate | None,
        read_effect: Callable[[Node], bool] | None,
        member_safe: Callable[[JsMemberExpression], bool] | None,
        call_established: _CallPredicate | None,
        call_pure_discarded: _CallPredicate | None,
    ):
        self.defunct = defunct
        self.call_pure = call_pure
        self.read_effect = read_effect
        self.member_safe = member_safe
        self.call_established = call_established
        self.call_pure_discarded = call_pure_discarded

    def free(self, node: Node, discarded: bool = False) -> bool:
        if isinstance(node, (JsStringLiteral, JsNumericLiteral, JsBooleanLiteral, JsNullLiteral)):
            return True
        if isinstance(node, JsIdentifier):
            return self.read_effect is None or not self.read_effect(node)
        if isinstance(node, (JsFunctionExpression, JsArrowFunctionExpression)):
            return True
        if isinstance(node, JsParenthesizedExpression):
            return node.expression is not None and self.free(node.expression, discarded)
        if isinstance(node, JsUnaryExpression):
            if node.operator == 'delete':
                return False
            return node.operand is not None and self.free(node.operand)
        if isinstance(node, JsMemberExpression):
            if node.object is None:
                return False
            if not self.free(node.object):
                return False
            if node.property is not None and not self.free(node.property):
                return False
            if _is_poison_pill_property(node):
                return self.member_safe is not None and self.member_safe(node)
            if _is_safe_property_base(node.object, self.defunct):
                return True
            return self.member_safe is not None and self.member_safe(node)
        if isinstance(node, (JsBinaryExpression, JsLogicalExpression)):
            return (
                node.left is not None
                and self.free(node.left)
                and node.right is not None
                and self.free(node.right)
            )
        if isinstance(node, JsConditionalExpression):
            return (
                node.test is not None
                and self.free(node.test)
                and node.consequent is not None
                and self.free(node.consequent)
                and node.alternate is not None
                and self.free(node.alternate)
            )
        if isinstance(node, JsObjectExpression):
            for prop in node.properties:
                if not isinstance(prop, JsProperty):
                    return False
                if prop.computed and (prop.key is None or not self.free(prop.key)):
                    return False
                if prop.value is not None and not self.free(prop.value):
                    return False
            return True
        if isinstance(node, JsArrayExpression):
            return all(elem is None or self.free(elem) for elem in node.elements)
        if isinstance(node, JsSequenceExpression):
            return all(self.free(e, discarded) for e in node.expressions)
        if isinstance(node, JsCallExpression):
            if self.defunct and isinstance(node.callee, JsIdentifier) and node.callee.name in self.defunct:
                return all(self.free(arg) for arg in node.arguments)
        if (
            isinstance(node, (JsCallExpression, JsNewExpression))
            and self._call_is_pure(node, discarded)
            and self.call_established is not None
            and self.call_established(node)
        ):
            return all(self.free(arg) for arg in node.arguments)
        return False

    def _call_is_pure(self, node: JsCallExpression | JsNewExpression, discarded: bool) -> bool:
        """
        Whether the call leaf itself carries no effect, choosing the discard-aware predicate only where
        the call's result is thrown away and one was supplied; otherwise the ordinary purity predicate.
        """
        if discarded and self.call_pure_discarded is not None:
            return self.call_pure_discarded(node)
        return self.call_pure is not None and self.call_pure(node)


def side_effect_free(
    node: Node,
    defunct: set[str] | None = None,
    call_pure: _CallPredicate | None = None,
    read_effect: Callable[[Node], bool] | None = None,
    member_safe: Callable[[JsMemberExpression], bool] | None = None,
    call_established: _CallPredicate | None = None,
    discarded: bool = False,
    call_pure_discarded: _CallPredicate | None = None,
) -> bool:
    """
    Conservative check for whether evaluating an expression can be dropped or reordered with no
    observable side effect. Compositional: an expression is free when every sub-expression is. Two leaves
    can bear an effect. The call — free only when its callee is a *defunct* identifier, or, when both
    *call_pure* and *call_established* are supplied, when *call_pure* certifies the call (or `new`) pure,
    *call_established* certifies its callee is in place before the call runs, and its arguments are free.
    An inline function-expression callee is not special-cased: it is resolved like any other callee through
    *call_pure*, which inspects its body, so a called IIFE with an effectful body is not cleared. The
    identifier read — free unless *read_effect* rejects it,
    which `EffectModel.is_side_effect_free` supplies as `SemanticModel.read_has_dynamic_effect` to reject
    a bare name that resolves through a `with` body's dynamic scope (reading it may fire the `with`
    object's getter or throw). A function expression is free without descending into its body — defining
    it runs nothing — so a dynamic-scope read inside an un-called function does not make the value
    effectful. A parenthesized expression is transparent, bearing exactly the effects of the expression
    it groups. A member read is free when its base is a safe value or *member_safe* certifies it;
    `EffectModel.is_side_effect_free` supplies `EffectModel._is_trusted_global_read` to clear a
    getter-free read of a trusted global data property. It passes `EffectModel.is_pure_call` and
    `read_has_dynamic_effect` too. A caller without a model gets the conservative behaviour. When *defunct*
    is given its identifiers name bindings being removed, so calls to them and property reads through
    them are treated as free.

    When *discarded* is set the expression's own value is thrown away by the caller, so a top-level call
    (or `new`) leaf is cleared through *call_pure_discarded* instead of *call_pure* — admitting a call
    whose only residual effect is a write it confines to its returned value, unobservable once that value
    is dropped. The flag reaches only positions that stay discarded — a parenthesized group and every
    element of a sequence — and is reset into any consumed operand, so a nested call whose result is used
    is still held to *call_pure*.
    """
    return _SideEffectScan(
        defunct, call_pure, read_effect, member_safe, call_established, call_pure_discarded,
    ).free(node, discarded)


class _WriteClass(enum.Enum):
    """
    How far a member write through a container base can be observed by code outside the writing
    function. `UNOBSERVABLE`: the container is a fresh value the function owns and never lets escape, so
    no other code can ever reach it and the write is invisible. `VIA_RESULT`: the container is a fresh
    local the function owns, but it — or a closure over it — leaves the function only through the value
    the call returns, so the write is seen only if that value is used, and a call whose result is
    discarded may still be dropped. `OBSERVABLE`: the base may alias state the caller already holds — a
    global, a plain parameter, a binding captured from an enclosing scope, or a write hidden behind a
    dynamic scope — so the write is a plain side effect that any caller can observe.
    """
    UNOBSERVABLE = 0
    VIA_RESULT = 1
    OBSERVABLE = 2


class _FreshKind(enum.Enum):
    """
    What an expression is known to evaluate to, for the purpose of deciding whether a write through it can be
    observed. `NOT_FRESH`: nothing is known, so the value may alias state the caller already holds.
    `CONTAINER`: a container no other code can reach, built where the expression sits. `ARRAY`: the same, and
    additionally known to be an array.

    Array-ness is tracked apart from freshness because the two are independent and both are needed: an
    allocating `Array.prototype` method returns a new array only when the receiver really is an array, and an
    object literal carrying its own `slice` is a fresh container whose `slice` may hand back a shared one. A
    boolean would force that second question to be asked separately at the one site that needs it, which is
    how a predicate acquires a divergent copy.
    """
    NOT_FRESH = 0
    CONTAINER = 1
    ARRAY = 2

    @property
    def is_fresh(self) -> bool:
        return self is not _FreshKind.NOT_FRESH


class EffectModel:
    """
    Per-function effect summaries for one script, built over a
    `refinery.lib.scripts.js.analysis.model.SemanticModel`. Query a function's summary with
    `summary_of` and a call expression's purity with `is_pure_call`. Build through `build_effects`.
    """

    def __init__(self, model: SemanticModel):
        self.model = model
        self.intrinsics_pristine = _intrinsics_pristine(model)
        self.global_pristine = _global_pristine(model)
        self._globals_written, self._global_keys_written = _global_writes_by_name(model)
        self._summaries: dict[int, EffectSummary] = {}
        self._confine_cache: dict[int, Node | None] = {}
        self._immutable_cache: dict[tuple[int, bool], bool] = {}
        self._member_write_cache: dict[int, _WriteClass] = {}
        self._uses_arguments_cache: dict[int, bool] = {}
        self._mutators_escape_cache: dict[int, bool] = {}
        self._some_mutator_cache: dict[int, bool] = {}
        self._functions: list[Node] = self._collect_functions()
        self._compute()

    def summary_of(self, func: Node) -> EffectSummary:
        """
        The effect summary of a function node (or the script). An unknown node is reported as impure.
        """
        return self._summaries.get(id(func), EffectSummary(calls_unknown=True))

    def mutated_bindings(self, func: Node) -> frozenset[Binding]:
        """
        The outer bindings (captured locals and globals) a call to *func* may write, directly or through
        any function it transitively calls, each identified by its `Binding` rather than its name so a
        caller can ask whether one specific binding is mutated. Empty for a function with no such writes
        and for an unknown node alike — use `summary_of(func).calls_unknown` to tell those apart.
        """
        return frozenset(self.summary_of(func).written_bindings)

    def function_can_mutate(self, func: Node, binding: Binding) -> bool:
        """
        Whether a call to *func* may write *binding*, itself or through a transitive callee.
        """
        return binding in self.summary_of(func).written_bindings

    def function_escapes(self, func: Node) -> bool:
        """
        Whether *func* may be invoked at a point the surrounding scope cannot enumerate as a resolvable
        `name(...)` call site: an anonymous function (an IIFE, a callback, stored and called later), or a
        named function whose binding is reassigned, redeclared, or referenced anywhere other than as the
        callee of a direct call (aliased, passed as an argument, `f.call(...)`). A reference inside a
        dynamic scope — a name a `with` body resolves at runtime — counts too: the model cannot order or
        resolve it, so the function may be invoked or aliased there with no static call site. A call to
        such a function can land at a point no call site pins down; a function only ever called directly
        by name has all its invocations enumerated by those call sites.
        """
        binding = self.model.naming_binding(func)
        if binding is None:
            return True
        if binding.writes or binding.dynamic_refs or len(binding.declarations) != 1:
            return True
        for ref in self.model.references(binding):
            parent = ref.parent
            if isinstance(parent, JsCallExpression) and parent.callee is ref:
                continue
            return True
        return False

    def mutators_escape(self, binding: Binding) -> bool:
        """
        Whether some function that may write *binding* — itself or through a transitive callee — escapes
        (`function_escapes`), so a write to *binding* may occur at a point no call site enumerates. When
        true, the places *binding* changes cannot be pinned down, and a caller reasoning about where its
        value survives must treat it as volatile everywhere. Memoized per binding.
        """
        cached = self._mutators_escape_cache.get(id(binding))
        if cached is None:
            cached = any(
                func is not self.model.root
                and binding in self.summary_of(func).written_bindings
                and self.function_escapes(func)
                for func in self._functions
            )
            self._mutators_escape_cache[id(binding)] = cached
        return cached

    def some_function_can_mutate(self, binding: Binding) -> bool:
        """
        Whether any function this file writes may write *binding*, itself or through a transitive
        callee. The answer a caller needs where a call runs a function it cannot name: not knowing
        which one runs, it has to reckon with every one that could. Memoized per binding.
        """
        cached = self._some_mutator_cache.get(id(binding))
        if cached is None:
            cached = any(
                func is not self.model.root and self.function_can_mutate(func, binding)
                for func in self._functions
            )
            self._some_mutator_cache[id(binding)] = cached
        return cached

    def is_pure_call(self, call: JsCallExpression | JsNewExpression) -> bool:
        """
        Whether evaluating *call* has no observable effect: it invokes a trusted pure intrinsic (under
        the pristine-intrinsics precondition) or a local function whose summary is pure.
        """
        callee = self._resolve_callee(call)
        if callee is _PURE:
            return True
        if isinstance(callee, Node):
            return self.summary_of(callee).is_pure
        return False

    def is_pure_call_discarded(self, call: JsCallExpression | JsNewExpression) -> bool:
        """
        Whether evaluating *call* and discarding its result has no observable effect. Like `is_pure_call`
        but resolved through `EffectSummary.is_effect_free_when_discarded`, so a callee whose only residual
        effect is a write it confines to its returned value qualifies — that write is unobservable once the
        result is thrown away. A caller may use this only in a position it has proven discards the value.
        """
        callee = self._resolve_callee(call)
        if callee is _PURE:
            return True
        if isinstance(callee, Node):
            return self.summary_of(callee).is_effect_free_when_discarded
        return False

    def call_clearable(
        self,
        call: JsCallExpression | JsNewExpression,
        callee_established: Callable[[Node], bool],
    ) -> bool:
        """
        Whether *call*'s callee is established — in place before the call runs — given *callee_established*,
        the caller's test for a resolved named local callee. A trusted pure intrinsic and an inline
        function-expression callee (defined at the call site, hence always in place) qualify
        unconditionally; a call resolving to a single named local function qualifies when
        *callee_established* accepts it; an unresolved or ambiguous callee does not. The resolution, the
        intrinsic case, and the inline-callee case live here so callers supply only the ordering judgment
        their layer can make. This certifies establishment ONLY, not purity — a caller deciding whether a
        call may be dropped must conjoin it with `is_pure_call`, as `side_effect_free` does, since an
        established callee may still run an effectful body.
        """
        resolved = self._resolve_callee(call)
        if resolved is _PURE:
            return True
        if isinstance(resolved, Node):
            if isinstance(strip_parens(call.callee), (JsFunctionExpression, JsArrowFunctionExpression)):
                return True
            return callee_established(resolved)
        return False

    def _established_call_default(self, call: JsCallExpression | JsNewExpression) -> bool:
        """
        The ordering-free floor for `is_side_effect_free`: clears a trusted pure intrinsic, an inline
        function-expression callee (established at its call site), or a call to a hoisted function
        declaration (empty `establishment_sites`), whose value is in place before any statement runs. A
        non-hoisted named local callee — a `const`/`let`/`var` initializer or a bare assignment — is
        refused, since this model cannot order the definition against the call; a caller that can supplies
        its own `call_established`.
        """
        return self.call_clearable(call, lambda func: self.model.establishment_sites(func) == [])

    def is_side_effect_free(
        self,
        node: Node,
        defunct: set[str] | None = None,
        member_safe: Callable[[JsMemberExpression], bool] | None = None,
        call_established: Callable[[JsCallExpression | JsNewExpression], bool] | None = None,
        discarded: bool = False,
    ) -> bool:
        """
        Whether evaluating *node* can be dropped or reordered without an observable side effect, with
        the call leaf resolved through this model's `is_pure_call`: a call to a proven-pure function or
        trusted intrinsic is free, recursing into its arguments. *defunct* names bindings being removed,
        whose calls and property reads are treated as free. This is the model-aware form of the
        model-free `side_effect_free` in this module, which clears only calls to a defunct name; unlike
        it, an identifier read that resolves through a `with` body's dynamic
        scope is rejected here — reading the bare name may fire the `with` object's getter or throw (see
        `refinery.lib.scripts.js.analysis.model.SemanticModel.read_has_dynamic_effect`) — while a
        function value whose body performs such a read stays free, since defining it runs nothing. A
        caller with control-flow context passes *member_safe* to also clear a getter-free read through a
        local global-object alias it can prove established before the read; the default clears only the
        syntactic global case (`_is_trusted_global_read`).

        With *discarded* the caller asserts *node*'s own value is thrown away, so a top-level call leaf is
        cleared through `is_pure_call_discarded` and a callee that only mutates a local it returns is
        droppable — the removal contexts of `JsUnusedCodeRemoval` supply it.
        """
        return side_effect_free(
            node,
            defunct,
            self.is_pure_call,
            self.model.read_has_dynamic_effect,
            member_safe or self._getter_free_read,
            call_established or self._established_call_default,
            discarded,
            self.is_pure_call_discarded,
        )

    def binding_is_immutable_container(
        self, binding: Binding, *, member_calls_mutate: bool = True, exclude: Node | None = None,
    ) -> bool:
        """
        Whether *binding* holds a container — an object or array — whose element and property values are
        stable after construction, so that an access into it may be soundly inlined at its read sites.
        Every reference must read through the container (`obj.k`, `obj[i]`) or plainly rebind the name
        (`obj = ...`, whose value the caller resolves by domination); a write through the container
        (`obj.k = v`, `obj[i]++`, `delete obj[i]`, a `for-of` or destructuring target) makes it mutable.
        A method invoked on the container (`obj.m(...)`) may mutate it — an array's `sort`/`push`/`splice`
        and so on — so by default it too counts as mutable; a caller that knows the container's methods
        cannot mutate it (an object literal with no `this`-bound property) may pass *member_calls_mutate*
        false to permit such calls. A reference that escapes is safe in two cases: it aliases another
        binding that is itself an immutable container (alias-following the textual predicates this
        replaces could not do, and the reason a reassigned-and-aliased lookup array stays inlinable), or
        it is passed to a statically known function as an argument whose parameter is itself an immutable
        container (so the callee neither mutates nor further-escapes it). Any other escape — returned,
        stored as a property, passed to a call that cannot be resolved — is treated conservatively as
        mutable. A mutation through a dynamic scope is modelled: a `with` body that names the container —
        a member write, method call, reassignment, or escape — is attributed to it as a dynamic reference
        and judged by the same role logic, so a `with` that never names it keeps it foldable, and a direct
        `eval` in a local container's own function makes it mutable. The one residual is a script-scope
        container reached by an opaque global surface — a direct `eval`, `Function`, timer, or dynamic
        global write whose code cannot be read — which cannot be frozen without also freezing the lookup
        arrays real samples fold, so it is left to the caller's reflection reasoning, the trust an
        unresolved external call already receives.

        The query is over a *resolved binding*, so it is shadowing-correct, and it descends through
        alias chains, callee parameters, and nested functions, so a capturing closure that mutates the
        container is caught. The answer is fixed for the model's lifetime — a binding's reference set does
        not change — so it is memoized per `(binding, member_calls_mutate)`. A caller may pass *exclude*
        to disregard references within that subtree — asking whether the container is stable across the
        rest of the program, ignoring a read site about to be relocated into it; such a query is not
        memoized, since the answer depends on the excluded region.
        """
        if exclude is not None:
            return self._immutable_container(binding, set(), member_calls_mutate, exclude)
        key = (id(binding), member_calls_mutate)
        cached = self._immutable_cache.get(key)
        if cached is None:
            cached = self._immutable_container(binding, set(), member_calls_mutate)
            self._immutable_cache[key] = cached
        return cached

    def _immutable_container(
        self, binding: Binding, visiting: set[int], member_calls_mutate: bool, exclude: Node | None = None,
    ) -> bool:
        key = id(binding)
        if key in visiting:
            return True
        visiting = visiting | {key}
        if self._dynamic_scope_mutates(binding, member_calls_mutate, exclude):
            return False
        for ref in self.model.references(binding, exclude=exclude):
            role = container_reference_role(ref)
            if role is ContainerRole.MEMBER_WRITE:
                return False
            if role is ContainerRole.MEMBER_CALL and member_calls_mutate:
                return False
            if role is ContainerRole.ESCAPE:
                if not isinstance(ref, JsIdentifier) or not self._escape_keeps_container(
                    ref, visiting, member_calls_mutate,
                ):
                    return False
        return True

    def _dynamic_scope_mutates(
        self, binding: Binding, member_calls_mutate: bool, exclude: Node | None,
    ) -> bool:
        """
        Whether a dynamic scope may change the container *binding* holds. A direct `eval` in a local
        container's own function can rewrite it opaquely — a global is left to the caller's reflection
        reasoning, since freezing every global on any surface over-blocks. A `with` body's accesses are
        attributed by name: a member write, a reassignment, or an escape mutates it or may alias it out,
        and a method call may mutate it unless the caller vouches that its methods cannot; only a plain
        member read leaves it intact, so a `with` that never names the container is no threat. A dynamic
        escape or reassignment cannot be alias-followed or ordered the way a resolved one can, so either
        is treated as mutating.
        """
        if self.model.local_reachable_by_direct_eval(binding):
            return True
        for ref in self.model.dynamic_references(binding, exclude=exclude):
            role = container_reference_role(ref)
            if role is ContainerRole.MEMBER_READ:
                continue
            if role is ContainerRole.MEMBER_CALL and not member_calls_mutate:
                continue
            return True
        return False

    def _escape_keeps_container(self, ref: JsIdentifier, visiting: set[int], member_calls_mutate: bool) -> bool:
        """
        Whether an escaping reference leaves the container unmutated. Two escapes are precise: an alias
        (`var x = ref` or `x = ref`) keeps it when the aliased binding is itself an immutable container,
        and an argument passed to a statically known function (`f(ref)`) keeps it when the parameter it
        binds is itself an immutable container — interprocedural Case B, the parameter's own references
        decide whether the callee mutates or further-escapes it. Every other escape is conservatively
        unsafe.
        """
        alias = self._alias_target(ref)
        if alias is not None:
            return self._immutable_container(alias, visiting, member_calls_mutate)
        return self._argument_keeps_container(ref, visiting)

    def _argument_keeps_container(self, ref: JsIdentifier, visiting: set[int]) -> bool:
        """
        Case B: whether an argument *ref* passed to a statically known function leaves the container it
        holds unmutated — true when the parameter it binds is itself an immutable container, judged
        recursively from that parameter's own references, so the callee neither member-writes the
        argument nor lets it escape mutably. The parameter is judged under the conservative
        `member_calls_mutate=True`: a relaxed `member_calls_mutate=False` is the *caller*'s promise that
        the container's own methods cannot mutate it at the original site, and does not carry to a method
        the callee invokes on the argument or on one of its nested containers (`x.a.push(...)`), which
        may mutate it. False, conservatively, when the call cannot be analysed: the callee is not a
        single known function, it can reach the argument through its own `arguments` object, the argument
        is spread, a spread precedes it (so its runtime position shifts past the textual index and the
        parameter it binds cannot be pinned down), the slot it lands in is a rest or destructuring
        parameter, or the parameter is reachable through a `with` or direct `eval` in the callee that
        resolves a name at runtime (an unrecorded write the parameter's reference set cannot rule out).
        An argument with no parameter to bind — passed beyond the declared parameters of a function with
        no rest collector and no `arguments` reach, textual or reflective — is safe, since the callee
        cannot name it.
        """
        parent = ref.parent
        if not isinstance(parent, JsCallExpression) or ref not in parent.arguments:
            return False
        func = self.unambiguous_callee(parent)
        if func is None:
            return False
        if self._callee_uses_arguments(func):
            return False
        params = func.params
        if any(isinstance(param, JsRestElement) for param in params):
            return False
        index = parent.arguments.index(ref)
        if any(isinstance(arg, JsSpreadElement) for arg in parent.arguments[:index]):
            return False
        if index >= len(params):
            return True
        param = params[index]
        if not isinstance(param, JsIdentifier):
            return False
        binding = self.model.binding_of(param)
        if binding is None:
            return False
        if self.model.reflection_can_reach(binding):
            return False
        return self._immutable_container(binding, visiting, True)

    def _callee_uses_arguments(self, func: Node) -> bool:
        """
        Whether a non-arrow callee can reach its call's arguments through its own `arguments` object,
        which aliases the positional arguments — including any passed beyond the declared parameters — so
        that `arguments[i][...] = v` mutates a container the by-position parameter reasoning in
        `_argument_keeps_container` would otherwise miss. It is reached either by naming `arguments`
        directly, or reflectively: a `with` or a direct `eval` in the callee — or in a closure nested
        inside it, which inherits the callee's `arguments` — can read that object with no textual
        reference, so a reflectively reachable `arguments` counts too. An arrow has no `arguments` of its
        own (a reference inside it binds the enclosing function's, unrelated to the arrow's parameters),
        so it is exempt. When the callee can reach `arguments`, the escape is treated as mutable. The
        answer is a structural property of the callee, so it is memoized per function.
        """
        cached = self._uses_arguments_cache.get(id(func))
        if cached is None:
            cached = self._compute_callee_uses_arguments(func)
            self._uses_arguments_cache[id(func)] = cached
        return cached

    def _compute_callee_uses_arguments(self, func: Node) -> bool:
        if isinstance(func, JsArrowFunctionExpression):
            return False
        func_scope = self.model.parameter_scope(func)
        if func_scope is None:
            return False
        binding = func_scope.bindings.get('arguments')
        if binding is None:
            return False
        if self.model.references(binding):
            return True
        return self.model.reflection_can_reach(binding)

    def static_callee(
        self, call: JsCallExpression
    ) -> JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression | None:
        """
        The function a call invokes, resolved permissively through `function_of`: a direct function or
        arrow expression callee, or an identifier bound to a single function — a declaration, a
        `var`/`let`/`const` initializer, or the value a name is assigned exactly once. For a name that
        held a value and was then reassigned this returns the post-reassignment value, which is the
        running target only where that reassignment is established before the call; a consumer that
        cannot order the reassignment against the call must use `unambiguous_callee` instead. `None` for
        a method call, a parameter, a redeclared or dynamically-rebindable binding, or an unresolved name.
        """
        callee = call.callee
        if isinstance(callee, (JsFunctionExpression, JsArrowFunctionExpression)):
            return callee
        if not isinstance(callee, JsIdentifier):
            return None
        return self.function_of(self.model.resolve(callee))

    def a_name_this_file_binds_holds_the_callee(self, call: JsCallExpression) -> bool:
        """
        Whether *call* names its callee with an identifier this file binds, while `static_callee`
        declines to say which function that binding holds.

        A caller reasoning about what a call may have done reads `static_callee` answering `None` in
        two ways, and this tells them apart. Where the callee is a method, a host function, or a
        name nothing here declares, `None` means the call runs something outside this file's
        reckoning, which is a standing condition every such caller was written under. Where it is a
        name this file binds, `None` means the model saw the binding and would not state its value
        - one Annex B copies into a block's enclosing scope is such a function - and what it runs
        may be any of the ones written here, one that writes the very binding being reasoned about
        included.
        """
        callee = strip_parens(call.callee)
        if not isinstance(callee, JsIdentifier):
            return False
        if self.model.resolve(callee) is None:
            return False
        return self.static_callee(call) is None

    def unambiguous_callee(
        self, call: JsCallExpression
    ) -> JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression | None:
        """
        The ordering-free twin of `static_callee`, for a consumer that reasons about a call without
        knowing where it sits in execution order. Identical except an identifier callee resolves through
        `unambiguous_function`, so a name that held a value and was then reassigned — whose running target
        depends on the call's position relative to the reassignment — yields `None` rather than the
        post-reassignment value.
        """
        return _unambiguous_callee(self.model, call)

    def _alias_target(self, ref: JsIdentifier) -> Binding | None:
        parent = ref.parent
        if isinstance(parent, JsVariableDeclarator) and parent.init is ref:
            if isinstance(parent.id, JsIdentifier):
                return self.model.binding_of(parent.id)
            return None
        if (
            isinstance(parent, JsAssignmentExpression)
            and parent.right is ref
            and parent.operator == '='
            and isinstance(parent.left, JsIdentifier)
        ):
            return self.model.resolve(parent.left)
        return None

    def _collect_functions(self) -> list[Node]:
        functions: list[Node] = [self.model.root]
        for node in self.model.root.walk():
            if isinstance(node, FUNCTION_NODES):
                functions.append(node)
        return functions

    def _compute(self):
        for func in self._functions:
            self._summaries[id(func)] = EffectSummary()
        changed = True
        while changed:
            changed = False
            for func in self._functions:
                summary = self._scan(func)
                if summary != self._summaries[id(func)]:
                    self._summaries[id(func)] = summary
                    changed = True

    def _scan(self, func: Node) -> EffectSummary:
        summary = EffectSummary()
        if isinstance(func, FUNCTION_NODES) and wraps_return(func):
            summary.wraps_return = True
        for node in _body_nodes(func):
            if isinstance(node, JsThrowStatement):
                summary.throws = True
            elif isinstance(node, JsIdentifier):
                if not summary.throws and self.model.read_may_throw(node):
                    summary.throws = True
                if reference_role(node) is not Role.READ:
                    self._account_write(summary, node, func)
            elif isinstance(node, JsMemberExpression):
                base = node.object
                if base is not None and not self._base_is_safe(base):
                    summary.throws = True
                if is_member_write_target(node):
                    write_class = self._member_write_class(node, func)
                    if write_class is _WriteClass.OBSERVABLE:
                        summary.writes_global = True
                    elif write_class is _WriteClass.VIA_RESULT:
                        summary.mutates_returned_local = True
                elif base is not None and not self._getter_free_read(node):
                    summary.calls_unknown = True
            elif isinstance(node, (JsCallExpression, JsNewExpression)):
                self._account_call(summary, node)
            elif isinstance(node, JsImportExpression):
                summary.calls_unknown = True
        return summary

    def _account_write(self, summary: EffectSummary, target: JsIdentifier, func: Node):
        binding = self.model.resolve(target)
        if binding is None:
            summary.writes_global = True
            return
        if self._owns_binding(binding, func):
            return
        if binding.is_read:
            summary.written_bindings.add(binding)
        if self._write_unobservable(binding, func):
            return
        if binding.kind is BindingKind.IMPLICIT_GLOBAL or binding.scope is self.model.root_scope:
            summary.writes_global = True
        else:
            summary.writes_captured = True

    def _write_unobservable(self, binding: Binding, func: Node) -> bool:
        """
        Whether assigning *binding* within *func* has no observable consumer, so the assignment
        is not counted as a write. The program must be `global_pristine`: it exposes no reflection
        surface through which the name could be read and installs no accessor that an assignment to
        a global property could trigger as a setter. Then the write is unobservable when either the
        value is read nowhere (`Binding.is_read` is false), or every reference to it is
        `_confined_to` *func* so no outside code can see it. This ports the evaluator's sound
        permissiveness for an obfuscator's scratch binding — whether a write-only global or an
        accumulator local to a single function.

        Not counting the write is not the same as answering that the function is pure. A confined
        accumulator whose name is an implicit global is also *read*, and
        `SemanticModel.read_may_throw` answers that such a read may throw, since nothing here orders
        the creating assignment in front of it; the summary then carries `throws` with
        `writes_global` clear.
        """
        if not self.global_pristine:
            return False
        return not binding.is_read or self._confined_to(binding, func)

    def _confined_to(self, binding: Binding, func: Node) -> bool:
        """
        Whether every reference to *binding* lies within *func*, which must be a function rather than the
        script, so the binding does not escape: no code outside *func* can read it, and a write to it is
        unobservable past the single call.
        """
        if not isinstance(func, FUNCTION_NODES):
            return False
        return self._confining_function(binding) is func

    def _member_write_class(self, member: JsMemberExpression, func: Node) -> _WriteClass:
        """
        How observable the container written by *member* (`base.k = v`, `base[i]++`, `delete base[i]`) is
        to code outside *func* — the distinction that lets a mutation of an obfuscator's scratch container
        be tolerated without weakening purity. The base must be a fresh value: written directly on an
        object/array/function literal, or resolving to a binding *func* owns whose value is always freshly
        built — a rest parameter, which the language guarantees is a new array, or a local initialized
        only to an object/array/function literal. An object literal with an own setter — or one that
        installs a custom prototype through `__proto__:`, which may carry an inherited setter — does NOT
        qualify, since the write then runs an accessor a caller can observe. A plain parameter does NOT
        qualify either: it aliases the caller's object, so `function modify(a){ a[0] = 9; }` mutates the
        argument observably — the soundness boundary this rests on. Ownership is the exact test
        `_account_write` uses for a plain-identifier write (`func_scope.contains(binding.scope)` and not
        global), so a binding captured *from an enclosing scope* is not owned and its mutation stays
        `OBSERVABLE`, matching that a call mutating an outer local is a visible effect.

        For an owned fresh container the outcome splits on how it escapes. When no reference lets it out
        (`_container_non_escaping`) and no nested function captures it, the write is `UNOBSERVABLE` — the
        container dies with the call and no caller can ever reach it, so a function whose only effect is
        the mutation is pure. Otherwise the container — or a closure over it — leaves *func*, but every
        escape route other than the return value independently sets a blocking flag on the summary (a
        store to a global or captured binding, a leak into an unknown callee, a throw), so the only
        unflagged escape is `return`, whose value the caller may discard: the write is then `VIA_RESULT`,
        seen only if that value is used.

        A write hidden behind a dynamic scope — through a name a `with` body or direct `eval` resolves at
        runtime — is `OBSERVABLE`: the base resolves to no binding, so the write is conservatively kept,
        which is sound. The residual is the opaque-surface one `binding_is_immutable_container` documents:
        a reflective surface whose code cannot be read could install a prototype accessor that observes a
        write this deems unobservable, and freezing on it would refuse the obfuscator idioms this is meant
        to see through, so it is left to that boundary.

        The judgment is structural — fixed by the binding's declarations and reference set — so it is
        invariant across the fixpoint passes that recompute the summaries, and is memoized per member.
        """
        cached = self._member_write_cache.get(id(member))
        if cached is None:
            cached = self._classify_member_write(member, func)
            self._member_write_cache[id(member)] = cached
        return cached

    def _classify_member_write(self, member: JsMemberExpression, func: Node) -> _WriteClass:
        base = member.object
        if isinstance(base, (JsArrayExpression, JsFunctionExpression)):
            return _WriteClass.UNOBSERVABLE
        if isinstance(base, JsObjectExpression):
            if object_member_access_runs_accessor(base):
                return _WriteClass.OBSERVABLE
            return _WriteClass.UNOBSERVABLE
        if not isinstance(base, JsIdentifier):
            return _WriteClass.OBSERVABLE
        binding = self.model.resolve(base)
        if binding is None or not self._owns_binding(binding, func):
            return _WriteClass.OBSERVABLE
        if not self._fresh_container_origin(binding, func):
            return _WriteClass.OBSERVABLE
        if not binding.captured and self._container_non_escaping(binding):
            return _WriteClass.UNOBSERVABLE
        return _WriteClass.VIA_RESULT

    def _owns_binding(self, binding: Binding, func: Node) -> bool:
        """
        Whether *binding* is declared within *func* rather than reaching in from an enclosing scope or the
        global object — the exact ownership test `_account_write` applies to a plain-identifier write, so a
        mutation of an owned local and a mutation of its name agree on observability. A binding *func* owns
        has all its references inside *func*'s subtree, so the summary scan sees every one of its escapes.

        A function whose parameter list holds an expression introduces its parameters and its own
        name in scopes standing *outside* its body's, so containment alone would read a write to its
        own parameter as a write reaching in from elsewhere. `Scope.closure_home` says those scopes
        are the same call as the body, and answers for all three shapes a function is built in.
        """
        if binding.kind is BindingKind.IMPLICIT_GLOBAL or binding.scope is self.model.root_scope:
            return False
        func_scope = self.model.function_scope(func)
        if func_scope is None:
            return False
        return func_scope.contains(binding.scope) or binding.scope.closure_home is func_scope

    def _fresh_container_origin(self, binding: Binding, func: Node) -> bool:
        """
        Whether *binding* only ever holds a container freshly built inside *func*, so a member write through
        it cannot be observed anywhere else. The binding-level face of `_fresh_kind`; see that method.
        """
        return self._binding_fresh_kind(binding, func, frozenset()).is_fresh

    def _binding_fresh_kind(
        self, binding: Binding, func: Node, visiting: frozenset[int]
    ) -> _FreshKind:
        """
        The kind of container *binding* is known to hold on every path, judged from inside *func*. A rest
        parameter is a fresh array by language guarantee. Otherwise the binding must be one *func* owns — a
        name reaching in from an enclosing scope or the global object denotes a container other code can
        already reach, however freshly its initializer built it — and *every* value it can take must be
        fresh: each declaration's initializer and each later write, because a name that holds an outer
        container even once makes a write through it observable there. A binding written through a pattern
        rather than a plain assignment target has no single value expression to judge, so it fails.

        A binding whose `constructor` or `__proto__` is written anywhere is never an `ARRAY`, however it was
        built. Those two properties decide what an allocating `Array.prototype` method actually returns:
        `slice` and its neighbours route through ArraySpeciesCreate, which reads
        `constructor[Symbol.species]` off the receiver, so a program that writes either one can make the
        "new" array be a shared object — or make the call throw, by leaving a primitive there. The container
        is still fresh, so a write *into* it stays unobservable; it is only the array guarantee that is lost.

        The kind is the weakest of the values, since a consumer may only rely on what holds for all of them.
        """
        if self._is_rest_param(binding):
            return _FreshKind.ARRAY
        if binding.kind not in (BindingKind.VAR, BindingKind.LET, BindingKind.CONST):
            return _FreshKind.NOT_FRESH
        if not self._owns_binding(binding, func):
            return _FreshKind.NOT_FRESH
        if not binding.declarations or id(binding) in visiting:
            return _FreshKind.NOT_FRESH
        visiting = visiting | {id(binding)}
        kind = _FreshKind.ARRAY
        if self._species_written(binding):
            kind = _FreshKind.CONTAINER
        for decl in binding.declarations:
            declarator = decl.parent
            if not isinstance(declarator, JsVariableDeclarator):
                return _FreshKind.NOT_FRESH
            kind = _weakest(kind, self._fresh_kind(declarator.init, func, visiting))
            if not kind.is_fresh:
                return _FreshKind.NOT_FRESH
        for ref in self.model.references(binding):
            if reference_role(ref) is Role.READ:
                continue
            parent = ref.parent
            if not isinstance(parent, JsAssignmentExpression) or parent.left is not ref:
                return _FreshKind.NOT_FRESH
            if parent.operator != '=':
                return _FreshKind.NOT_FRESH
            kind = _weakest(kind, self._fresh_kind(parent.right, func, visiting))
            if not kind.is_fresh:
                return _FreshKind.NOT_FRESH
        return kind

    def _species_written(self, binding: Binding) -> bool:
        """
        Whether any reference to *binding* writes a property that decides what an allocating
        `Array.prototype` method returns. Only `constructor` and `__proto__` do: ArraySpeciesCreate reads
        `constructor[Symbol.species]` off the receiver, and `__proto__` replaces the prototype the
        `constructor` lookup walks.

        A key that is not a literal counts, because it may name either one. That conservatism is affordable
        here and would not be in a whole-program rule: the question is asked about the handful of references
        to one binding, so an ordinary `s[i] = v` element write on a *different* binding is untouched. It is
        also the direction that survives constant folding — a fold turns `a['const' + 'ructor']` into
        `a.constructor`, moving the answer from unsafe to unsafe rather than from safe to unsafe.
        """
        for ref in self.model.references(binding):
            parent = ref.parent
            if not isinstance(parent, JsMemberExpression) or parent.object is not ref:
                continue
            if not is_member_write_target(parent):
                continue
            prop = parent.property
            if not parent.computed:
                if isinstance(prop, JsIdentifier) and prop.name in _SPECIES_KEYS:
                    return True
            elif isinstance(prop, JsStringLiteral):
                if prop.value in _SPECIES_KEYS:
                    return True
            elif not isinstance(prop, JsNumericLiteral):
                return True
        return False

    def _fresh_kind(self, node: Node | None, func: Node, visiting: frozenset[int]) -> _FreshKind:
        """
        The kind of container the expression *node* is known to build when evaluated inside *func*, or
        `NOT_FRESH` when it may evaluate to something other code can already reach. This is a *must* analysis:
        it answers only for expressions whose result is provably a new object, which is what lets a member
        write through the result be classified as unobservable.

        Five forms qualify. A container literal builds its value on the spot — unless its member access runs an
        accessor, the shared `container_literal_access_is_plain` test. An identifier resolves through its
        binding, which *func* must own. An allocating `Array.prototype` method
        (`_FRESH_ARRAY_RESULT_METHODS`) returns a new array, but only when the prototype is undisturbed and
        the receiver is itself known to be an array: a fresh object literal carrying its own `slice` is not,
        and neither is a value of unknown type such as a plain parameter. A call the model resolves to one
        function qualifies when every `return` in that function yields a fresh container — and a function with
        a path that returns no value does not, since that path yields `undefined`. `new Array(...)` is
        deferred to `_pure_construct`, which owns the argument rule for that root.

        Deliberately *not* a may-allocate analysis. `JsObjectFold._value_allocates` asks the opposite
        question — whether an expression might mint an object whose identity a fold would duplicate — and
        answers `True` for any nested call, where this answers `NOT_FRESH` for nearly all of them. The two are
        not monotone in one another and must not be merged.
        """
        node = strip_parens(node)
        if node is None:
            return _FreshKind.NOT_FRESH
        if isinstance(node, JsArrayExpression):
            return _FreshKind.ARRAY
        if isinstance(node, (JsObjectExpression, JsFunctionExpression, JsArrowFunctionExpression)):
            return _FreshKind.CONTAINER if container_literal_access_is_plain(node) else _FreshKind.NOT_FRESH
        if isinstance(node, JsIdentifier):
            binding = self.model.resolve(node)
            if binding is None:
                return _FreshKind.NOT_FRESH
            return self._binding_fresh_kind(binding, func, visiting)
        if isinstance(node, JsNewExpression):
            return _FreshKind.ARRAY if self._pure_construct(node) else _FreshKind.NOT_FRESH
        if isinstance(node, JsCallExpression):
            return self._call_fresh_kind(node, func, visiting)
        return _FreshKind.NOT_FRESH

    def _call_fresh_kind(
        self, call: JsCallExpression, func: Node, visiting: frozenset[int]
    ) -> _FreshKind:
        callee = strip_parens(call.callee)
        if isinstance(callee, JsMemberExpression) and not callee.computed:
            prop = callee.property
            if not isinstance(prop, JsIdentifier) or prop.name not in _FRESH_ARRAY_RESULT_METHODS:
                return _FreshKind.NOT_FRESH
            if not self.trusted_prototype(list):
                return _FreshKind.NOT_FRESH
            if self._fresh_kind(callee.object, func, visiting) is not _FreshKind.ARRAY:
                return _FreshKind.NOT_FRESH
            return _FreshKind.ARRAY
        if isinstance(callee, JsIdentifier):
            callee_func = self.unambiguous_function(self.model.resolve(callee))
            if callee_func is None or id(callee_func) in visiting:
                return _FreshKind.NOT_FRESH
            visiting = visiting | {id(callee_func)}
            returns = [n for n in _body_nodes(callee_func) if isinstance(n, JsReturnStatement)]
            if not returns or not _returns_on_every_path(callee_func):
                return _FreshKind.NOT_FRESH
            kind = _FreshKind.ARRAY
            for statement in returns:
                kind = _weakest(kind, self._fresh_kind(statement.argument, callee_func, visiting))
                if not kind.is_fresh:
                    return _FreshKind.NOT_FRESH
            return kind

        return _FreshKind.NOT_FRESH

    @staticmethod
    def _is_rest_param(binding: Binding) -> bool:
        """
        Whether *binding* is a function's rest parameter (`function f(...xs)`), whose value the language
        guarantees is a fresh array on every call.
        """
        return binding.kind is BindingKind.PARAM and any(
            isinstance(decl.parent, JsRestElement) for decl in binding.declarations
        )

    def _container_non_escaping(self, binding: Binding) -> bool:
        """
        Whether every reference to *binding* keeps its container contained: each is a member read or
        write (`obj.k`, `obj[i] = v`), never an escape, rebinding, or method call through which the
        container could be aliased out, mutated by other code, or replaced. The tightest form of the
        escape check, since a mutation only stays unobservable while no other code can reach the object.

        Orthogonal to freshness, and deliberately not merged with `_binding_fresh_kind`: this asks where a
        container *goes*, that asks where it *came from*. Both are needed and neither implies the other — a
        fresh literal can escape, and a parameter that never escapes was still not built here.
        `_classify_member_write` is where the two compose.
        """
        for ref in self.model.references(binding):
            if container_reference_role(ref) not in (
                ContainerRole.MEMBER_READ, ContainerRole.MEMBER_WRITE,
            ):
                return False
        return True

    def _confining_function(self, binding: Binding) -> Node | None:
        """
        The single function that lexically encloses every reference to *binding*, or `None` when the
        references do not share one — they span sibling functions or reach the top level. Cached per
        binding, since the binding's reference set is fixed for the lifetime of the model.
        """
        key = id(binding)
        if key not in self._confine_cache:
            self._confine_cache[key] = self._scan_confining_function(binding)
        return self._confine_cache[key]

    def _scan_confining_function(self, binding: Binding) -> Node | None:
        refs = self.model.references(binding)
        if not refs:
            return None
        enclosing = enclosing_function(refs[0])
        if enclosing is None:
            return None
        for ref in refs[1:]:
            if enclosing_function(ref) is not enclosing:
                return None
        return enclosing

    def _account_call(self, summary: EffectSummary, call: JsCallExpression | JsNewExpression):
        callee = self._resolve_callee(call)
        if callee is _PURE:
            return
        if isinstance(callee, Node):
            summary.absorb(self.summary_of(callee))
        else:
            summary.calls_unknown = True

    def _resolve_callee(self, call: JsCallExpression | JsNewExpression) -> Node | _PureCall | None:
        callee = call.callee
        if isinstance(call, JsNewExpression) and self._pure_construct(call):
            return _PURE
        if isinstance(callee, (JsFunctionExpression, JsArrowFunctionExpression)):
            return callee
        if isinstance(callee, JsMemberExpression) and not callee.computed:
            base, prop = callee.object, callee.property
            if isinstance(base, JsIdentifier) and isinstance(prop, JsIdentifier):
                if F'{base.name}.{prop.name}' in _PURE_INTRINSIC_METHODS and self._is_global_intrinsic(base):
                    return _PURE
            return None
        if isinstance(callee, JsIdentifier):
            if callee.name in _PURE_GLOBAL_FUNCTIONS and self._is_global_intrinsic(callee):
                return _PURE
            return self.unambiguous_function(self.model.resolve(callee))
        return None

    def _pure_construct(self, call: JsNewExpression) -> bool:
        """
        Whether `new <callee>(...)` is a pure allocation: the callee denotes a pristine constructor root in
        `_PURE_CONSTRUCTOR_ROOTS` and its arguments are safe for that root. `Array` — the only such root
        today — throws only on a bad single numeric length, decided by `_array_construct_is_pure`; a root
        added to the set needs its own argument rule wired in here rather than reusing Array's.

        Purity of the construction, not freshness of its result: those are separate questions, and this stays
        separate from `_fresh_kind` even though that predicate's `new Array(n)` form calls it. A construction
        can be impure and still yield a fresh object, so a caller wanting freshness must ask `_fresh_kind`.
        """
        root = self.intrinsic_of(call.callee)
        if not (isinstance(root, str) and root in _PURE_CONSTRUCTOR_ROOTS):
            return False
        return _array_construct_is_pure(call.arguments)

    def function_of(
        self, binding: Binding | None
    ) -> JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression | None:
        """
        The single function a *binding* stably resolves to — a sole declaration's function declaration or
        function/arrow initializer, or a name assigned a function exactly once (`f = function(){}`, the
        form namespace flattening leaves) — or `None` when the binding is absent, redeclared, reassigned
        to more than one value, dynamically rebindable, or not bound to a function. A lone assignment
        counts because the name denotes that one function wherever it is not in the value's temporal dead
        zone; a caller that also needs the value established before a use orders it separately. The
        binding-level twin of `static_callee`, and the function-typed specialization of
        `SemanticModel.singular_value`: it filters that value-resolution to a function node.
        """
        value = self.model.singular_value(binding)
        if isinstance(value, FUNCTION_NODES):
            return value
        return None

    def unambiguous_function(
        self, binding: Binding | None
    ) -> JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression | None:
        """
        The single function *binding* names for a consumer that resolves calls without execution ordering
        — the interpreter — or `None`. `function_of` narrowed to that ordering-free view: a pure function
        declaration, or a hoisted `var`/`let` assigned a function exactly once (`var f; f = function(){}`,
        the bare-assignment form namespace flattening leaves), qualifies; a name that already carried a
        value from its declaration — a function/class declaration, an initialized declarator, or a
        parameter — and is then reassigned holds two values across its life and is refused. This reproduces
        the filter the evaluator's visible-functions map applied before interpretation routed resolution
        through the model.
        """
        return _unambiguous_function(self.model, binding)

    def _is_global_intrinsic(self, name: JsIdentifier) -> bool:
        """
        Whether *name* denotes a trusted intrinsic root that the program leaves pristine and does not
        shadow with a local binding at this use site.
        """
        if not self.intrinsics_pristine:
            return False
        return self.model.lookup(name.name, self.model.scope_of(name)) is None

    def intrinsic_of(self, node: Node | None) -> str | _GlobalObject | None:
        """
        The pristine intrinsic value *node* provably denotes: `GLOBAL_OBJECT` for the global object, an
        intrinsic root name (`'Array'`, `'String'`, …) for a named intrinsic, or `None`. A name is
        returned only under `intrinsics_pristine` and where the identifier is unshadowed at this use site,
        so the result may be *value-trusted* — used to construct, to clear a getter-free static read, or
        to fold `A || B`. Every value it can return — `globalThis` and every `_PURE_INTRINSIC_ROOTS`
        member — is truthy, so `A || B` evaluates to `A` whenever `intrinsic_of(A)` is not `None`; a
        contributor extending this must preserve that truthiness invariant and never return a falsy name
        such as `NaN`/`undefined`.

        It deliberately does NOT follow a local alias through its value — `intrinsic_of` of an identifier
        bound to `var x = Array` is `None` — because a local's value holds only where it is established, a
        control-flow fact this flow-insensitive query cannot certify; a consumer that owns dominance
        resolves the alias itself against `singular_value`. It likewise does not treat `<global-object>.Name`
        as a value: that read's getter-freeness rests on `global_pristine`, a weaker premise than value
        trust, so it stays the concern of `_is_trusted_global_read`.
        """
        node = strip_parens(node)
        if isinstance(node, JsIdentifier):
            if node.name == 'globalThis' and self.model.lookup(node.name, self.model.scope_of(node)) is None:
                return GLOBAL_OBJECT
            if node.name in _PURE_INTRINSIC_ROOTS and self._is_global_intrinsic(node):
                return node.name
            return None
        if isinstance(node, JsLogicalExpression) and node.operator == '||':
            return self.intrinsic_of(node.left)
        return None

    def trusted_intrinsic(self, node: Node | None) -> str | None:
        """
        The global name *node* denotes, when that one name is provably still the built-in, or `None`. A
        name qualifies when the program never binds it, never assigns to it, never writes or updates a
        property anywhere on it, and exposes no reflection surface through which it could be replaced at
        runtime.

        This differs from `intrinsic_of` in *scope of the question*, not in strictness. `intrinsic_of`
        rests on `intrinsics_pristine`, one program-wide flag over a fixed root set, so a single
        `Object.prototype.x = 1` withdraws trust from every intrinsic in the file — including `Math`,
        which that line cannot affect. This query answers per name, so the same program still folds
        `Math.floor` while declining `Object.keys`. Its callers are the constant folds, which need to know
        whether *this* built-in is intact; `intrinsic_of` answers the stronger question of whether a value
        may be trusted for construction or `A || B` folding, and keeps its own callers and vocabulary.

        The name is not checked against a list of blessed intrinsics: whether a fold *knows how* to
        evaluate the call is the caller's question, answered by its own registry lookup. Conflating the two
        is what let a registry entry exist with no matching trust rule.

        Shadowing needs no per-site scope resolution, because a name bound *anywhere* in the program is
        already disqualified: `_globals_written` collects every binding in every scope. That is stricter
        than JavaScript requires — a shadow inside an unrelated function does not affect this use site —
        and deliberately so. A name the program binds at all is one an obfuscator may be routing values
        through, and the price of refusing is an unfolded call rather than a wrong value.
        """
        node = strip_parens(node)
        if not isinstance(node, JsIdentifier):
            return None
        if self.model.has_reflection_surface():
            return None
        if node.name in self._globals_written:
            return None
        return node.name

    def trusted_prototype(self, value_type: type) -> bool:
        """
        Whether the prototype supplying *value_type*'s methods is provably unmodified, so a method call on
        a receiver of that type still means what the language says. A method call on a literal receiver
        names no global, which is exactly why it needs its own question: `trusted_intrinsic` can only
        judge names the expression mentions, and `'ab'.toUpperCase()` mentions none.

        The owning intrinsic is looked up rather than assumed, and then asked the same per-name question a
        named callee gets — `String.prototype.toUpperCase = f` and
        `Object.defineProperty(Array.prototype, ...)` are both already recorded as writes to `String` and
        `Array` by `_global_writes_by_name`. A type with no known owner is never trusted.
        """
        owner = _PROTOTYPE_OWNERS.get(value_type.__name__)
        if owner is None:
            return False
        if self.model.has_reflection_surface():
            return False
        return owner not in self._globals_written

    def global_key_written(self, name: str, key: str) -> bool:
        """
        Whether the program writes the property *key* on a chain rooted at the global *name*:
        `Object.prototype.constructor = C`, `delete Object.getPrototypeOf`, or a descriptor
        installed for that key. The per-name question `_globals_written` answers is too coarse for a
        caller that cares which property was replaced — a file patching `Object.prototype.z` has
        written `Object`, and refusing everything about `Object` on that basis refuses the very
        files the question is asked about.

        The write is attributed to a *name* rather than to a receiver, which is what makes it
        answerable at all: the receiver of `Object.prototype.constructor = C` is a value no static
        analysis names, while the chain it is written through is rooted in one that is. A name whose
        written keys cannot be bounded — one the program binds, hands to code this analysis cannot
        read, writes a computed key on, or installs a descriptor on from a value it cannot read —
        answers `True` for every key, and so does a name outside `_KEYED_WRITE_ROOTS`, which the
        scan records nothing about at all.
        """
        if name not in _KEYED_WRITE_ROOTS:
            return True
        keys = self._global_keys_written.get(name, frozenset())
        return keys is None or key in keys

    def _roots_unwritten(self, owner: str, roots: frozenset[str]) -> bool:
        """
        Whether the program writes neither *owner* nor any prototype in *roots*. A property read
        resolves against the whole prototype chain rather than one prototype, so each name the chain
        passes through has to answer the same question `trusted_prototype` asks of the owner alone.
        """
        return all(name not in self._globals_written for name in (owner, *roots))

    def _prototypes_intact(self, owner: str, roots: frozenset[str]) -> bool:
        """
        `_roots_unwritten` with the reflection term, which is what separates the two questions
        `read_chain_intact` and `chain_roots_unwritten` ask. Neither is spelled out twice, so a
        term added to one chain question reaches both arms rather than only the one it was
        written into.
        """
        if self.model.has_reflection_surface():
            return False
        return self._roots_unwritten(owner, roots)

    def read_chain_intact(self, value_type: type) -> bool:
        """
        Whether every prototype a plain property read on a value of *value_type* consults is unmodified, so
        the read touches a data slot and runs nothing. Strictly stronger than `trusted_prototype`, which
        answers the neighbouring question for a method *call*, and the two must not be merged: a method
        resolves on the prototype that owns it, so `Array.prototype.join` shadows anything installed on
        `Object.prototype` and a patch there cannot change what `[1, 2].join('-')` means. A read of an
        arbitrary name has no such shadow — `Object.prototype` roots every chain, so a getter installed
        there is reached by a read on an array literal, on a primitive, and on `Math` alike.

        Confirmed against Node in both directions rather than reasoned from the specification: patching
        `Object.prototype.join` leaves `[1, 2].join('-')` intact, while patching `Object.prototype.zz`
        makes `[1, 2].zz` run a getter.

        Two separate facts have to hold and this is their conjunction: no chain root was written,
        which is `chain_roots_unwritten`, and no reflective surface could have written one without
        saying so. A caller for which the second costs more than it buys takes the first alone — see
        the note there for what that trade is and where it is made.
        """
        owner = _PROTOTYPE_OWNERS.get(value_type.__name__)
        if owner is None:
            return False
        return self._prototypes_intact(owner, _INHERITED_CHAIN_ROOTS)

    def chain_roots_unwritten(self, value_type: type) -> bool:
        """
        Whether the program writes no prototype a plain property read on a value of *value_type*
        consults. This is `read_chain_intact` without its reflection term, and the difference is not
        a weakening of the same question but a different one: `read_chain_intact` also refuses
        wherever `SemanticModel.has_reflection_surface` holds, and that predicate answers whether
        code could reference a global *by name* — true of `new Function('return this')`, which
        writes no prototype at all.

        The distinction is what the answer costs where it is wrong, and it decides who may ask this
        rather than being a judgement each caller makes for itself. A caller folding one expression
        pays an unfolded expression for refusing, so it may as well refuse under a surface, and
        every one of them asks `read_chain_intact`. A caller deciding whether a whole *pass* may run
        pays the pass, and a reflective surface is exactly what the real obfuscated files carry:
        measured on the samples this project tests against, the surface is present in the input and
        gone from the finished output, so a pass gated on it never runs and never clears the surface
        that was gating it. Only those callers ask this — today namespace flattening and the
        dispatcher unwrapper — and they accept that an unresolvable `eval` could in principle have
        written a prototype, which is no worse than the nothing they asked before.

        The two facts separate cleanly on the evidence rather than by assumption: every program in
        the defect ledger that reaches a wrong answer here writes a chain root and has no reflective
        surface, and every sample that has a surface writes no chain root.
        """
        owner = _PROTOTYPE_OWNERS.get(value_type.__name__)
        if owner is None:
            return False
        return self._roots_unwritten(owner, _INHERITED_CHAIN_ROOTS)

    def call_is_foldable(
        self,
        node: JsCallExpression,
        *,
        receiver_type: type | None = None,
    ) -> bool:
        """
        Whether the call *node* may be evaluated to a constant and have its result replace it. This is the
        one admission gate every fold shares, so that a rule proven necessary at one site cannot be missing
        at another — the fold surface acquired its divergent hand-rolled checks precisely because each
        transform owned its own.

        Three questions must all answer yes:

        - The callee is still the built-in it is spelled as. A named callee (`parseInt(...)`,
          `String.fromCharCode(...)`) is judged by `trusted_intrinsic` on its root name; a call on a
          literal receiver is judged by `trusted_prototype` on the type that literal's syntax fixes; and a
          call on another call — a chain link — is judged by asking this whole question of that inner call.
          `receiver_type` covers the remaining case, a caller holding an already-evaluated receiver whose
          type it knows and this model does not.
        - Every function-valued argument writes nothing outside itself. `is_effect_free_when_discarded` is
          the right predicate rather than `is_pure`: a callback that mutates a fresh local and returns it —
          the `reduce` accumulator idiom — is not pure, but that mutation is the value being computed and an
          evaluator that runs the call reproduces it. Purity is not sufficient on its own either, since a
          callback writing a script-scope `var` reports `writes_captured=False` — that binding is not
          captured from its perspective — so the write would be dropped while the value folds.
          `written_bindings` records it by identity and catches it.
        - Nothing in the argument list is itself a call this gate does not also clear, so admitting an outer
          call cannot smuggle in an inner one. A nested built-in (`Math.floor(Math.abs(-1.7))`) is fine
          precisely because the same questions are asked of it.
        - No part of the call stores anything the residual would go on to read. The fold deletes the
          whole expression, so a store is lost wherever in it the store was written — an argument, the
          receiver, or the computed key naming the method; see `_call_stores_nothing`.

        Trust is not evaluability, and this answers only the first. `trusted_intrinsic` says `unknownFn` is
        undisturbed — true, and no help, since no such built-in exists to fold. Whether a callee can
        actually be evaluated is the caller's question, answered by its own registry lookup before it asks
        this one; conflating the two is what let a registry entry exist with no matching trust rule.
        """
        if not self._callee_is_trusted(node, receiver_type):
            return False
        if not self._call_stores_nothing(node):
            return False
        return all(self._argument_is_admissible(arg) for arg in node.arguments)

    def _call_stores_nothing(self, node: JsCallExpression) -> bool:
        """
        Whether the parts of *node* that name what is being called store anything: the receiver it is
        called on, and the computed key that says which method. The fold deletes the whole call
        expression, so a store written into either is lost exactly as one written into an argument is,
        and `Math[v = 'floor'](1.9)` drops the write to `v` while answering `1`.

        A receiver that is itself a call is not asked here. It is a link in a chain, and
        `_callee_is_trusted` already puts it through this same gate in full, where its own receiver,
        key and arguments are each accounted for.
        """
        callee = strip_parens(node.callee)
        if not isinstance(callee, JsMemberExpression):
            return True
        base = strip_parens(callee.object)
        if not isinstance(base, JsCallExpression) and not self._stores_nothing(base):
            return False
        return not callee.computed or self._stores_nothing(callee.property)

    def _callee_is_trusted(self, node: JsCallExpression, receiver_type: type | None) -> bool:
        callee = strip_parens(node.callee)
        if isinstance(callee, JsIdentifier):
            return self.trusted_intrinsic(callee) is not None
        if not isinstance(callee, JsMemberExpression):
            return False
        base = strip_parens(callee.object)
        if isinstance(base, JsIdentifier):
            return self.trusted_intrinsic(base) is not None
        if isinstance(base, JsCallExpression):
            # A chained call (`Buffer.from(x).toString('hex')`, `[1, 2].map(f).join('')`) has no name at
            # this link. Its receiver is whatever the inner call returned, so it is trustworthy exactly
            # when that inner call is admissible in full — including its arguments, since an effectful
            # argument to the inner link is just as observable as one to the outer.
            return self.call_is_foldable(base)
        literal_type = _LITERAL_RECEIVER_TYPES.get(type(base))
        if literal_type is not None:
            return self.trusted_prototype(literal_type)
        if receiver_type is None:
            return False
        return self.trusted_prototype(receiver_type)

    def _argument_is_admissible(self, arg: Node | None) -> bool:
        """
        Whether *arg* may be evaluated as part of a fold that then replaces the whole call, deleting
        the argument's text along with it.

        A function-valued argument is judged by what calling it would do, because the call is what the
        fold performs. A call is judged by this same gate in full, so that admitting an outer call
        cannot smuggle in an inner one. Everything else is judged by whether evaluating it can be
        dropped at all, which is the question `is_side_effect_free` already answers over the whole
        subtree — and it is the argument's *subtree* that matters, because a store need not be the
        argument itself. `Math.floor(v = 4)` is only the plainest spelling of it; the same store hides
        in a summand, a comma operand, a template substitution, and a compound or logical assignment,
        each of which the fold would delete while the residual keeps reading the old value.
        """
        node = strip_parens(arg)
        if isinstance(node, FUNCTION_NODES):
            summary = self.summary_of(node)
            return summary.is_effect_free_when_discarded and not summary.written_bindings
        if isinstance(node, JsCallExpression):
            return self.call_is_foldable(node)
        return self._stores_nothing(node)

    def _stores_nothing(self, node: Node | None) -> bool:
        """
        Whether evaluating the expression *node* performs no store, so that a fold which replaces it
        with the value it computed loses nothing a later statement could read.

        A fold deletes the expression it replaces. Where that expression assigned, updated or deleted
        something, the store went with it and the residual program keeps reading the old value, which
        is what makes `Math.floor(v = 4)` answer `4` and leave `v` at `0`. The store is rarely the
        whole argument — it hides in a summand, a comma operand, a template substitution, a compound
        or a logical assignment — so the question is asked of the whole subtree and not of the shape
        at its root.

        A `yield` or an `await` is refused under the same heading: neither stores, but both hand
        control somewhere that can, and a fold that runs them decides when they resume.

        A function written down inside the expression stores nothing by being evaluated, since it is a
        value and only calling it could store; its body is therefore not walked.

        A call is refused when its callee can be named and that callee is known to write. It is
        admitted when the callee cannot be named, which is where this predicate stops being a proof:
        `s.charCodeAt(i)` on a parameter resolves to nothing this model can summarize and stores
        nothing, and refusing every unnameable call would decline the string decoders this tool exists
        to read. A `new` expression is admitted on the same terms. Admitting them is what the gate did
        for every call in this position before, so the rule only ever narrows what folds.
        """
        if node is None:
            return True
        pending: list[Node] = [node]
        while pending:
            current = pending.pop()
            if isinstance(current, FUNCTION_NODES):
                continue
            if isinstance(current, (JsAssignmentExpression, JsUpdateExpression)):
                return False
            if isinstance(current, JsUnaryExpression) and current.operator == 'delete':
                return False
            if isinstance(current, (JsYieldExpression, JsAwaitExpression)):
                return False
            if isinstance(current, JsCallExpression):
                callee = self.unambiguous_callee(current)
                if callee is not None and self.summary_of(callee).written_bindings:
                    return False
            pending.extend(current.children())
        return True

    def _is_trusted_global_read(self, member: JsMemberExpression) -> bool:
        """
        Whether reading *member* off the global object runs no user getter, so the read carries no
        observable effect: a non-computed access of a trusted intrinsic-named data property on the global
        object, sound only under the `global_pristine` precondition. This mirrors the intrinsic-call trust
        of `_resolve_callee`, lifted from methods to global data-property reads.
        """
        if not self.global_pristine or member.computed:
            return False
        prop = member.property
        if not isinstance(prop, JsIdentifier) or prop.name not in _GLOBAL_DATA_PROPERTIES:
            return False
        return member.object is not None and self._base_is_global_object(member.object)

    def _base_is_global_object(self, node: Node) -> bool:
        """
        Whether *node* denotes the global object itself: an unshadowed global-object alias identifier,
        always safe because the global object is never in a temporal dead zone. A local that only holds
        the global from an establishing definition is resolved separately by `_trusted_global_alias_read`,
        whose caller orders that definition before the read.
        """
        if isinstance(node, JsIdentifier) and node.name in GLOBAL_OBJECT_ALIASES:
            return self.model.lookup(node.name, self.model.scope_of(node)) is None
        return False

    def member_read_getter_free(
        self,
        member: JsMemberExpression,
        established: Callable[[Binding, JsMemberExpression], bool] | None = None,
    ) -> bool:
        """
        Whether reading *member* runs no user getter, so it carries no observable effect: a getter-free
        read off a pristine value (a fresh literal or a pristine intrinsic root) or a trusted global
        data-property read off a syntactic global-object alias — always, since neither is nullish — or off
        a local single-assigned to the global object, which holds it only from its establishing definition
        onward. The local case qualifies only when *established* confirms that definition reaches the read,
        an ordering this effect model cannot decide on its own (see
        `refinery.lib.scripts.js.analysis.reaching.ReachingModel.value_preserved`).
        """
        if self._getter_free_read(member):
            return True
        if established is None:
            return False
        binding = self._trusted_global_alias_read(member)
        return binding is not None and established(binding, member)

    def _trusted_global_alias_read(self, member: JsMemberExpression) -> Binding | None:
        """
        The local binding *member*'s base reads when *member* is a non-computed access of a trusted
        global data property through a single-assignment local whose value is provably the global object
        — a `globalThis` alias or a `globalThis || ...` guard — under `global_pristine`; `None`
        otherwise. The binding is returned rather than a verdict because whether it already holds the
        global where it is read is an ordering question for a layer that sees control flow.
        """
        if not self.global_pristine or member.computed:
            return None
        prop = member.property
        if not isinstance(prop, JsIdentifier) or prop.name not in _GLOBAL_DATA_PROPERTIES:
            return None
        base = member.object
        if not isinstance(base, JsIdentifier) or base.name in GLOBAL_OBJECT_ALIASES:
            return None
        binding = self.model.resolve(base)
        if binding is None or self.model.reflection_can_reach(binding):
            return None
        return binding if self._value_is_global_object(self.model.singular_value(binding)) else None

    def _value_is_global_object(self, node: Node | None) -> bool:
        """
        Whether *node*, the value a local is single-assigned, is provably the global object: the
        canonical `globalThis`, or a `globalThis || ...` existence guard whose truthy left is exactly it.
        A host alias that may be `undefined` is excluded, so a read through the local cannot throw on a
        nullish base.
        """
        return self.intrinsic_of(node) is GLOBAL_OBJECT

    def _base_is_safe(self, node: Node) -> bool:
        """
        Whether a property access on *node* cannot throw because *node* is known not to be nullish: a
        container literal whose access is plain, a primitive literal other than `null`, the global object, a
        pristine intrinsic root, or a never-rebound rest parameter. A rest parameter is bound to a fresh array
        at function entry, before any body statement runs — no temporal-dead-zone or hoisted-`undefined` window
        a flow-insensitive check could miss — so a member access on it is safe wherever it appears, provided the
        name is never reassigned to a value that could be nullish. A `var`/`let`/`const` local initialized to a
        literal is deliberately NOT admitted here: its initializer may not have run yet at the access
        (`function(){ a.x = 1; var a = []; }` throws), which this flow-insensitive predicate cannot rule out.
        The global object is recognized through `_base_is_global_object`, so an alias spelling a
        local declaration shadows names that local and is decided by the same rules as any other
        name: a shadowed `window` is the hoisted-`undefined` window this predicate refuses
        elsewhere.

        Non-nullishness is a *different* question from freshness and from getter-freeness, so this shares only
        the container-literal atom with its neighbours: a primitive qualifies here and is not fresh, while a
        fresh local qualifies as fresh and not here. It needs no prototype question either, unlike
        `_base_getter_safe`: no patch to any prototype can make `[1, 2]` nullish, and a throwing getter
        reached through a patched chain is that predicate's concern. There is no member-chain arm, because
        `root.a` may be `undefined` however safe *root* is, and the second read would then throw.
        """
        if container_literal_access_is_plain(node):
            return True
        if isinstance(node, (JsStringLiteral, JsNumericLiteral, JsBooleanLiteral)):
            return True
        if isinstance(node, JsIdentifier):
            if self._base_is_global_object(node):
                return True
            if isinstance(self.intrinsic_of(node), str):
                return True
            binding = self.model.resolve(node)
            return binding is not None and self._is_rest_param(binding) and not binding.writes
        return False

    def _base_getter_safe(self, node: Node) -> bool:
        """
        Whether reading a property of *node* cannot run a user-defined getter, so the read carries no
        hidden effect: a literal, or a pristine intrinsic root, whose entire prototype chain the program
        leaves alone. Unlike `_base_is_safe`, the global object does not qualify — a global property such as
        `location` may be an accessor — so a read through it is treated as an unknown call. Unlike
        freshness, a primitive and an intrinsic root do qualify: neither is a newly built container, and
        neither runs user code on a read of a pristine chain.

        Syntax alone settles the *type* of a literal base but not its behaviour, which is why every arm ends
        in `read_chain_intact` rather than returning on the node kind. A literal was previously cleared on
        syntax alone, and that deleted reads which really did run a getter installed on the corresponding
        prototype — Node-confirmed for array, object, string, boolean, function, and arrow bases, plus an
        intrinsic root reached through `Object.prototype`. A container literal must additionally declare no
        accessor of its own, the shared `container_literal_access_is_plain` question, since a getter written
        into the literal needs no prototype at all. There is no member-chain arm, for the reason given on
        `_base_is_safe`.
        """
        inner = strip_parens(node)
        value_type = _LITERAL_READ_TYPES.get(type(inner))
        if value_type is not None:
            if self._literal_declares_accessor(inner):
                return False
            return self.read_chain_intact(value_type)
        if isinstance(inner, JsIdentifier) and isinstance(self.intrinsic_of(inner), str):
            return self._prototypes_intact('Object', _INTRINSIC_CHAIN_ROOTS)
        return False

    def _literal_declares_accessor(self, node: Node) -> bool:
        """
        Whether *node* is a container literal that declares an accessor of its own, so a member access on it
        runs user code with no prototype involved. Separate from the chain question because it needs no
        model: the getter is written into the expression.
        """
        if not isinstance(node, (JsObjectExpression, JsArrayExpression)):
            return False
        return not container_literal_access_is_plain(node)

    def _getter_free_read(self, member: JsMemberExpression) -> bool:
        """
        Whether reading *member* runs no user getter and cannot fire a poison-pill accessor: the base is a
        getter-safe value (a fresh literal or a pristine intrinsic root) or a trusted global-object data
        property, and the property is not one of the poison-pill names whose read may throw or run an
        `Object.prototype` accessor. This is the single getter-freeness gate the summary scan and
        `is_side_effect_free` share.
        """
        if _is_poison_pill_property(member):
            return False
        if member.object is not None and self._base_getter_safe(member.object):
            return True
        return self._is_trusted_global_read(member)


def _object_has_own_accessor(obj: JsObjectExpression) -> bool:
    """
    Whether the object literal *obj* declares an own getter or setter (`{ get k(){...} }`,
    `{ set k(v){...} }`). A read of such a property runs the getter and a write to it runs the setter,
    so a member access on an otherwise fresh literal that has one carries a hidden effect rather than a
    plain field read or store.
    """
    return any(
        isinstance(prop, JsProperty) and prop.kind in (JsPropertyKind.GET, JsPropertyKind.SET)
        for prop in obj.properties
    )


def object_sets_prototype(obj: JsObjectExpression) -> bool:
    """
    Whether the object literal *obj* installs a custom prototype through the special `__proto__:`
    property form (`{ __proto__: p }`, `{ '__proto__': p }`) — a plain, non-computed data property
    whose key is `__proto__`. Such an object no longer inherits from `Object.prototype` alone, so a
    plain-looking member read or write on it may run a getter or setter the installed prototype
    carries rather than touch a data slot. A computed key (`{ ['__proto__']: p }`), a shorthand
    (`{ __proto__ }`), a method, or an own `__proto__` accessor define an ordinary own property and do
    not set the prototype.
    """
    for prop in obj.properties:
        if not isinstance(prop, JsProperty):
            continue
        if prop.kind is not JsPropertyKind.INIT or prop.computed or prop.shorthand or prop.method:
            continue
        key = prop.key
        if isinstance(key, JsIdentifier) and key.name == '__proto__':
            return True
        if isinstance(key, JsStringLiteral) and key.value == '__proto__':
            return True
    return False


def object_member_access_runs_accessor(obj: JsObjectExpression) -> bool:
    """
    Whether a plain member read or write on the object literal *obj* may run a user-defined accessor
    instead of touching a data slot: it declares its own getter or setter, or it installs a custom
    prototype through the `__proto__:` literal form that may carry an inherited one. A fresh literal
    with neither behaves as a plain field container, so an access on it is observable only as the field
    it names.
    """
    return _object_has_own_accessor(obj) or object_sets_prototype(obj)


def _weakest(left: _FreshKind, right: _FreshKind) -> _FreshKind:
    """
    The kind that holds for both operands — the weaker of the two, since a consumer may rely only on what is
    true of every value an expression or binding can take.
    """
    return left if left.value <= right.value else right


def _returns_on_every_path(func: Node) -> bool:
    """
    Whether *func* cannot complete without an explicit `return`, so its call never yields the implicit
    `undefined`. Deciding this exactly is a control-flow question, and this model has no graph, so the test is
    the conservative syntactic one: the body's last statement must itself be a `return` or a `throw`. That
    refuses `if (c) { return x; } else { return y; }`, which does return on every path — a missed opportunity,
    not an unsound answer, and the direction to err in when a caller is about to treat the result as a
    container it may write through.
    """
    body = getattr(func, 'body', None)
    statements = getattr(body, 'body', None)
    if isinstance(body, (JsReturnStatement, JsThrowStatement)):
        return True
    if not isinstance(statements, list) or not statements:
        return isinstance(body, Expression)
    return isinstance(statements[-1], (JsReturnStatement, JsThrowStatement))


def _body_nodes(func: Node) -> Iterator[Node]:
    """
    Yield the nodes a single execution of *func* evaluates — its parameter list and the statements
    of its body, with their descendants — without descending into nested function bodies, whose
    effects belong to *their* calls. Nested function nodes are still yielded so a call to one can be
    recognized. The parameter list is part of what a call evaluates because a default runs on every
    call that omits its argument (`function f(a = zzz){}` reads `zzz` before the body is entered),
    so a scan that started at the body alone could not see an effect the call really has.
    """
    if isinstance(func, JsScript):
        roots: list[Node] = list(func.body)
    else:
        roots = list(getattr(func, 'params', ()))
        body = getattr(func, 'body', None)
        if body is not None:
            roots.append(body)
    stack = list(reversed(roots))
    while stack:
        node = stack.pop()
        yield node
        if isinstance(node, FUNCTION_NODES):
            continue
        stack.extend(reversed(node.children()))


class _GlobalWrites(NamedTuple):
    """
    What one scan of a program says about the globals it writes: *names*, the set a caller asks
    about a whole name with, and *keys*, the properties each of the watched names was written at —
    or `None` for a name whose written keys this scan cannot bound.

    The two are produced together because they are read off the same nodes and must not disagree:
    a name recorded in one for a reason the other cannot express is a name one caller refuses and
    the other clears. Where a write cannot be pinned to a key, *keys* records the name unbounded
    rather than omitting it, so `keys` never reports less about a name than `names` does.
    """
    names: frozenset[str]
    keys: dict[str, frozenset[str] | None]


def _global_writes_by_name(model: SemanticModel) -> _GlobalWrites:
    """
    The set of global names the program does anything with beyond reading them, and the property
    keys it writes on each of the names whose keys are watched.

    A name belongs to the first for binding it in any scope, assigning to it, writing or updating or
    deleting a property anywhere along a chain rooted at it (`Object.prototype.x = 1`,
    `Math.PI++`), installing a descriptor on it with `Object.defineProperty`, or handing it to
    code whose writes this analysis cannot enumerate.

    This is the per-name counterpart of `_intrinsics_pristine`, which answers the same question for a
    fixed root set but collapses it to one program-wide flag. Keeping the answer per name is what lets a
    program that patches `Object.prototype` still have its `Math.floor` calls folded; the flag cannot
    express that, because one disturbed root disables every other one.

    Bindings cover every *assignment* form too, so no separate scan of write-role identifiers is needed:
    a bare `Math = 1` introduces an implicit-global binding for `Math`, as do the destructuring and
    `for`-target forms. Only property writes, deletes, and descriptor installs — which leave the name
    itself a plain read — need the explicit branches below.

    A write target names the intrinsic it patches only when the program spells it out. `var m = Math;
    m.floor = f` patches `Math` while mentioning it nowhere in the assignment, so the chain root is
    resolved through the values its binding may hold rather than taken as the name it is spelled with.

    A write need not be *anywhere* in the program for a name to belong here. Handing an intrinsic to code
    whose writes this analysis cannot enumerate — `patch(Math)` for a `patch` it cannot resolve — leaves
    the name looking untouched while its properties are replaced, so an intrinsic that escapes is recorded
    as written. `_value_escapes` decides which uses hand the value over.

    The keyed answer is the same scan read for the property a write names rather than only for the
    name it is rooted at, and it exists because the per-name question is too coarse for a caller
    that cares which property was replaced — a file patching `Object.prototype.z` has written
    `Object`, and refusing everything about `Object` on that basis refuses the very files the
    question is asked about.

    Only the *final* key of a chain is recorded, which is the one a write replaces:
    `Object.prototype.z = 9` replaces `z` and leaves `prototype` and `constructor` alone, so
    recording the keys it passes through would report a file as having patched the mechanism a
    caller is asking about when it did nothing of the kind. Every other route by which a name's
    properties can change — a binding that shadows it, a value that escapes, a computed key, a
    descriptor read from a value this analysis cannot read — bounds no key at all and is recorded
    as unbounded.
    """
    names: set[str] = set()
    keys: dict[str, set[str] | None] = {}

    def record_keys(found: frozenset[str], key: str | None) -> None:
        for name in found & _KEYED_WRITE_ROOTS:
            if key is None:
                keys[name] = None
                continue
            known = keys.setdefault(name, set())
            if known is not None:
                known.add(key)

    def record(found: frozenset[str], key: str | None) -> None:
        names.update(found)
        record_keys(found, key)

    pending = [model.root_scope]
    while pending:
        scope = pending.pop()
        record(frozenset(scope.bindings), None)
        pending.extend(scope.children)
    aliases = _IntrinsicAliases(model)
    for node in model.root.walk():
        if isinstance(node, JsIdentifier):
            if reference_role(node) is Role.READ:
                watched = aliases.names_denoted_by(node) & _KEYED_WRITE_ROOTS
                if watched and _value_escapes(model, aliases, node, watched):
                    record_keys(watched, None)
                    names.update(watched & _PURE_INTRINSIC_ROOTS)
            continue
        if isinstance(node, JsCallExpression):
            for base in _accessor_install_targets(node):
                record(aliases.names_denoted_by(base), _installed_key(node))
            continue
        target = None
        if isinstance(node, JsAssignmentExpression):
            target = node.left
        elif isinstance(node, JsUpdateExpression):
            target = node.argument
        elif isinstance(node, JsUnaryExpression) and node.operator == 'delete':
            target = node.operand
        elif isinstance(node, (JsForInStatement, JsForOfStatement)):
            target = node.left
        for member in _written_members(target):
            base = _member_chain_root(member)
            if base is not None:
                record(aliases.names_denoted_by(base), _written_key(member))
    return _GlobalWrites(
        frozenset(names),
        {name: None if written is None else frozenset(written) for name, written in keys.items()},
    )


def _written_members(target: Node | None) -> Iterator[JsMemberExpression]:
    """
    Every member access a write to *target* may store through. A plain member target is that one
    access; a pattern holds one per position it assigns into, which `[Object.prototype.z] = [9]` and
    `({k: Object.prototype.z} = o)` both write a property through while naming no member target at
    the top. A pattern is read by walking it, so a member standing in a computed key inside one is
    yielded too — which over-reports a write and is the direction this whole scan fails in.
    """
    cursor = strip_parens(target)
    if isinstance(cursor, JsMemberExpression):
        yield cursor
    elif cursor is not None and not isinstance(cursor, JsIdentifier):
        for node in cursor.walk():
            if isinstance(node, JsMemberExpression):
                yield node


def _written_key(member: JsMemberExpression) -> str | None:
    """
    The property name *member* accesses, or `None` where it cannot be read as one name. A computed
    key is read through `_static_string`, so a key a fold would collapse to a literal is already
    that literal here, for the reason `_static_string` gives.
    """
    if not member.computed:
        prop = member.property
        return prop.name if isinstance(prop, JsIdentifier) else None
    return _static_string(member.property)


def _installed_key(call: JsCallExpression) -> str | None:
    """
    The property name the descriptor install *call* names, or `None` where it names more than one or
    none this analysis can read. `Object.defineProperty(o, 'k', d)` and `o.__defineGetter__('k', f)`
    both name it in the argument before the descriptor; `defineProperties` names a whole object of
    them, which is not one key and is reported as unbounded.
    """
    callee = strip_parens(call.callee)
    if not isinstance(callee, JsMemberExpression) or callee.computed:
        return None
    prop = callee.property
    if not isinstance(prop, JsIdentifier):
        return None
    if prop.name == 'defineProperty':
        return _static_string(call.arguments[1]) if len(call.arguments) > 1 else None
    if prop.name in ('__defineGetter__', '__defineSetter__'):
        return _static_string(call.arguments[0]) if call.arguments else None
    return None


def _value_escapes(
    model: SemanticModel,
    aliases: _IntrinsicAliases,
    node: JsIdentifier,
    names: frozenset[str],
    depth: int = 0,
) -> bool:
    """
    Whether the value read at *node* — which may denote the intrinsics *names* — reaches code that could
    write a property on it without this analysis seeing the write.

    The question is deliberately inverted. Tracking where an intrinsic *flows to* would need a binder from
    each argument to its parameter, and that binder reaches none of the routes an obfuscator actually uses:
    a callback that receives the value, a function that returns it, `arguments`, spread, rest, or a method
    on an object literal. Asking instead whether the value leaves a position whose effect is *known* covers
    all of them at once, and fails in the safe direction by construction — an unrecognized position counts
    as an escape, which costs an unfolded call rather than a wrong value.

    The positions that hand nothing over, and so must stay free or every fold collapses:

    - a member base, when the key cannot yield the intrinsic's own mutable surface (`Math.floor(1.7)`)
    - a callee, which the call consumes (`parseInt(x)`)
    - an operator operand, which reads a value without capturing the object (`typeof Math`)
    - a rebinding whose target the alias analysis still resolves to the same names (`var m = Math`)

    That last arm is what makes one predicate serve both this scan and the parameter-escape question inside
    `_callee_is_write_free`, rather than two walks differing by a flag. A rebinding is safe precisely when a
    later write through the new name is still attributed back, which is a question `_IntrinsicAliases`
    already answers: `var m = Math` is spared because `m` denotes `Math`, while `save = o` inside a callee
    is not, because a parameter denotes nothing and the attribution chain dies there. The *names* being
    non-empty is therefore load-bearing — an empty set is a subset of everything, and would spare the very
    case the arm exists to catch.
    """
    value = _forwarded_value(node)
    if value is None:
        return False
    parent = value.parent
    if isinstance(parent, JsCallExpression):
        if parent.callee is value:
            return False
        callee = _unambiguous_callee(model, parent)
        return not _callee_is_write_free(model, aliases, callee, depth + 1)
    if isinstance(parent, (JsUnaryExpression, JsBinaryExpression, JsTemplateLiteral)):
        return False
    target = None
    if isinstance(parent, JsVariableDeclarator) and parent.init is value:
        target = parent.id
    elif isinstance(parent, JsAssignmentExpression) and parent.right is value:
        target = parent.left
    if target is not None:
        return not (names and names <= _names_bound_to(model, aliases, target))
    return True


def _forwarded_value(node: JsIdentifier) -> Node | None:
    """
    The outermost expression still carrying *node*'s value, or `None` when no enclosing form can hand that
    value on. The walk looks through the forms that forward a value unchanged — parentheses, the operands
    of `||`/`&&`/`??`, the branches of a conditional, the last expression of a sequence, and a spread — and
    through a member access only when its key reaches the intrinsic's own surface, since `p(Array.prototype)`
    hands over an object whose properties decide what `[1, 2].join()` means while `p(Math.PI)` hands over a
    number nobody consults.

    Strictly outward through `parent`, so unlike `_denoted_roots` — which recurses *into* an expression and
    needs `_DENOTED_ROOT_DEPTH_LIMIT` — this terminates on the finite path to the root without a limit.
    """
    cursor: Node = node
    while True:
        parent = cursor.parent
        if parent is None:
            return None
        if isinstance(parent, JsMemberExpression) and parent.object is cursor:
            if not _reaches_intrinsic_surface(parent):
                return None
            cursor = parent
            continue
        if isinstance(parent, _VALUE_FORWARDING_NODES):
            cursor = parent
            continue
        return cursor


def _reaches_intrinsic_surface(member: JsMemberExpression) -> bool:
    """
    Whether reading *member*'s key off an intrinsic can yield an object sharing that intrinsic's mutable
    surface. A computed key whose string value is not statically known may be any of them, so it counts.

    This is a *may* analysis, opposite in direction to `_static_string`'s use in `_accessor_install_method`:
    there an unknown key names no method, because only a key a fold can collapse can reveal an install
    mid-pass; here an unknown key reaches everything, because a missed reach is a name that keeps its trust
    while the program patches it.
    """
    prop = member.property
    if member.computed:
        value = _static_string(prop)
        return value is None or value in _SURFACE_KEYS
    return isinstance(prop, JsIdentifier) and prop.name in _SURFACE_KEYS


def _names_bound_to(
    model: SemanticModel, aliases: _IntrinsicAliases, target: Node | None
) -> frozenset[str]:
    """
    The intrinsic names a value stored into *target* may still be found under, so a rebinding that keeps the
    value reachable by name is not an escape. A destructuring or member target yields nothing, since neither
    leaves a name this analysis resolves writes through.

    Both binding lookups are needed. A declarator id is not a *reference*, so `resolve` finds nothing for it
    and only `binding_of` answers; an assignment target is a reference and only `resolve` does.
    """
    if not isinstance(target, JsIdentifier):
        return frozenset()
    binding = model.binding_of(target) or model.resolve(target)
    if binding is None:
        return frozenset({target.name})
    return aliases.names_of(binding)


def _callee_is_write_free(
    model: SemanticModel,
    aliases: _IntrinsicAliases,
    func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression | None,
    depth: int,
) -> bool:
    """
    Whether a call to *func* provably writes no property reachable from one of its parameters, and lets no
    parameter escape further. This is what keeps the escape rule from refusing every call: `log(Math)` for a
    `log` that only reads `o.PI` hands the intrinsic to code whose writes are fully enumerable, so `Math`
    keeps its trust.

    Fails closed on every way the enumeration could be incomplete — an unresolvable callee, a rest or
    destructured parameter whose contents no name tracks, a reachable `arguments` object, or recursion past
    `_CALLEE_DEPTH_LIMIT`. Each of those means a parameter's value could be written through somewhere this
    scan does not look.

    The parameter-escape question routes back through `_value_escapes` rather than scanning for identifiers,
    so a callee that merely *reads* through its parameter (`return o.PI`) stays write-free while one that
    passes it on (`g(o)`) does not. That reuse also subsumes the return case without a branch of its own: a
    parameter in a return position matches no sparing arm, so it escapes — and by the same rule so do
    `return [o]`, `return { m: o }`, and an arrow's concise body, which an explicit return check missed.
    """
    if func is None or depth > _CALLEE_DEPTH_LIMIT:
        return False
    scope = model.parameter_scope(func)
    if scope is None:
        return False
    if not isinstance(func, JsArrowFunctionExpression):
        binding = scope.bindings.get('arguments')
        if binding is not None and (model.references(binding) or model.reflection_can_reach(binding)):
            return False
    if any(not isinstance(param, JsIdentifier) for param in func.params):
        return False
    params = frozenset(param.name for param in func.params)
    if not params:
        return True
    body = getattr(func, 'body', None)
    if body is None:
        return False
    for node in body.walk():
        target = None
        if isinstance(node, JsAssignmentExpression):
            target = node.left
        elif isinstance(node, JsUpdateExpression):
            target = node.argument
        elif isinstance(node, JsUnaryExpression) and node.operator == 'delete':
            target = node.operand
        base = _member_chain_root(target)
        if base is not None and base.name in params:
            return False
        if isinstance(node, JsIdentifier) and node.name in params:
            if reference_role(node) is Role.READ:
                names = aliases.names_denoted_by(node)
                if _value_escapes(model, aliases, node, names, depth):
                    return False
    return True


def _unambiguous_callee(
    model: SemanticModel, call: JsCallExpression
) -> JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression | None:
    """
    The function *call* certainly invokes for a consumer that cannot order the call against a reassignment
    of the callee's name, or `None`. The module-scope form of `EffectModel.unambiguous_callee`, which
    delegates here: the answer needs only the model, so a caller that runs before an `EffectModel`'s caches
    exist — `_global_writes_by_name` is computed during construction — can still ask it.
    """
    callee = call.callee
    if isinstance(callee, (JsFunctionExpression, JsArrowFunctionExpression)):
        return callee
    if not isinstance(callee, JsIdentifier):
        return None
    return _unambiguous_function(model, model.resolve(callee))


def _unambiguous_function(
    model: SemanticModel, binding: Binding | None
) -> JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression | None:
    """
    The module-scope form of `EffectModel.unambiguous_function`, which delegates here. See that method for
    which bindings qualify.
    """
    if binding is None:
        return None
    value = model.singular_value(binding)
    if not isinstance(value, FUNCTION_NODES):
        return None
    if not binding.writes:
        return value
    declaration = binding.declarations[0]
    parent = declaration.parent
    if (
        isinstance(parent, JsVariableDeclarator)
        and parent.id is declaration
        and parent.init is None
    ):
        return value
    return None


class _IntrinsicAliases:
    """
    Resolves the identifier at the root of a write target to every name it may denote, so that a property
    write reaching an intrinsic through a local is attributed to the intrinsic rather than to the local.
    `var m = Math; m.floor = f` must record `Math`, or the write stays invisible to every consumer of
    `_globals_written` and `Math.floor(1.7)` goes on folding to the built-in.

    A *may* analysis on purpose: every value a binding can hold contributes, so one branch assigning an
    intrinsic is enough to poison the name. The direction is forced — missing an alias yields a wrong
    value, while an extra name yields an unfolded call — and it matches how the accessor-install targets
    are already over-approximated, `Object.defineProperty(0 || Math, …)` among them.

    The backward step is `_denoted_roots`, the same value-preserving walk the install-target scan uses,
    reused rather than reimplemented for two reasons. It already looks through exactly the forms a fold
    collapses (`||`, `&&`, `??`, conditional, sequence-last, assignment-RHS, parens), which is what keeps
    this answer stable while passes run — the pinning contract in
    `refinery.lib.scripts.js.analysis.cache.ModelCache` requires that this set never *grow* across a pass.
    And it stops at a call, so `var s = String.fromCharCode(x)` does not alias `String`: the local holds
    the *result*, and a walk that merely collected identifiers from the initializer reported three such
    false aliases on a real sample. Its member-chain arm over-approximates in the one remaining direction —
    `var n = Array.length` reports `Array` — which is sound and measured to cost nothing.
    """

    def __init__(self, model: SemanticModel):
        self.model = model
        self._cache: dict[int, frozenset[str]] = {}

    def names_denoted_by(self, node: JsIdentifier) -> frozenset[str]:
        """
        Every intrinsic-root name *node* may denote, including its own when it names one directly. A name
        that resolves to no binding is a free global and denotes itself.
        """
        if node.name in _PURE_INTRINSIC_ROOTS:
            return frozenset({node.name})
        binding = self.model.resolve(node)
        if binding is None:
            return frozenset({node.name})
        return self.names_of(binding)

    def names_of(self, binding: Binding) -> frozenset[str]:
        """
        Every name a value of *binding* may denote. The binding-level entry point, for a caller holding a
        binding rather than a reference to it — resolving a *write* target, whose declaration id is not a
        reference at all.
        """
        return self._names_of(binding, set())

    def _names_of(self, binding: Binding, visiting: set[int]) -> frozenset[str]:
        """
        Every name a value of *binding* may denote, memoized per binding. The *visiting* set breaks the
        cycle a mutually-assigning pair (`a = b; b = a`) would otherwise spin on; a binding still on the
        stack contributes nothing further, since whatever it reaches is already being collected.
        """
        key = id(binding)
        cached = self._cache.get(key)
        if cached is not None:
            return cached
        if key in visiting:
            return frozenset()
        visiting.add(key)
        found: set[str] = set()
        for root in _binding_value_roots(binding):
            if root.name in _PURE_INTRINSIC_ROOTS:
                found.add(root.name)
                continue
            inner = self.model.resolve(root)
            if inner is None:
                found.add(root.name)
                continue
            found |= self._names_of(inner, visiting)
        visiting.discard(key)
        result = frozenset(found)
        self._cache[key] = result
        return result


def _binding_value_roots(binding: Binding) -> Iterator[JsIdentifier]:
    """
    Every name a value of *binding* may denote, over its declarations' initializers and the right side of
    every plain assignment to it. Both are needed: a binding declared empty and assigned later
    (`var m; m = Math`) holds the intrinsic just as one initialized with it does.

    A write the model cannot pin to a value (`indefinite_writes`) contributes as well. This is a
    *may* analysis, and `arguments[k] = Math` stores the intrinsic under a parameter's name whether
    or not the text says which parameter or whether the call supplied it; leaving it out is exactly
    the missed alias the caller's docstring names as a wrong answer.
    """
    for declaration in binding.declarations:
        parent = getattr(declaration, 'parent', None)
        initializer = getattr(parent, 'init', None)
        if initializer is not None:
            yield from _denoted_roots(initializer)
    for reference in (*binding.writes, *binding.indefinite_writes):
        parent = getattr(reference, 'parent', None)
        if isinstance(parent, JsAssignmentExpression) and parent.left is reference:
            yield from _denoted_roots(parent.right)


def _accessor_install_method(node: JsMemberExpression) -> str | None:
    """
    The accessor-install method *node* names, through a dotted property or a computed key whose string
    value is statically known, or `None`. Matching the dotted form alone is what let
    `Object['defineProperty']` slip past both callers, and a fold rewriting that key to a dot would then
    reveal the install only after the fact was consumed.
    """
    prop = node.property
    if node.computed:
        value = _static_string(prop)
        return value if value in _ACCESSOR_INSTALL_METHODS else None
    if isinstance(prop, JsIdentifier) and prop.name in _ACCESSOR_INSTALL_METHODS:
        return prop.name
    return None


def _static_string(node: Node | None) -> str | None:
    """
    The string *node* certainly evaluates to, or `None`. It reads a string literal, a substitution-free
    template, and concatenations of those — the forms a constant fold collapses to a literal.

    This exists so that an analysis answer cannot change as folds fire: a property key the simplifier will
    turn into `'defineProperty'` must already be read as that name, or a consumer holding the answer across
    a pass would be told there is no install and then have one appear. It is deliberately a *must*
    analysis — an unknown value yields `None`, and a key whose value is unknown names no method, since only
    a key a fold can collapse can reveal an install mid-pass.
    """
    node = strip_parens(node)
    if isinstance(node, JsStringLiteral):
        return node.value
    if isinstance(node, JsTemplateLiteral):
        if node.expressions:
            return None
        if any(quasi.value is None for quasi in node.quasis):
            return None
        return ''.join(quasi.value or '' for quasi in node.quasis)
    if isinstance(node, JsBinaryExpression) and node.operator == '+':
        left = _static_string(node.left)
        if left is None:
            return None
        right = _static_string(node.right)
        if right is None:
            return None
        return left + right
    return None


def _accessor_install_targets(call: JsCallExpression) -> Iterator[JsIdentifier]:
    """
    The names whose properties *call* may install an accessor or data descriptor on. An
    `Object.defineProperty(Math, ...)` replaces a method without ever writing `Math` syntactically, so a
    scan for assignments alone would leave the name looking untouched. The receiver form
    (`o.__defineGetter__(...)`) attributes to the receiver instead of an argument.

    Every name the target *may* denote is yielded, because a caller collecting writes needs an
    over-approximation: a missed name is a name that keeps its trust while the program patches it. That is
    the opposite of `intrinsic_of`, which must certify what a node *does* denote and so takes only the left
    of `A || B`; here both operands are yielded, since either may survive.
    """
    callee = strip_parens(call.callee)
    if not isinstance(callee, JsMemberExpression):
        return
    method = _accessor_install_method(callee)
    if method is None:
        return
    if method.startswith('__define'):
        yield from _denoted_roots(callee.object)
    elif call.arguments:
        yield from _denoted_roots(call.arguments[0])


def _denoted_roots(node: Node | None, depth: int = 0) -> Iterator[JsIdentifier]:
    """
    Every name *node* may denote, looking through the value-preserving forms a constant fold collapses:
    parentheses, the operands of `||`/`&&`/`??` and the branches of a conditional (either side may be the
    value), the last expression of a sequence, and the right side of an assignment.

    Resolving only the syntactic form would make this analysis change its answer as folds fire —
    `Math || 0` names nothing until it collapses to `Math` — which is precisely what a consumer holding the
    answer across a pass cannot tolerate.
    """
    if depth > _DENOTED_ROOT_DEPTH_LIMIT:
        return
    cursor = strip_parens(node)
    if isinstance(cursor, JsIdentifier):
        yield cursor
    elif isinstance(cursor, JsMemberExpression):
        root = _member_chain_root(cursor)
        if root is not None:
            yield root
    elif isinstance(cursor, JsLogicalExpression):
        yield from _denoted_roots(cursor.left, depth + 1)
        yield from _denoted_roots(cursor.right, depth + 1)
    elif isinstance(cursor, JsConditionalExpression):
        yield from _denoted_roots(cursor.consequent, depth + 1)
        yield from _denoted_roots(cursor.alternate, depth + 1)
    elif isinstance(cursor, JsSequenceExpression):
        if cursor.expressions:
            yield from _denoted_roots(cursor.expressions[-1], depth + 1)
    elif isinstance(cursor, JsAssignmentExpression):
        yield from _denoted_roots(cursor.right, depth + 1)


def _member_chain_root(node: Node | None) -> JsIdentifier | None:
    """
    The identifier at the foot of a member-access chain, or `None` when the chain does not start at a
    plain name. `Math.prototype.x` roots at `Math`, so a write anywhere along the chain is attributed to
    the name that owns it; testing only the immediate `.object` would miss every nested write.
    """
    cursor = strip_parens(node)
    if not isinstance(cursor, JsMemberExpression):
        return None
    while isinstance(cursor, JsMemberExpression):
        cursor = strip_parens(cursor.object)
    return cursor if isinstance(cursor, JsIdentifier) else None


def _intrinsics_pristine(model: SemanticModel) -> bool:
    """
    Whether the program leaves every trusted intrinsic untouched: it neither reassigns an intrinsic
    root nor writes a property on one, nor shadows one with a binding of its own, nor contains a
    reflection surface through which an intrinsic could be replaced at runtime. Only then may a call to
    a registry intrinsic be trusted to behave as specified.
    """
    if model.has_reflection_surface():
        return False
    if any(name in model.root_scope.bindings for name in _PURE_INTRINSIC_ROOTS):
        return False
    for node in model.root.walk():
        if isinstance(node, JsIdentifier) and node.name in _PURE_INTRINSIC_ROOTS:
            if reference_role(node) is not Role.READ:
                return False
        elif isinstance(node, JsAssignmentExpression):
            left = node.left
            if (
                isinstance(left, JsMemberExpression)
                and isinstance(left.object, JsIdentifier)
                and left.object.name in _PURE_INTRINSIC_ROOTS
            ):
                return False
        elif isinstance(node, JsUpdateExpression):
            target = node.argument
            if (
                isinstance(target, JsMemberExpression)
                and isinstance(target.object, JsIdentifier)
                and target.object.name in _PURE_INTRINSIC_ROOTS
            ):
                return False
    return True


def _global_pristine(model: SemanticModel) -> bool:
    """
    Whether a property read on the global object is free of user getters: the program exposes no
    reflective surface through which an accessor could be installed at runtime, and installs none
    statically through `Object.defineProperty`, `defineProperties`, or the legacy `__define[GS]etter__`.
    Only under this precondition may a read of a trusted global data property be treated as effect-free.

    A computed key counts as an install, since `Object['define' + 'Property']` reaches the same method as
    the dotted form; reading the property name alone would let a fold of the key withdraw this trust after
    a consumer had already relied on it.

    A key whose value is *not* statically known — `Object[k]` — is deliberately not treated as an install.
    It would deny this trust to nearly every dynamic call, and it buys nothing: a variable key is resolved
    by substitution in a different pass, never within the one that consumes this answer.
    """
    if model.has_reflection_surface():
        return False
    for node in model.root.walk():
        if isinstance(node, JsMemberExpression) and _accessor_install_method(node) is not None:
            return False
    return True


def build_effects(model: SemanticModel) -> EffectModel:
    """
    Build the `EffectModel` for a script's `refinery.lib.scripts.js.analysis.model.SemanticModel`.
    """
    return EffectModel(model)

Functions

def container_literal_access_is_plain(node)

Whether node is a container literal on which a plain member access touches a data slot and nothing else: an array or function expression, or an object literal that declares no accessor and installs no custom prototype. This is the one shared atom behind every predicate that has to decide what a member access on a freshly built value does — EffectModel._base_is_safe and EffectModel._base_getter_safe here, and strict_divergence._fresh_writable_base.

Each of those asks a further question this deliberately does not answer, which is why they remain distinct predicates rather than aliases of this one: whether the base can be nullish, whether a primitive or a pristine intrinsic also qualifies, whether every slot is writable as opposed to merely accessor-free. What they must not disagree about is this atom. They did: the accessor veto lived in only two of the four copies, and the copy without it cleared a getter-carrying literal as effect-free, which deleted the getter call outright.

It answers only what the literal declares, so it is never sufficient on its own for a getter-freeness question: [1, 2] declares no accessor and still inherits everything on Array.prototype and Object.prototype. A caller asking about a read must pair this with EffectModel.read_chain_intact().

Expand source code Browse git
def container_literal_access_is_plain(node: Node | None) -> bool:
    """
    Whether *node* is a container literal on which a plain member access touches a data slot and nothing
    else: an array or function expression, or an object literal that declares no accessor and installs no
    custom prototype. This is the one shared atom behind every predicate that has to decide what a member
    access on a freshly built value does — `EffectModel._base_is_safe` and `EffectModel._base_getter_safe`
    here, and `strict_divergence._fresh_writable_base`.

    Each of those asks a further question this deliberately does not answer, which is why they remain
    distinct predicates rather than aliases of this one: whether the base can be nullish, whether a
    primitive or a pristine intrinsic also qualifies, whether every slot is *writable* as opposed to
    merely accessor-free. What they must not disagree about is this atom. They did: the accessor veto
    lived in only two of the four copies, and the copy without it cleared a getter-carrying literal as
    effect-free, which deleted the getter call outright.

    It answers only what the *literal* declares, so it is never sufficient on its own for a getter-freeness
    question: `[1, 2]` declares no accessor and still inherits everything on `Array.prototype` and
    `Object.prototype`. A caller asking about a read must pair this with `EffectModel.read_chain_intact`.
    """
    node = strip_parens(node)
    if isinstance(node, JsObjectExpression):
        return not object_member_access_runs_accessor(node)
    return isinstance(node, (JsArrayExpression, JsFunctionExpression, JsArrowFunctionExpression))
def side_effect_free(node, defunct=None, call_pure=None, read_effect=None, member_safe=None, call_established=None, discarded=False, call_pure_discarded=None)

Conservative check for whether evaluating an expression can be dropped or reordered with no observable side effect. Compositional: an expression is free when every sub-expression is. Two leaves can bear an effect. The call — free only when its callee is a defunct identifier, or, when both call_pure and call_established are supplied, when call_pure certifies the call (or new) pure, call_established certifies its callee is in place before the call runs, and its arguments are free. An inline function-expression callee is not special-cased: it is resolved like any other callee through call_pure, which inspects its body, so a called IIFE with an effectful body is not cleared. The identifier read — free unless read_effect rejects it, which EffectModel.is_side_effect_free() supplies as SemanticModel.read_has_dynamic_effect to reject a bare name that resolves through a with body's dynamic scope (reading it may fire the with object's getter or throw). A function expression is free without descending into its body — defining it runs nothing — so a dynamic-scope read inside an un-called function does not make the value effectful. A parenthesized expression is transparent, bearing exactly the effects of the expression it groups. A member read is free when its base is a safe value or member_safe certifies it; EffectModel.is_side_effect_free() supplies EffectModel._is_trusted_global_read to clear a getter-free read of a trusted global data property. It passes EffectModel.is_pure_call() and read_has_dynamic_effect too. A caller without a model gets the conservative behaviour. When defunct is given its identifiers name bindings being removed, so calls to them and property reads through them are treated as free.

When discarded is set the expression's own value is thrown away by the caller, so a top-level call (or new) leaf is cleared through call_pure_discarded instead of call_pure — admitting a call whose only residual effect is a write it confines to its returned value, unobservable once that value is dropped. The flag reaches only positions that stay discarded — a parenthesized group and every element of a sequence — and is reset into any consumed operand, so a nested call whose result is used is still held to call_pure.

Expand source code Browse git
def side_effect_free(
    node: Node,
    defunct: set[str] | None = None,
    call_pure: _CallPredicate | None = None,
    read_effect: Callable[[Node], bool] | None = None,
    member_safe: Callable[[JsMemberExpression], bool] | None = None,
    call_established: _CallPredicate | None = None,
    discarded: bool = False,
    call_pure_discarded: _CallPredicate | None = None,
) -> bool:
    """
    Conservative check for whether evaluating an expression can be dropped or reordered with no
    observable side effect. Compositional: an expression is free when every sub-expression is. Two leaves
    can bear an effect. The call — free only when its callee is a *defunct* identifier, or, when both
    *call_pure* and *call_established* are supplied, when *call_pure* certifies the call (or `new`) pure,
    *call_established* certifies its callee is in place before the call runs, and its arguments are free.
    An inline function-expression callee is not special-cased: it is resolved like any other callee through
    *call_pure*, which inspects its body, so a called IIFE with an effectful body is not cleared. The
    identifier read — free unless *read_effect* rejects it,
    which `EffectModel.is_side_effect_free` supplies as `SemanticModel.read_has_dynamic_effect` to reject
    a bare name that resolves through a `with` body's dynamic scope (reading it may fire the `with`
    object's getter or throw). A function expression is free without descending into its body — defining
    it runs nothing — so a dynamic-scope read inside an un-called function does not make the value
    effectful. A parenthesized expression is transparent, bearing exactly the effects of the expression
    it groups. A member read is free when its base is a safe value or *member_safe* certifies it;
    `EffectModel.is_side_effect_free` supplies `EffectModel._is_trusted_global_read` to clear a
    getter-free read of a trusted global data property. It passes `EffectModel.is_pure_call` and
    `read_has_dynamic_effect` too. A caller without a model gets the conservative behaviour. When *defunct*
    is given its identifiers name bindings being removed, so calls to them and property reads through
    them are treated as free.

    When *discarded* is set the expression's own value is thrown away by the caller, so a top-level call
    (or `new`) leaf is cleared through *call_pure_discarded* instead of *call_pure* — admitting a call
    whose only residual effect is a write it confines to its returned value, unobservable once that value
    is dropped. The flag reaches only positions that stay discarded — a parenthesized group and every
    element of a sequence — and is reset into any consumed operand, so a nested call whose result is used
    is still held to *call_pure*.
    """
    return _SideEffectScan(
        defunct, call_pure, read_effect, member_safe, call_established, call_pure_discarded,
    ).free(node, discarded)
def object_sets_prototype(obj)

Whether the object literal obj installs a custom prototype through the special __proto__: property form ({ __proto__: p }, { '__proto__': p }) — a plain, non-computed data property whose key is __proto__. Such an object no longer inherits from Object.prototype alone, so a plain-looking member read or write on it may run a getter or setter the installed prototype carries rather than touch a data slot. A computed key ({ ['__proto__']: p }), a shorthand ({ __proto__ }), a method, or an own __proto__ accessor define an ordinary own property and do not set the prototype.

Expand source code Browse git
def object_sets_prototype(obj: JsObjectExpression) -> bool:
    """
    Whether the object literal *obj* installs a custom prototype through the special `__proto__:`
    property form (`{ __proto__: p }`, `{ '__proto__': p }`) — a plain, non-computed data property
    whose key is `__proto__`. Such an object no longer inherits from `Object.prototype` alone, so a
    plain-looking member read or write on it may run a getter or setter the installed prototype
    carries rather than touch a data slot. A computed key (`{ ['__proto__']: p }`), a shorthand
    (`{ __proto__ }`), a method, or an own `__proto__` accessor define an ordinary own property and do
    not set the prototype.
    """
    for prop in obj.properties:
        if not isinstance(prop, JsProperty):
            continue
        if prop.kind is not JsPropertyKind.INIT or prop.computed or prop.shorthand or prop.method:
            continue
        key = prop.key
        if isinstance(key, JsIdentifier) and key.name == '__proto__':
            return True
        if isinstance(key, JsStringLiteral) and key.value == '__proto__':
            return True
    return False
def object_member_access_runs_accessor(obj)

Whether a plain member read or write on the object literal obj may run a user-defined accessor instead of touching a data slot: it declares its own getter or setter, or it installs a custom prototype through the __proto__: literal form that may carry an inherited one. A fresh literal with neither behaves as a plain field container, so an access on it is observable only as the field it names.

Expand source code Browse git
def object_member_access_runs_accessor(obj: JsObjectExpression) -> bool:
    """
    Whether a plain member read or write on the object literal *obj* may run a user-defined accessor
    instead of touching a data slot: it declares its own getter or setter, or it installs a custom
    prototype through the `__proto__:` literal form that may carry an inherited one. A fresh literal
    with neither behaves as a plain field container, so an access on it is observable only as the field
    it names.
    """
    return _object_has_own_accessor(obj) or object_sets_prototype(obj)
def build_effects(model)

Build the EffectModel for a script's SemanticModel.

Expand source code Browse git
def build_effects(model: SemanticModel) -> EffectModel:
    """
    Build the `EffectModel` for a script's `refinery.lib.scripts.js.analysis.model.SemanticModel`.
    """
    return EffectModel(model)

Classes

class EffectSummary (writes_global=False, writes_captured=False, throws=False, calls_unknown=False, mutates_returned_local=False, wraps_return=False, written_bindings=<factory>)

The observable effects one call of a function may have, each field a conservative over-estimate. writes_global covers assignment to a global or to a property of an object reached through one; writes_captured covers assignment to a binding owned by an enclosing function (a closure mutation visible after the call returns); throws covers a throw, an operation that may throw on a value the analysis cannot prove safe, or a read of a name that is not certain to denote a binding (SemanticModel.read_may_throw); calls_unknown covers invoking a callee that cannot be resolved and summarized. A summary with none of these set is is_pure. mutates_returned_local is held apart from those four: it records a write to a fresh local the function owns whose sole route to the caller is the value the call returns. Such a write is a real mutation baked into the returned value, so it blocks is_pure and is_expression_replaceable, but not is_effect_free_when_discarded — a call whose result is thrown away can never expose it — and not is_literal_replaceable, whose replacement is a fresh object at every site. wraps_return is separate: it does not bear on purity but records that a call to the function yields a wrapper (a promise from an async function, an iterator from a generator) rather than the value of its return expression, so the call cannot be replaced by that expression. written_bindings names, by identity, the outer bindings — captured locals and globals — a call may write where the write resolves to one, so a caller can ask which binding a call mutates rather than only whether it mutates some. It is decided independently of purity: a write the purity analysis deems unobservable because the binding never escapes the function is still recorded here, since a consumer reasoning about a read inside that function must still see the mutation. A binding written but never read anywhere adds nothing, as no read can observe the change; likewise a coarse write with no resolvable binding (a dynamic-scope or globalThis.x = member write) sets writes_global but adds nothing here.

Four properties read these flags, and they are not a scale from strict to permissive — each answers a different question about a different rewrite, so a consumer picks by naming the rewrite it is about to perform rather than by how many flags a property excludes:

  • is_pure — may the call be deleted outright, result and all? Nothing may be lost, so every flag blocks.
  • is_effect_free_when_discarded — may the call be deleted when its result is already unused? A mutation confined to the returned value is then unreachable, so only that flag is forgiven.
  • is_literal_replaceable — may the call become a literal denoting its value? Throwing and unknown reads are reproduced by actually evaluating it, and mutates_returned_local is forgiven because a literal is a fresh object at every site. A rewrite that yields something other than a literal may not use this.
  • is_expression_replaceable — may the call become one expression lifted out of its body, with the rest of the body discarded? Everything the literal case needs, plus a refusal of mutates_returned_local, since a lifted expression yields no fresh object.
Expand source code Browse git
@dataclass
class EffectSummary:
    """
    The observable effects one call of a function may have, each field a conservative over-estimate.
    `writes_global` covers assignment to a global or to a property of an object reached through one;
    `writes_captured` covers assignment to a binding owned by an enclosing function (a closure mutation
    visible after the call returns); `throws` covers a `throw`, an operation that may throw on a
    value the analysis cannot prove safe, or a read of a name that is not certain to denote a binding
    (`SemanticModel.read_may_throw`); `calls_unknown` covers invoking a callee that cannot be
    resolved and summarized. A summary with none of these set is `is_pure`. `mutates_returned_local` is
    held apart from those four: it records a write to a fresh local the function owns whose sole route to
    the caller is the value the call returns. Such a write is a real mutation baked into the returned
    value, so it blocks `is_pure` and `is_expression_replaceable`, but not
    `is_effect_free_when_discarded` — a call whose result is thrown away can never expose it — and not
    `is_literal_replaceable`, whose replacement is a fresh object at every site. `wraps_return` is separate:
    it does not bear on purity but records that a call to the function yields a wrapper (a promise from
    an `async` function, an iterator from a generator) rather than the value of its return expression,
    so the call cannot be replaced by that expression. `written_bindings` names, by identity, the outer
    bindings — captured locals and globals — a call may write where the write resolves to one, so a
    caller can ask which binding a call mutates rather than only whether it mutates some. It is decided
    independently of purity: a write the purity analysis deems unobservable because the binding never
    escapes the function is still recorded here, since a consumer reasoning about a read *inside* that
    function must still see the mutation. A binding written but never read anywhere adds nothing, as no
    read can observe the change; likewise a coarse write with no resolvable binding (a dynamic-scope or
    `globalThis.x =` member write) sets `writes_global` but adds nothing here.

    Four properties read these flags, and they are not a scale from strict to permissive — each answers a
    different question about a *different rewrite*, so a consumer picks by naming the rewrite it is about to
    perform rather than by how many flags a property excludes:

    - `is_pure` — may the call be deleted outright, result and all? Nothing may be lost, so every flag blocks.
    - `is_effect_free_when_discarded` — may the call be deleted when its result is already unused? A mutation
      confined to the returned value is then unreachable, so only that flag is forgiven.
    - `is_literal_replaceable` — may the call become a literal denoting its value? Throwing and unknown reads
      are reproduced by actually evaluating it, and `mutates_returned_local` is forgiven because a literal is a
      fresh object at every site. A rewrite that yields something other than a literal may not use this.
    - `is_expression_replaceable` — may the call become one expression lifted out of its body, with the rest of
      the body discarded? Everything the literal case needs, plus a refusal of `mutates_returned_local`, since
      a lifted expression yields no fresh object.
    """
    writes_global: bool = False
    writes_captured: bool = False
    throws: bool = False
    calls_unknown: bool = False
    mutates_returned_local: bool = False
    wraps_return: bool = False
    written_bindings: set[Binding] = field(default_factory=set)

    @property
    def is_pure(self) -> bool:
        """
        Whether a call to the summarized function produces no observable effect, so it carries no
        consequence the program can detect (termination aside) whether or not its result is used. A
        mutation the function confines to its returned value (`mutates_returned_local`) disqualifies it
        here, since a caller that uses the result observes that mutation; `is_effect_free_when_discarded`
        is the companion test for a call whose result is thrown away, which tolerates it.
        """
        return not (
            self.writes_global
            or self.writes_captured
            or self.throws
            or self.calls_unknown
            or self.mutates_returned_local
        )

    @property
    def is_effect_free_when_discarded(self) -> bool:
        """
        Whether a call to the summarized function, its result discarded, produces no observable effect.
        Identical to `is_pure` except it tolerates `mutates_returned_local`: a write to a fresh local the
        function owns is observable only through the value the call returns, so once that value is thrown
        away the write can never be seen and the call is free to drop. Every other way such a local — or a
        closure over it — reaches the caller is a distinct effect that independently sets a blocking flag
        (a store to a global, a store to an enclosing capture, a leak into an unknown callee, a throw), so
        excluding only `mutates_returned_local` here stays sound.
        """
        return not (self.writes_global or self.writes_captured or self.throws or self.calls_unknown)

    @property
    def is_literal_replaceable(self) -> bool:
        """
        Whether a call to the summarized function may be replaced by a *literal* denoting its computed return
        value. This holds when the call writes no state visible after it returns — neither a global nor a
        captured binding — and returns its value directly rather than wrapped: an `async` function's call is a
        promise and a generator's is an iterator, neither equal to the return expression, so `wraps_return`
        disqualifies it. Unlike `is_pure`, a call that may throw or read unknown state still qualifies: an
        evaluator that actually executes the call to a value reproduces those, and only a *write* would be
        silently lost.

        `mutates_returned_local` is tolerated, and the name of this property is what licenses that. Such a
        mutation is baked into a container the call returns, so the substituted value must be a distinct
        object per call — which a literal is, because `value_to_node` builds a new array or object literal at
        every site it fills. A replacement that is *not* a literal has no such guarantee and must not consult
        this property; see the family note on `EffectSummary`.
        """
        return not (
            self.writes_global
            or self.writes_captured
            or self.wraps_return
        )

    @property
    def is_expression_replaceable(self) -> bool:
        """
        Whether a call to the summarized function may be replaced by a single expression lifted out of its
        body, with everything else the body would have done discarded.

        Everything `is_literal_replaceable` requires is required here, for the same reasons: a write to a
        global or a capture would be lost, and a wrapped return is not the return expression. Throwing and
        unknown reads are tolerated identically — the lifted expression sits at the call site and still
        performs them, so they are reproduced rather than dropped, which is why the discard question is *not*
        the right one to ask even though statements are being discarded.

        What this additionally forbids is `mutates_returned_local`. The literal case tolerates it because
        `value_to_node` builds a new array or object at every site it fills, so the distinct container the
        mutation is baked into survives. A lifted expression is spliced from the body and names whatever the
        body named, guaranteeing nothing about identity, so a mutated container must not travel this way.
        """
        return self.is_literal_replaceable and not self.mutates_returned_local

    def absorb(self, other: EffectSummary):
        """
        Union *other*'s effects into this summary, used to fold a callee's effects into its caller.
        """
        self.writes_global = self.writes_global or other.writes_global
        self.writes_captured = self.writes_captured or other.writes_captured
        self.throws = self.throws or other.throws
        self.calls_unknown = self.calls_unknown or other.calls_unknown
        self.mutates_returned_local = self.mutates_returned_local or other.mutates_returned_local
        self.written_bindings |= other.written_bindings

Instance variables

var written_bindings

The type of the None singleton.

var writes_global

The type of the None singleton.

var writes_captured

The type of the None singleton.

var throws

The type of the None singleton.

var calls_unknown

The type of the None singleton.

var mutates_returned_local

The type of the None singleton.

var wraps_return

The type of the None singleton.

var is_pure

Whether a call to the summarized function produces no observable effect, so it carries no consequence the program can detect (termination aside) whether or not its result is used. A mutation the function confines to its returned value (mutates_returned_local) disqualifies it here, since a caller that uses the result observes that mutation; is_effect_free_when_discarded is the companion test for a call whose result is thrown away, which tolerates it.

Expand source code Browse git
@property
def is_pure(self) -> bool:
    """
    Whether a call to the summarized function produces no observable effect, so it carries no
    consequence the program can detect (termination aside) whether or not its result is used. A
    mutation the function confines to its returned value (`mutates_returned_local`) disqualifies it
    here, since a caller that uses the result observes that mutation; `is_effect_free_when_discarded`
    is the companion test for a call whose result is thrown away, which tolerates it.
    """
    return not (
        self.writes_global
        or self.writes_captured
        or self.throws
        or self.calls_unknown
        or self.mutates_returned_local
    )
var is_effect_free_when_discarded

Whether a call to the summarized function, its result discarded, produces no observable effect. Identical to is_pure except it tolerates mutates_returned_local: a write to a fresh local the function owns is observable only through the value the call returns, so once that value is thrown away the write can never be seen and the call is free to drop. Every other way such a local — or a closure over it — reaches the caller is a distinct effect that independently sets a blocking flag (a store to a global, a store to an enclosing capture, a leak into an unknown callee, a throw), so excluding only mutates_returned_local here stays sound.

Expand source code Browse git
@property
def is_effect_free_when_discarded(self) -> bool:
    """
    Whether a call to the summarized function, its result discarded, produces no observable effect.
    Identical to `is_pure` except it tolerates `mutates_returned_local`: a write to a fresh local the
    function owns is observable only through the value the call returns, so once that value is thrown
    away the write can never be seen and the call is free to drop. Every other way such a local — or a
    closure over it — reaches the caller is a distinct effect that independently sets a blocking flag
    (a store to a global, a store to an enclosing capture, a leak into an unknown callee, a throw), so
    excluding only `mutates_returned_local` here stays sound.
    """
    return not (self.writes_global or self.writes_captured or self.throws or self.calls_unknown)
var is_literal_replaceable

Whether a call to the summarized function may be replaced by a literal denoting its computed return value. This holds when the call writes no state visible after it returns — neither a global nor a captured binding — and returns its value directly rather than wrapped: an async function's call is a promise and a generator's is an iterator, neither equal to the return expression, so wraps_return disqualifies it. Unlike is_pure, a call that may throw or read unknown state still qualifies: an evaluator that actually executes the call to a value reproduces those, and only a write would be silently lost.

mutates_returned_local is tolerated, and the name of this property is what licenses that. Such a mutation is baked into a container the call returns, so the substituted value must be a distinct object per call — which a literal is, because value_to_node builds a new array or object literal at every site it fills. A replacement that is not a literal has no such guarantee and must not consult this property; see the family note on EffectSummary.

Expand source code Browse git
@property
def is_literal_replaceable(self) -> bool:
    """
    Whether a call to the summarized function may be replaced by a *literal* denoting its computed return
    value. This holds when the call writes no state visible after it returns — neither a global nor a
    captured binding — and returns its value directly rather than wrapped: an `async` function's call is a
    promise and a generator's is an iterator, neither equal to the return expression, so `wraps_return`
    disqualifies it. Unlike `is_pure`, a call that may throw or read unknown state still qualifies: an
    evaluator that actually executes the call to a value reproduces those, and only a *write* would be
    silently lost.

    `mutates_returned_local` is tolerated, and the name of this property is what licenses that. Such a
    mutation is baked into a container the call returns, so the substituted value must be a distinct
    object per call — which a literal is, because `value_to_node` builds a new array or object literal at
    every site it fills. A replacement that is *not* a literal has no such guarantee and must not consult
    this property; see the family note on `EffectSummary`.
    """
    return not (
        self.writes_global
        or self.writes_captured
        or self.wraps_return
    )
var is_expression_replaceable

Whether a call to the summarized function may be replaced by a single expression lifted out of its body, with everything else the body would have done discarded.

Everything is_literal_replaceable requires is required here, for the same reasons: a write to a global or a capture would be lost, and a wrapped return is not the return expression. Throwing and unknown reads are tolerated identically — the lifted expression sits at the call site and still performs them, so they are reproduced rather than dropped, which is why the discard question is not the right one to ask even though statements are being discarded.

What this additionally forbids is mutates_returned_local. The literal case tolerates it because value_to_node builds a new array or object at every site it fills, so the distinct container the mutation is baked into survives. A lifted expression is spliced from the body and names whatever the body named, guaranteeing nothing about identity, so a mutated container must not travel this way.

Expand source code Browse git
@property
def is_expression_replaceable(self) -> bool:
    """
    Whether a call to the summarized function may be replaced by a single expression lifted out of its
    body, with everything else the body would have done discarded.

    Everything `is_literal_replaceable` requires is required here, for the same reasons: a write to a
    global or a capture would be lost, and a wrapped return is not the return expression. Throwing and
    unknown reads are tolerated identically — the lifted expression sits at the call site and still
    performs them, so they are reproduced rather than dropped, which is why the discard question is *not*
    the right one to ask even though statements are being discarded.

    What this additionally forbids is `mutates_returned_local`. The literal case tolerates it because
    `value_to_node` builds a new array or object at every site it fills, so the distinct container the
    mutation is baked into survives. A lifted expression is spliced from the body and names whatever the
    body named, guaranteeing nothing about identity, so a mutated container must not travel this way.
    """
    return self.is_literal_replaceable and not self.mutates_returned_local

Methods

def absorb(self, other)

Union other's effects into this summary, used to fold a callee's effects into its caller.

Expand source code Browse git
def absorb(self, other: EffectSummary):
    """
    Union *other*'s effects into this summary, used to fold a callee's effects into its caller.
    """
    self.writes_global = self.writes_global or other.writes_global
    self.writes_captured = self.writes_captured or other.writes_captured
    self.throws = self.throws or other.throws
    self.calls_unknown = self.calls_unknown or other.calls_unknown
    self.mutates_returned_local = self.mutates_returned_local or other.mutates_returned_local
    self.written_bindings |= other.written_bindings
class EffectModel (model)

Per-function effect summaries for one script, built over a SemanticModel. Query a function's summary with summary_of and a call expression's purity with is_pure_call. Build through build_effects().

Expand source code Browse git
class EffectModel:
    """
    Per-function effect summaries for one script, built over a
    `refinery.lib.scripts.js.analysis.model.SemanticModel`. Query a function's summary with
    `summary_of` and a call expression's purity with `is_pure_call`. Build through `build_effects`.
    """

    def __init__(self, model: SemanticModel):
        self.model = model
        self.intrinsics_pristine = _intrinsics_pristine(model)
        self.global_pristine = _global_pristine(model)
        self._globals_written, self._global_keys_written = _global_writes_by_name(model)
        self._summaries: dict[int, EffectSummary] = {}
        self._confine_cache: dict[int, Node | None] = {}
        self._immutable_cache: dict[tuple[int, bool], bool] = {}
        self._member_write_cache: dict[int, _WriteClass] = {}
        self._uses_arguments_cache: dict[int, bool] = {}
        self._mutators_escape_cache: dict[int, bool] = {}
        self._some_mutator_cache: dict[int, bool] = {}
        self._functions: list[Node] = self._collect_functions()
        self._compute()

    def summary_of(self, func: Node) -> EffectSummary:
        """
        The effect summary of a function node (or the script). An unknown node is reported as impure.
        """
        return self._summaries.get(id(func), EffectSummary(calls_unknown=True))

    def mutated_bindings(self, func: Node) -> frozenset[Binding]:
        """
        The outer bindings (captured locals and globals) a call to *func* may write, directly or through
        any function it transitively calls, each identified by its `Binding` rather than its name so a
        caller can ask whether one specific binding is mutated. Empty for a function with no such writes
        and for an unknown node alike — use `summary_of(func).calls_unknown` to tell those apart.
        """
        return frozenset(self.summary_of(func).written_bindings)

    def function_can_mutate(self, func: Node, binding: Binding) -> bool:
        """
        Whether a call to *func* may write *binding*, itself or through a transitive callee.
        """
        return binding in self.summary_of(func).written_bindings

    def function_escapes(self, func: Node) -> bool:
        """
        Whether *func* may be invoked at a point the surrounding scope cannot enumerate as a resolvable
        `name(...)` call site: an anonymous function (an IIFE, a callback, stored and called later), or a
        named function whose binding is reassigned, redeclared, or referenced anywhere other than as the
        callee of a direct call (aliased, passed as an argument, `f.call(...)`). A reference inside a
        dynamic scope — a name a `with` body resolves at runtime — counts too: the model cannot order or
        resolve it, so the function may be invoked or aliased there with no static call site. A call to
        such a function can land at a point no call site pins down; a function only ever called directly
        by name has all its invocations enumerated by those call sites.
        """
        binding = self.model.naming_binding(func)
        if binding is None:
            return True
        if binding.writes or binding.dynamic_refs or len(binding.declarations) != 1:
            return True
        for ref in self.model.references(binding):
            parent = ref.parent
            if isinstance(parent, JsCallExpression) and parent.callee is ref:
                continue
            return True
        return False

    def mutators_escape(self, binding: Binding) -> bool:
        """
        Whether some function that may write *binding* — itself or through a transitive callee — escapes
        (`function_escapes`), so a write to *binding* may occur at a point no call site enumerates. When
        true, the places *binding* changes cannot be pinned down, and a caller reasoning about where its
        value survives must treat it as volatile everywhere. Memoized per binding.
        """
        cached = self._mutators_escape_cache.get(id(binding))
        if cached is None:
            cached = any(
                func is not self.model.root
                and binding in self.summary_of(func).written_bindings
                and self.function_escapes(func)
                for func in self._functions
            )
            self._mutators_escape_cache[id(binding)] = cached
        return cached

    def some_function_can_mutate(self, binding: Binding) -> bool:
        """
        Whether any function this file writes may write *binding*, itself or through a transitive
        callee. The answer a caller needs where a call runs a function it cannot name: not knowing
        which one runs, it has to reckon with every one that could. Memoized per binding.
        """
        cached = self._some_mutator_cache.get(id(binding))
        if cached is None:
            cached = any(
                func is not self.model.root and self.function_can_mutate(func, binding)
                for func in self._functions
            )
            self._some_mutator_cache[id(binding)] = cached
        return cached

    def is_pure_call(self, call: JsCallExpression | JsNewExpression) -> bool:
        """
        Whether evaluating *call* has no observable effect: it invokes a trusted pure intrinsic (under
        the pristine-intrinsics precondition) or a local function whose summary is pure.
        """
        callee = self._resolve_callee(call)
        if callee is _PURE:
            return True
        if isinstance(callee, Node):
            return self.summary_of(callee).is_pure
        return False

    def is_pure_call_discarded(self, call: JsCallExpression | JsNewExpression) -> bool:
        """
        Whether evaluating *call* and discarding its result has no observable effect. Like `is_pure_call`
        but resolved through `EffectSummary.is_effect_free_when_discarded`, so a callee whose only residual
        effect is a write it confines to its returned value qualifies — that write is unobservable once the
        result is thrown away. A caller may use this only in a position it has proven discards the value.
        """
        callee = self._resolve_callee(call)
        if callee is _PURE:
            return True
        if isinstance(callee, Node):
            return self.summary_of(callee).is_effect_free_when_discarded
        return False

    def call_clearable(
        self,
        call: JsCallExpression | JsNewExpression,
        callee_established: Callable[[Node], bool],
    ) -> bool:
        """
        Whether *call*'s callee is established — in place before the call runs — given *callee_established*,
        the caller's test for a resolved named local callee. A trusted pure intrinsic and an inline
        function-expression callee (defined at the call site, hence always in place) qualify
        unconditionally; a call resolving to a single named local function qualifies when
        *callee_established* accepts it; an unresolved or ambiguous callee does not. The resolution, the
        intrinsic case, and the inline-callee case live here so callers supply only the ordering judgment
        their layer can make. This certifies establishment ONLY, not purity — a caller deciding whether a
        call may be dropped must conjoin it with `is_pure_call`, as `side_effect_free` does, since an
        established callee may still run an effectful body.
        """
        resolved = self._resolve_callee(call)
        if resolved is _PURE:
            return True
        if isinstance(resolved, Node):
            if isinstance(strip_parens(call.callee), (JsFunctionExpression, JsArrowFunctionExpression)):
                return True
            return callee_established(resolved)
        return False

    def _established_call_default(self, call: JsCallExpression | JsNewExpression) -> bool:
        """
        The ordering-free floor for `is_side_effect_free`: clears a trusted pure intrinsic, an inline
        function-expression callee (established at its call site), or a call to a hoisted function
        declaration (empty `establishment_sites`), whose value is in place before any statement runs. A
        non-hoisted named local callee — a `const`/`let`/`var` initializer or a bare assignment — is
        refused, since this model cannot order the definition against the call; a caller that can supplies
        its own `call_established`.
        """
        return self.call_clearable(call, lambda func: self.model.establishment_sites(func) == [])

    def is_side_effect_free(
        self,
        node: Node,
        defunct: set[str] | None = None,
        member_safe: Callable[[JsMemberExpression], bool] | None = None,
        call_established: Callable[[JsCallExpression | JsNewExpression], bool] | None = None,
        discarded: bool = False,
    ) -> bool:
        """
        Whether evaluating *node* can be dropped or reordered without an observable side effect, with
        the call leaf resolved through this model's `is_pure_call`: a call to a proven-pure function or
        trusted intrinsic is free, recursing into its arguments. *defunct* names bindings being removed,
        whose calls and property reads are treated as free. This is the model-aware form of the
        model-free `side_effect_free` in this module, which clears only calls to a defunct name; unlike
        it, an identifier read that resolves through a `with` body's dynamic
        scope is rejected here — reading the bare name may fire the `with` object's getter or throw (see
        `refinery.lib.scripts.js.analysis.model.SemanticModel.read_has_dynamic_effect`) — while a
        function value whose body performs such a read stays free, since defining it runs nothing. A
        caller with control-flow context passes *member_safe* to also clear a getter-free read through a
        local global-object alias it can prove established before the read; the default clears only the
        syntactic global case (`_is_trusted_global_read`).

        With *discarded* the caller asserts *node*'s own value is thrown away, so a top-level call leaf is
        cleared through `is_pure_call_discarded` and a callee that only mutates a local it returns is
        droppable — the removal contexts of `JsUnusedCodeRemoval` supply it.
        """
        return side_effect_free(
            node,
            defunct,
            self.is_pure_call,
            self.model.read_has_dynamic_effect,
            member_safe or self._getter_free_read,
            call_established or self._established_call_default,
            discarded,
            self.is_pure_call_discarded,
        )

    def binding_is_immutable_container(
        self, binding: Binding, *, member_calls_mutate: bool = True, exclude: Node | None = None,
    ) -> bool:
        """
        Whether *binding* holds a container — an object or array — whose element and property values are
        stable after construction, so that an access into it may be soundly inlined at its read sites.
        Every reference must read through the container (`obj.k`, `obj[i]`) or plainly rebind the name
        (`obj = ...`, whose value the caller resolves by domination); a write through the container
        (`obj.k = v`, `obj[i]++`, `delete obj[i]`, a `for-of` or destructuring target) makes it mutable.
        A method invoked on the container (`obj.m(...)`) may mutate it — an array's `sort`/`push`/`splice`
        and so on — so by default it too counts as mutable; a caller that knows the container's methods
        cannot mutate it (an object literal with no `this`-bound property) may pass *member_calls_mutate*
        false to permit such calls. A reference that escapes is safe in two cases: it aliases another
        binding that is itself an immutable container (alias-following the textual predicates this
        replaces could not do, and the reason a reassigned-and-aliased lookup array stays inlinable), or
        it is passed to a statically known function as an argument whose parameter is itself an immutable
        container (so the callee neither mutates nor further-escapes it). Any other escape — returned,
        stored as a property, passed to a call that cannot be resolved — is treated conservatively as
        mutable. A mutation through a dynamic scope is modelled: a `with` body that names the container —
        a member write, method call, reassignment, or escape — is attributed to it as a dynamic reference
        and judged by the same role logic, so a `with` that never names it keeps it foldable, and a direct
        `eval` in a local container's own function makes it mutable. The one residual is a script-scope
        container reached by an opaque global surface — a direct `eval`, `Function`, timer, or dynamic
        global write whose code cannot be read — which cannot be frozen without also freezing the lookup
        arrays real samples fold, so it is left to the caller's reflection reasoning, the trust an
        unresolved external call already receives.

        The query is over a *resolved binding*, so it is shadowing-correct, and it descends through
        alias chains, callee parameters, and nested functions, so a capturing closure that mutates the
        container is caught. The answer is fixed for the model's lifetime — a binding's reference set does
        not change — so it is memoized per `(binding, member_calls_mutate)`. A caller may pass *exclude*
        to disregard references within that subtree — asking whether the container is stable across the
        rest of the program, ignoring a read site about to be relocated into it; such a query is not
        memoized, since the answer depends on the excluded region.
        """
        if exclude is not None:
            return self._immutable_container(binding, set(), member_calls_mutate, exclude)
        key = (id(binding), member_calls_mutate)
        cached = self._immutable_cache.get(key)
        if cached is None:
            cached = self._immutable_container(binding, set(), member_calls_mutate)
            self._immutable_cache[key] = cached
        return cached

    def _immutable_container(
        self, binding: Binding, visiting: set[int], member_calls_mutate: bool, exclude: Node | None = None,
    ) -> bool:
        key = id(binding)
        if key in visiting:
            return True
        visiting = visiting | {key}
        if self._dynamic_scope_mutates(binding, member_calls_mutate, exclude):
            return False
        for ref in self.model.references(binding, exclude=exclude):
            role = container_reference_role(ref)
            if role is ContainerRole.MEMBER_WRITE:
                return False
            if role is ContainerRole.MEMBER_CALL and member_calls_mutate:
                return False
            if role is ContainerRole.ESCAPE:
                if not isinstance(ref, JsIdentifier) or not self._escape_keeps_container(
                    ref, visiting, member_calls_mutate,
                ):
                    return False
        return True

    def _dynamic_scope_mutates(
        self, binding: Binding, member_calls_mutate: bool, exclude: Node | None,
    ) -> bool:
        """
        Whether a dynamic scope may change the container *binding* holds. A direct `eval` in a local
        container's own function can rewrite it opaquely — a global is left to the caller's reflection
        reasoning, since freezing every global on any surface over-blocks. A `with` body's accesses are
        attributed by name: a member write, a reassignment, or an escape mutates it or may alias it out,
        and a method call may mutate it unless the caller vouches that its methods cannot; only a plain
        member read leaves it intact, so a `with` that never names the container is no threat. A dynamic
        escape or reassignment cannot be alias-followed or ordered the way a resolved one can, so either
        is treated as mutating.
        """
        if self.model.local_reachable_by_direct_eval(binding):
            return True
        for ref in self.model.dynamic_references(binding, exclude=exclude):
            role = container_reference_role(ref)
            if role is ContainerRole.MEMBER_READ:
                continue
            if role is ContainerRole.MEMBER_CALL and not member_calls_mutate:
                continue
            return True
        return False

    def _escape_keeps_container(self, ref: JsIdentifier, visiting: set[int], member_calls_mutate: bool) -> bool:
        """
        Whether an escaping reference leaves the container unmutated. Two escapes are precise: an alias
        (`var x = ref` or `x = ref`) keeps it when the aliased binding is itself an immutable container,
        and an argument passed to a statically known function (`f(ref)`) keeps it when the parameter it
        binds is itself an immutable container — interprocedural Case B, the parameter's own references
        decide whether the callee mutates or further-escapes it. Every other escape is conservatively
        unsafe.
        """
        alias = self._alias_target(ref)
        if alias is not None:
            return self._immutable_container(alias, visiting, member_calls_mutate)
        return self._argument_keeps_container(ref, visiting)

    def _argument_keeps_container(self, ref: JsIdentifier, visiting: set[int]) -> bool:
        """
        Case B: whether an argument *ref* passed to a statically known function leaves the container it
        holds unmutated — true when the parameter it binds is itself an immutable container, judged
        recursively from that parameter's own references, so the callee neither member-writes the
        argument nor lets it escape mutably. The parameter is judged under the conservative
        `member_calls_mutate=True`: a relaxed `member_calls_mutate=False` is the *caller*'s promise that
        the container's own methods cannot mutate it at the original site, and does not carry to a method
        the callee invokes on the argument or on one of its nested containers (`x.a.push(...)`), which
        may mutate it. False, conservatively, when the call cannot be analysed: the callee is not a
        single known function, it can reach the argument through its own `arguments` object, the argument
        is spread, a spread precedes it (so its runtime position shifts past the textual index and the
        parameter it binds cannot be pinned down), the slot it lands in is a rest or destructuring
        parameter, or the parameter is reachable through a `with` or direct `eval` in the callee that
        resolves a name at runtime (an unrecorded write the parameter's reference set cannot rule out).
        An argument with no parameter to bind — passed beyond the declared parameters of a function with
        no rest collector and no `arguments` reach, textual or reflective — is safe, since the callee
        cannot name it.
        """
        parent = ref.parent
        if not isinstance(parent, JsCallExpression) or ref not in parent.arguments:
            return False
        func = self.unambiguous_callee(parent)
        if func is None:
            return False
        if self._callee_uses_arguments(func):
            return False
        params = func.params
        if any(isinstance(param, JsRestElement) for param in params):
            return False
        index = parent.arguments.index(ref)
        if any(isinstance(arg, JsSpreadElement) for arg in parent.arguments[:index]):
            return False
        if index >= len(params):
            return True
        param = params[index]
        if not isinstance(param, JsIdentifier):
            return False
        binding = self.model.binding_of(param)
        if binding is None:
            return False
        if self.model.reflection_can_reach(binding):
            return False
        return self._immutable_container(binding, visiting, True)

    def _callee_uses_arguments(self, func: Node) -> bool:
        """
        Whether a non-arrow callee can reach its call's arguments through its own `arguments` object,
        which aliases the positional arguments — including any passed beyond the declared parameters — so
        that `arguments[i][...] = v` mutates a container the by-position parameter reasoning in
        `_argument_keeps_container` would otherwise miss. It is reached either by naming `arguments`
        directly, or reflectively: a `with` or a direct `eval` in the callee — or in a closure nested
        inside it, which inherits the callee's `arguments` — can read that object with no textual
        reference, so a reflectively reachable `arguments` counts too. An arrow has no `arguments` of its
        own (a reference inside it binds the enclosing function's, unrelated to the arrow's parameters),
        so it is exempt. When the callee can reach `arguments`, the escape is treated as mutable. The
        answer is a structural property of the callee, so it is memoized per function.
        """
        cached = self._uses_arguments_cache.get(id(func))
        if cached is None:
            cached = self._compute_callee_uses_arguments(func)
            self._uses_arguments_cache[id(func)] = cached
        return cached

    def _compute_callee_uses_arguments(self, func: Node) -> bool:
        if isinstance(func, JsArrowFunctionExpression):
            return False
        func_scope = self.model.parameter_scope(func)
        if func_scope is None:
            return False
        binding = func_scope.bindings.get('arguments')
        if binding is None:
            return False
        if self.model.references(binding):
            return True
        return self.model.reflection_can_reach(binding)

    def static_callee(
        self, call: JsCallExpression
    ) -> JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression | None:
        """
        The function a call invokes, resolved permissively through `function_of`: a direct function or
        arrow expression callee, or an identifier bound to a single function — a declaration, a
        `var`/`let`/`const` initializer, or the value a name is assigned exactly once. For a name that
        held a value and was then reassigned this returns the post-reassignment value, which is the
        running target only where that reassignment is established before the call; a consumer that
        cannot order the reassignment against the call must use `unambiguous_callee` instead. `None` for
        a method call, a parameter, a redeclared or dynamically-rebindable binding, or an unresolved name.
        """
        callee = call.callee
        if isinstance(callee, (JsFunctionExpression, JsArrowFunctionExpression)):
            return callee
        if not isinstance(callee, JsIdentifier):
            return None
        return self.function_of(self.model.resolve(callee))

    def a_name_this_file_binds_holds_the_callee(self, call: JsCallExpression) -> bool:
        """
        Whether *call* names its callee with an identifier this file binds, while `static_callee`
        declines to say which function that binding holds.

        A caller reasoning about what a call may have done reads `static_callee` answering `None` in
        two ways, and this tells them apart. Where the callee is a method, a host function, or a
        name nothing here declares, `None` means the call runs something outside this file's
        reckoning, which is a standing condition every such caller was written under. Where it is a
        name this file binds, `None` means the model saw the binding and would not state its value
        - one Annex B copies into a block's enclosing scope is such a function - and what it runs
        may be any of the ones written here, one that writes the very binding being reasoned about
        included.
        """
        callee = strip_parens(call.callee)
        if not isinstance(callee, JsIdentifier):
            return False
        if self.model.resolve(callee) is None:
            return False
        return self.static_callee(call) is None

    def unambiguous_callee(
        self, call: JsCallExpression
    ) -> JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression | None:
        """
        The ordering-free twin of `static_callee`, for a consumer that reasons about a call without
        knowing where it sits in execution order. Identical except an identifier callee resolves through
        `unambiguous_function`, so a name that held a value and was then reassigned — whose running target
        depends on the call's position relative to the reassignment — yields `None` rather than the
        post-reassignment value.
        """
        return _unambiguous_callee(self.model, call)

    def _alias_target(self, ref: JsIdentifier) -> Binding | None:
        parent = ref.parent
        if isinstance(parent, JsVariableDeclarator) and parent.init is ref:
            if isinstance(parent.id, JsIdentifier):
                return self.model.binding_of(parent.id)
            return None
        if (
            isinstance(parent, JsAssignmentExpression)
            and parent.right is ref
            and parent.operator == '='
            and isinstance(parent.left, JsIdentifier)
        ):
            return self.model.resolve(parent.left)
        return None

    def _collect_functions(self) -> list[Node]:
        functions: list[Node] = [self.model.root]
        for node in self.model.root.walk():
            if isinstance(node, FUNCTION_NODES):
                functions.append(node)
        return functions

    def _compute(self):
        for func in self._functions:
            self._summaries[id(func)] = EffectSummary()
        changed = True
        while changed:
            changed = False
            for func in self._functions:
                summary = self._scan(func)
                if summary != self._summaries[id(func)]:
                    self._summaries[id(func)] = summary
                    changed = True

    def _scan(self, func: Node) -> EffectSummary:
        summary = EffectSummary()
        if isinstance(func, FUNCTION_NODES) and wraps_return(func):
            summary.wraps_return = True
        for node in _body_nodes(func):
            if isinstance(node, JsThrowStatement):
                summary.throws = True
            elif isinstance(node, JsIdentifier):
                if not summary.throws and self.model.read_may_throw(node):
                    summary.throws = True
                if reference_role(node) is not Role.READ:
                    self._account_write(summary, node, func)
            elif isinstance(node, JsMemberExpression):
                base = node.object
                if base is not None and not self._base_is_safe(base):
                    summary.throws = True
                if is_member_write_target(node):
                    write_class = self._member_write_class(node, func)
                    if write_class is _WriteClass.OBSERVABLE:
                        summary.writes_global = True
                    elif write_class is _WriteClass.VIA_RESULT:
                        summary.mutates_returned_local = True
                elif base is not None and not self._getter_free_read(node):
                    summary.calls_unknown = True
            elif isinstance(node, (JsCallExpression, JsNewExpression)):
                self._account_call(summary, node)
            elif isinstance(node, JsImportExpression):
                summary.calls_unknown = True
        return summary

    def _account_write(self, summary: EffectSummary, target: JsIdentifier, func: Node):
        binding = self.model.resolve(target)
        if binding is None:
            summary.writes_global = True
            return
        if self._owns_binding(binding, func):
            return
        if binding.is_read:
            summary.written_bindings.add(binding)
        if self._write_unobservable(binding, func):
            return
        if binding.kind is BindingKind.IMPLICIT_GLOBAL or binding.scope is self.model.root_scope:
            summary.writes_global = True
        else:
            summary.writes_captured = True

    def _write_unobservable(self, binding: Binding, func: Node) -> bool:
        """
        Whether assigning *binding* within *func* has no observable consumer, so the assignment
        is not counted as a write. The program must be `global_pristine`: it exposes no reflection
        surface through which the name could be read and installs no accessor that an assignment to
        a global property could trigger as a setter. Then the write is unobservable when either the
        value is read nowhere (`Binding.is_read` is false), or every reference to it is
        `_confined_to` *func* so no outside code can see it. This ports the evaluator's sound
        permissiveness for an obfuscator's scratch binding — whether a write-only global or an
        accumulator local to a single function.

        Not counting the write is not the same as answering that the function is pure. A confined
        accumulator whose name is an implicit global is also *read*, and
        `SemanticModel.read_may_throw` answers that such a read may throw, since nothing here orders
        the creating assignment in front of it; the summary then carries `throws` with
        `writes_global` clear.
        """
        if not self.global_pristine:
            return False
        return not binding.is_read or self._confined_to(binding, func)

    def _confined_to(self, binding: Binding, func: Node) -> bool:
        """
        Whether every reference to *binding* lies within *func*, which must be a function rather than the
        script, so the binding does not escape: no code outside *func* can read it, and a write to it is
        unobservable past the single call.
        """
        if not isinstance(func, FUNCTION_NODES):
            return False
        return self._confining_function(binding) is func

    def _member_write_class(self, member: JsMemberExpression, func: Node) -> _WriteClass:
        """
        How observable the container written by *member* (`base.k = v`, `base[i]++`, `delete base[i]`) is
        to code outside *func* — the distinction that lets a mutation of an obfuscator's scratch container
        be tolerated without weakening purity. The base must be a fresh value: written directly on an
        object/array/function literal, or resolving to a binding *func* owns whose value is always freshly
        built — a rest parameter, which the language guarantees is a new array, or a local initialized
        only to an object/array/function literal. An object literal with an own setter — or one that
        installs a custom prototype through `__proto__:`, which may carry an inherited setter — does NOT
        qualify, since the write then runs an accessor a caller can observe. A plain parameter does NOT
        qualify either: it aliases the caller's object, so `function modify(a){ a[0] = 9; }` mutates the
        argument observably — the soundness boundary this rests on. Ownership is the exact test
        `_account_write` uses for a plain-identifier write (`func_scope.contains(binding.scope)` and not
        global), so a binding captured *from an enclosing scope* is not owned and its mutation stays
        `OBSERVABLE`, matching that a call mutating an outer local is a visible effect.

        For an owned fresh container the outcome splits on how it escapes. When no reference lets it out
        (`_container_non_escaping`) and no nested function captures it, the write is `UNOBSERVABLE` — the
        container dies with the call and no caller can ever reach it, so a function whose only effect is
        the mutation is pure. Otherwise the container — or a closure over it — leaves *func*, but every
        escape route other than the return value independently sets a blocking flag on the summary (a
        store to a global or captured binding, a leak into an unknown callee, a throw), so the only
        unflagged escape is `return`, whose value the caller may discard: the write is then `VIA_RESULT`,
        seen only if that value is used.

        A write hidden behind a dynamic scope — through a name a `with` body or direct `eval` resolves at
        runtime — is `OBSERVABLE`: the base resolves to no binding, so the write is conservatively kept,
        which is sound. The residual is the opaque-surface one `binding_is_immutable_container` documents:
        a reflective surface whose code cannot be read could install a prototype accessor that observes a
        write this deems unobservable, and freezing on it would refuse the obfuscator idioms this is meant
        to see through, so it is left to that boundary.

        The judgment is structural — fixed by the binding's declarations and reference set — so it is
        invariant across the fixpoint passes that recompute the summaries, and is memoized per member.
        """
        cached = self._member_write_cache.get(id(member))
        if cached is None:
            cached = self._classify_member_write(member, func)
            self._member_write_cache[id(member)] = cached
        return cached

    def _classify_member_write(self, member: JsMemberExpression, func: Node) -> _WriteClass:
        base = member.object
        if isinstance(base, (JsArrayExpression, JsFunctionExpression)):
            return _WriteClass.UNOBSERVABLE
        if isinstance(base, JsObjectExpression):
            if object_member_access_runs_accessor(base):
                return _WriteClass.OBSERVABLE
            return _WriteClass.UNOBSERVABLE
        if not isinstance(base, JsIdentifier):
            return _WriteClass.OBSERVABLE
        binding = self.model.resolve(base)
        if binding is None or not self._owns_binding(binding, func):
            return _WriteClass.OBSERVABLE
        if not self._fresh_container_origin(binding, func):
            return _WriteClass.OBSERVABLE
        if not binding.captured and self._container_non_escaping(binding):
            return _WriteClass.UNOBSERVABLE
        return _WriteClass.VIA_RESULT

    def _owns_binding(self, binding: Binding, func: Node) -> bool:
        """
        Whether *binding* is declared within *func* rather than reaching in from an enclosing scope or the
        global object — the exact ownership test `_account_write` applies to a plain-identifier write, so a
        mutation of an owned local and a mutation of its name agree on observability. A binding *func* owns
        has all its references inside *func*'s subtree, so the summary scan sees every one of its escapes.

        A function whose parameter list holds an expression introduces its parameters and its own
        name in scopes standing *outside* its body's, so containment alone would read a write to its
        own parameter as a write reaching in from elsewhere. `Scope.closure_home` says those scopes
        are the same call as the body, and answers for all three shapes a function is built in.
        """
        if binding.kind is BindingKind.IMPLICIT_GLOBAL or binding.scope is self.model.root_scope:
            return False
        func_scope = self.model.function_scope(func)
        if func_scope is None:
            return False
        return func_scope.contains(binding.scope) or binding.scope.closure_home is func_scope

    def _fresh_container_origin(self, binding: Binding, func: Node) -> bool:
        """
        Whether *binding* only ever holds a container freshly built inside *func*, so a member write through
        it cannot be observed anywhere else. The binding-level face of `_fresh_kind`; see that method.
        """
        return self._binding_fresh_kind(binding, func, frozenset()).is_fresh

    def _binding_fresh_kind(
        self, binding: Binding, func: Node, visiting: frozenset[int]
    ) -> _FreshKind:
        """
        The kind of container *binding* is known to hold on every path, judged from inside *func*. A rest
        parameter is a fresh array by language guarantee. Otherwise the binding must be one *func* owns — a
        name reaching in from an enclosing scope or the global object denotes a container other code can
        already reach, however freshly its initializer built it — and *every* value it can take must be
        fresh: each declaration's initializer and each later write, because a name that holds an outer
        container even once makes a write through it observable there. A binding written through a pattern
        rather than a plain assignment target has no single value expression to judge, so it fails.

        A binding whose `constructor` or `__proto__` is written anywhere is never an `ARRAY`, however it was
        built. Those two properties decide what an allocating `Array.prototype` method actually returns:
        `slice` and its neighbours route through ArraySpeciesCreate, which reads
        `constructor[Symbol.species]` off the receiver, so a program that writes either one can make the
        "new" array be a shared object — or make the call throw, by leaving a primitive there. The container
        is still fresh, so a write *into* it stays unobservable; it is only the array guarantee that is lost.

        The kind is the weakest of the values, since a consumer may only rely on what holds for all of them.
        """
        if self._is_rest_param(binding):
            return _FreshKind.ARRAY
        if binding.kind not in (BindingKind.VAR, BindingKind.LET, BindingKind.CONST):
            return _FreshKind.NOT_FRESH
        if not self._owns_binding(binding, func):
            return _FreshKind.NOT_FRESH
        if not binding.declarations or id(binding) in visiting:
            return _FreshKind.NOT_FRESH
        visiting = visiting | {id(binding)}
        kind = _FreshKind.ARRAY
        if self._species_written(binding):
            kind = _FreshKind.CONTAINER
        for decl in binding.declarations:
            declarator = decl.parent
            if not isinstance(declarator, JsVariableDeclarator):
                return _FreshKind.NOT_FRESH
            kind = _weakest(kind, self._fresh_kind(declarator.init, func, visiting))
            if not kind.is_fresh:
                return _FreshKind.NOT_FRESH
        for ref in self.model.references(binding):
            if reference_role(ref) is Role.READ:
                continue
            parent = ref.parent
            if not isinstance(parent, JsAssignmentExpression) or parent.left is not ref:
                return _FreshKind.NOT_FRESH
            if parent.operator != '=':
                return _FreshKind.NOT_FRESH
            kind = _weakest(kind, self._fresh_kind(parent.right, func, visiting))
            if not kind.is_fresh:
                return _FreshKind.NOT_FRESH
        return kind

    def _species_written(self, binding: Binding) -> bool:
        """
        Whether any reference to *binding* writes a property that decides what an allocating
        `Array.prototype` method returns. Only `constructor` and `__proto__` do: ArraySpeciesCreate reads
        `constructor[Symbol.species]` off the receiver, and `__proto__` replaces the prototype the
        `constructor` lookup walks.

        A key that is not a literal counts, because it may name either one. That conservatism is affordable
        here and would not be in a whole-program rule: the question is asked about the handful of references
        to one binding, so an ordinary `s[i] = v` element write on a *different* binding is untouched. It is
        also the direction that survives constant folding — a fold turns `a['const' + 'ructor']` into
        `a.constructor`, moving the answer from unsafe to unsafe rather than from safe to unsafe.
        """
        for ref in self.model.references(binding):
            parent = ref.parent
            if not isinstance(parent, JsMemberExpression) or parent.object is not ref:
                continue
            if not is_member_write_target(parent):
                continue
            prop = parent.property
            if not parent.computed:
                if isinstance(prop, JsIdentifier) and prop.name in _SPECIES_KEYS:
                    return True
            elif isinstance(prop, JsStringLiteral):
                if prop.value in _SPECIES_KEYS:
                    return True
            elif not isinstance(prop, JsNumericLiteral):
                return True
        return False

    def _fresh_kind(self, node: Node | None, func: Node, visiting: frozenset[int]) -> _FreshKind:
        """
        The kind of container the expression *node* is known to build when evaluated inside *func*, or
        `NOT_FRESH` when it may evaluate to something other code can already reach. This is a *must* analysis:
        it answers only for expressions whose result is provably a new object, which is what lets a member
        write through the result be classified as unobservable.

        Five forms qualify. A container literal builds its value on the spot — unless its member access runs an
        accessor, the shared `container_literal_access_is_plain` test. An identifier resolves through its
        binding, which *func* must own. An allocating `Array.prototype` method
        (`_FRESH_ARRAY_RESULT_METHODS`) returns a new array, but only when the prototype is undisturbed and
        the receiver is itself known to be an array: a fresh object literal carrying its own `slice` is not,
        and neither is a value of unknown type such as a plain parameter. A call the model resolves to one
        function qualifies when every `return` in that function yields a fresh container — and a function with
        a path that returns no value does not, since that path yields `undefined`. `new Array(...)` is
        deferred to `_pure_construct`, which owns the argument rule for that root.

        Deliberately *not* a may-allocate analysis. `JsObjectFold._value_allocates` asks the opposite
        question — whether an expression might mint an object whose identity a fold would duplicate — and
        answers `True` for any nested call, where this answers `NOT_FRESH` for nearly all of them. The two are
        not monotone in one another and must not be merged.
        """
        node = strip_parens(node)
        if node is None:
            return _FreshKind.NOT_FRESH
        if isinstance(node, JsArrayExpression):
            return _FreshKind.ARRAY
        if isinstance(node, (JsObjectExpression, JsFunctionExpression, JsArrowFunctionExpression)):
            return _FreshKind.CONTAINER if container_literal_access_is_plain(node) else _FreshKind.NOT_FRESH
        if isinstance(node, JsIdentifier):
            binding = self.model.resolve(node)
            if binding is None:
                return _FreshKind.NOT_FRESH
            return self._binding_fresh_kind(binding, func, visiting)
        if isinstance(node, JsNewExpression):
            return _FreshKind.ARRAY if self._pure_construct(node) else _FreshKind.NOT_FRESH
        if isinstance(node, JsCallExpression):
            return self._call_fresh_kind(node, func, visiting)
        return _FreshKind.NOT_FRESH

    def _call_fresh_kind(
        self, call: JsCallExpression, func: Node, visiting: frozenset[int]
    ) -> _FreshKind:
        callee = strip_parens(call.callee)
        if isinstance(callee, JsMemberExpression) and not callee.computed:
            prop = callee.property
            if not isinstance(prop, JsIdentifier) or prop.name not in _FRESH_ARRAY_RESULT_METHODS:
                return _FreshKind.NOT_FRESH
            if not self.trusted_prototype(list):
                return _FreshKind.NOT_FRESH
            if self._fresh_kind(callee.object, func, visiting) is not _FreshKind.ARRAY:
                return _FreshKind.NOT_FRESH
            return _FreshKind.ARRAY
        if isinstance(callee, JsIdentifier):
            callee_func = self.unambiguous_function(self.model.resolve(callee))
            if callee_func is None or id(callee_func) in visiting:
                return _FreshKind.NOT_FRESH
            visiting = visiting | {id(callee_func)}
            returns = [n for n in _body_nodes(callee_func) if isinstance(n, JsReturnStatement)]
            if not returns or not _returns_on_every_path(callee_func):
                return _FreshKind.NOT_FRESH
            kind = _FreshKind.ARRAY
            for statement in returns:
                kind = _weakest(kind, self._fresh_kind(statement.argument, callee_func, visiting))
                if not kind.is_fresh:
                    return _FreshKind.NOT_FRESH
            return kind

        return _FreshKind.NOT_FRESH

    @staticmethod
    def _is_rest_param(binding: Binding) -> bool:
        """
        Whether *binding* is a function's rest parameter (`function f(...xs)`), whose value the language
        guarantees is a fresh array on every call.
        """
        return binding.kind is BindingKind.PARAM and any(
            isinstance(decl.parent, JsRestElement) for decl in binding.declarations
        )

    def _container_non_escaping(self, binding: Binding) -> bool:
        """
        Whether every reference to *binding* keeps its container contained: each is a member read or
        write (`obj.k`, `obj[i] = v`), never an escape, rebinding, or method call through which the
        container could be aliased out, mutated by other code, or replaced. The tightest form of the
        escape check, since a mutation only stays unobservable while no other code can reach the object.

        Orthogonal to freshness, and deliberately not merged with `_binding_fresh_kind`: this asks where a
        container *goes*, that asks where it *came from*. Both are needed and neither implies the other — a
        fresh literal can escape, and a parameter that never escapes was still not built here.
        `_classify_member_write` is where the two compose.
        """
        for ref in self.model.references(binding):
            if container_reference_role(ref) not in (
                ContainerRole.MEMBER_READ, ContainerRole.MEMBER_WRITE,
            ):
                return False
        return True

    def _confining_function(self, binding: Binding) -> Node | None:
        """
        The single function that lexically encloses every reference to *binding*, or `None` when the
        references do not share one — they span sibling functions or reach the top level. Cached per
        binding, since the binding's reference set is fixed for the lifetime of the model.
        """
        key = id(binding)
        if key not in self._confine_cache:
            self._confine_cache[key] = self._scan_confining_function(binding)
        return self._confine_cache[key]

    def _scan_confining_function(self, binding: Binding) -> Node | None:
        refs = self.model.references(binding)
        if not refs:
            return None
        enclosing = enclosing_function(refs[0])
        if enclosing is None:
            return None
        for ref in refs[1:]:
            if enclosing_function(ref) is not enclosing:
                return None
        return enclosing

    def _account_call(self, summary: EffectSummary, call: JsCallExpression | JsNewExpression):
        callee = self._resolve_callee(call)
        if callee is _PURE:
            return
        if isinstance(callee, Node):
            summary.absorb(self.summary_of(callee))
        else:
            summary.calls_unknown = True

    def _resolve_callee(self, call: JsCallExpression | JsNewExpression) -> Node | _PureCall | None:
        callee = call.callee
        if isinstance(call, JsNewExpression) and self._pure_construct(call):
            return _PURE
        if isinstance(callee, (JsFunctionExpression, JsArrowFunctionExpression)):
            return callee
        if isinstance(callee, JsMemberExpression) and not callee.computed:
            base, prop = callee.object, callee.property
            if isinstance(base, JsIdentifier) and isinstance(prop, JsIdentifier):
                if F'{base.name}.{prop.name}' in _PURE_INTRINSIC_METHODS and self._is_global_intrinsic(base):
                    return _PURE
            return None
        if isinstance(callee, JsIdentifier):
            if callee.name in _PURE_GLOBAL_FUNCTIONS and self._is_global_intrinsic(callee):
                return _PURE
            return self.unambiguous_function(self.model.resolve(callee))
        return None

    def _pure_construct(self, call: JsNewExpression) -> bool:
        """
        Whether `new <callee>(...)` is a pure allocation: the callee denotes a pristine constructor root in
        `_PURE_CONSTRUCTOR_ROOTS` and its arguments are safe for that root. `Array` — the only such root
        today — throws only on a bad single numeric length, decided by `_array_construct_is_pure`; a root
        added to the set needs its own argument rule wired in here rather than reusing Array's.

        Purity of the construction, not freshness of its result: those are separate questions, and this stays
        separate from `_fresh_kind` even though that predicate's `new Array(n)` form calls it. A construction
        can be impure and still yield a fresh object, so a caller wanting freshness must ask `_fresh_kind`.
        """
        root = self.intrinsic_of(call.callee)
        if not (isinstance(root, str) and root in _PURE_CONSTRUCTOR_ROOTS):
            return False
        return _array_construct_is_pure(call.arguments)

    def function_of(
        self, binding: Binding | None
    ) -> JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression | None:
        """
        The single function a *binding* stably resolves to — a sole declaration's function declaration or
        function/arrow initializer, or a name assigned a function exactly once (`f = function(){}`, the
        form namespace flattening leaves) — or `None` when the binding is absent, redeclared, reassigned
        to more than one value, dynamically rebindable, or not bound to a function. A lone assignment
        counts because the name denotes that one function wherever it is not in the value's temporal dead
        zone; a caller that also needs the value established before a use orders it separately. The
        binding-level twin of `static_callee`, and the function-typed specialization of
        `SemanticModel.singular_value`: it filters that value-resolution to a function node.
        """
        value = self.model.singular_value(binding)
        if isinstance(value, FUNCTION_NODES):
            return value
        return None

    def unambiguous_function(
        self, binding: Binding | None
    ) -> JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression | None:
        """
        The single function *binding* names for a consumer that resolves calls without execution ordering
        — the interpreter — or `None`. `function_of` narrowed to that ordering-free view: a pure function
        declaration, or a hoisted `var`/`let` assigned a function exactly once (`var f; f = function(){}`,
        the bare-assignment form namespace flattening leaves), qualifies; a name that already carried a
        value from its declaration — a function/class declaration, an initialized declarator, or a
        parameter — and is then reassigned holds two values across its life and is refused. This reproduces
        the filter the evaluator's visible-functions map applied before interpretation routed resolution
        through the model.
        """
        return _unambiguous_function(self.model, binding)

    def _is_global_intrinsic(self, name: JsIdentifier) -> bool:
        """
        Whether *name* denotes a trusted intrinsic root that the program leaves pristine and does not
        shadow with a local binding at this use site.
        """
        if not self.intrinsics_pristine:
            return False
        return self.model.lookup(name.name, self.model.scope_of(name)) is None

    def intrinsic_of(self, node: Node | None) -> str | _GlobalObject | None:
        """
        The pristine intrinsic value *node* provably denotes: `GLOBAL_OBJECT` for the global object, an
        intrinsic root name (`'Array'`, `'String'`, …) for a named intrinsic, or `None`. A name is
        returned only under `intrinsics_pristine` and where the identifier is unshadowed at this use site,
        so the result may be *value-trusted* — used to construct, to clear a getter-free static read, or
        to fold `A || B`. Every value it can return — `globalThis` and every `_PURE_INTRINSIC_ROOTS`
        member — is truthy, so `A || B` evaluates to `A` whenever `intrinsic_of(A)` is not `None`; a
        contributor extending this must preserve that truthiness invariant and never return a falsy name
        such as `NaN`/`undefined`.

        It deliberately does NOT follow a local alias through its value — `intrinsic_of` of an identifier
        bound to `var x = Array` is `None` — because a local's value holds only where it is established, a
        control-flow fact this flow-insensitive query cannot certify; a consumer that owns dominance
        resolves the alias itself against `singular_value`. It likewise does not treat `<global-object>.Name`
        as a value: that read's getter-freeness rests on `global_pristine`, a weaker premise than value
        trust, so it stays the concern of `_is_trusted_global_read`.
        """
        node = strip_parens(node)
        if isinstance(node, JsIdentifier):
            if node.name == 'globalThis' and self.model.lookup(node.name, self.model.scope_of(node)) is None:
                return GLOBAL_OBJECT
            if node.name in _PURE_INTRINSIC_ROOTS and self._is_global_intrinsic(node):
                return node.name
            return None
        if isinstance(node, JsLogicalExpression) and node.operator == '||':
            return self.intrinsic_of(node.left)
        return None

    def trusted_intrinsic(self, node: Node | None) -> str | None:
        """
        The global name *node* denotes, when that one name is provably still the built-in, or `None`. A
        name qualifies when the program never binds it, never assigns to it, never writes or updates a
        property anywhere on it, and exposes no reflection surface through which it could be replaced at
        runtime.

        This differs from `intrinsic_of` in *scope of the question*, not in strictness. `intrinsic_of`
        rests on `intrinsics_pristine`, one program-wide flag over a fixed root set, so a single
        `Object.prototype.x = 1` withdraws trust from every intrinsic in the file — including `Math`,
        which that line cannot affect. This query answers per name, so the same program still folds
        `Math.floor` while declining `Object.keys`. Its callers are the constant folds, which need to know
        whether *this* built-in is intact; `intrinsic_of` answers the stronger question of whether a value
        may be trusted for construction or `A || B` folding, and keeps its own callers and vocabulary.

        The name is not checked against a list of blessed intrinsics: whether a fold *knows how* to
        evaluate the call is the caller's question, answered by its own registry lookup. Conflating the two
        is what let a registry entry exist with no matching trust rule.

        Shadowing needs no per-site scope resolution, because a name bound *anywhere* in the program is
        already disqualified: `_globals_written` collects every binding in every scope. That is stricter
        than JavaScript requires — a shadow inside an unrelated function does not affect this use site —
        and deliberately so. A name the program binds at all is one an obfuscator may be routing values
        through, and the price of refusing is an unfolded call rather than a wrong value.
        """
        node = strip_parens(node)
        if not isinstance(node, JsIdentifier):
            return None
        if self.model.has_reflection_surface():
            return None
        if node.name in self._globals_written:
            return None
        return node.name

    def trusted_prototype(self, value_type: type) -> bool:
        """
        Whether the prototype supplying *value_type*'s methods is provably unmodified, so a method call on
        a receiver of that type still means what the language says. A method call on a literal receiver
        names no global, which is exactly why it needs its own question: `trusted_intrinsic` can only
        judge names the expression mentions, and `'ab'.toUpperCase()` mentions none.

        The owning intrinsic is looked up rather than assumed, and then asked the same per-name question a
        named callee gets — `String.prototype.toUpperCase = f` and
        `Object.defineProperty(Array.prototype, ...)` are both already recorded as writes to `String` and
        `Array` by `_global_writes_by_name`. A type with no known owner is never trusted.
        """
        owner = _PROTOTYPE_OWNERS.get(value_type.__name__)
        if owner is None:
            return False
        if self.model.has_reflection_surface():
            return False
        return owner not in self._globals_written

    def global_key_written(self, name: str, key: str) -> bool:
        """
        Whether the program writes the property *key* on a chain rooted at the global *name*:
        `Object.prototype.constructor = C`, `delete Object.getPrototypeOf`, or a descriptor
        installed for that key. The per-name question `_globals_written` answers is too coarse for a
        caller that cares which property was replaced — a file patching `Object.prototype.z` has
        written `Object`, and refusing everything about `Object` on that basis refuses the very
        files the question is asked about.

        The write is attributed to a *name* rather than to a receiver, which is what makes it
        answerable at all: the receiver of `Object.prototype.constructor = C` is a value no static
        analysis names, while the chain it is written through is rooted in one that is. A name whose
        written keys cannot be bounded — one the program binds, hands to code this analysis cannot
        read, writes a computed key on, or installs a descriptor on from a value it cannot read —
        answers `True` for every key, and so does a name outside `_KEYED_WRITE_ROOTS`, which the
        scan records nothing about at all.
        """
        if name not in _KEYED_WRITE_ROOTS:
            return True
        keys = self._global_keys_written.get(name, frozenset())
        return keys is None or key in keys

    def _roots_unwritten(self, owner: str, roots: frozenset[str]) -> bool:
        """
        Whether the program writes neither *owner* nor any prototype in *roots*. A property read
        resolves against the whole prototype chain rather than one prototype, so each name the chain
        passes through has to answer the same question `trusted_prototype` asks of the owner alone.
        """
        return all(name not in self._globals_written for name in (owner, *roots))

    def _prototypes_intact(self, owner: str, roots: frozenset[str]) -> bool:
        """
        `_roots_unwritten` with the reflection term, which is what separates the two questions
        `read_chain_intact` and `chain_roots_unwritten` ask. Neither is spelled out twice, so a
        term added to one chain question reaches both arms rather than only the one it was
        written into.
        """
        if self.model.has_reflection_surface():
            return False
        return self._roots_unwritten(owner, roots)

    def read_chain_intact(self, value_type: type) -> bool:
        """
        Whether every prototype a plain property read on a value of *value_type* consults is unmodified, so
        the read touches a data slot and runs nothing. Strictly stronger than `trusted_prototype`, which
        answers the neighbouring question for a method *call*, and the two must not be merged: a method
        resolves on the prototype that owns it, so `Array.prototype.join` shadows anything installed on
        `Object.prototype` and a patch there cannot change what `[1, 2].join('-')` means. A read of an
        arbitrary name has no such shadow — `Object.prototype` roots every chain, so a getter installed
        there is reached by a read on an array literal, on a primitive, and on `Math` alike.

        Confirmed against Node in both directions rather than reasoned from the specification: patching
        `Object.prototype.join` leaves `[1, 2].join('-')` intact, while patching `Object.prototype.zz`
        makes `[1, 2].zz` run a getter.

        Two separate facts have to hold and this is their conjunction: no chain root was written,
        which is `chain_roots_unwritten`, and no reflective surface could have written one without
        saying so. A caller for which the second costs more than it buys takes the first alone — see
        the note there for what that trade is and where it is made.
        """
        owner = _PROTOTYPE_OWNERS.get(value_type.__name__)
        if owner is None:
            return False
        return self._prototypes_intact(owner, _INHERITED_CHAIN_ROOTS)

    def chain_roots_unwritten(self, value_type: type) -> bool:
        """
        Whether the program writes no prototype a plain property read on a value of *value_type*
        consults. This is `read_chain_intact` without its reflection term, and the difference is not
        a weakening of the same question but a different one: `read_chain_intact` also refuses
        wherever `SemanticModel.has_reflection_surface` holds, and that predicate answers whether
        code could reference a global *by name* — true of `new Function('return this')`, which
        writes no prototype at all.

        The distinction is what the answer costs where it is wrong, and it decides who may ask this
        rather than being a judgement each caller makes for itself. A caller folding one expression
        pays an unfolded expression for refusing, so it may as well refuse under a surface, and
        every one of them asks `read_chain_intact`. A caller deciding whether a whole *pass* may run
        pays the pass, and a reflective surface is exactly what the real obfuscated files carry:
        measured on the samples this project tests against, the surface is present in the input and
        gone from the finished output, so a pass gated on it never runs and never clears the surface
        that was gating it. Only those callers ask this — today namespace flattening and the
        dispatcher unwrapper — and they accept that an unresolvable `eval` could in principle have
        written a prototype, which is no worse than the nothing they asked before.

        The two facts separate cleanly on the evidence rather than by assumption: every program in
        the defect ledger that reaches a wrong answer here writes a chain root and has no reflective
        surface, and every sample that has a surface writes no chain root.
        """
        owner = _PROTOTYPE_OWNERS.get(value_type.__name__)
        if owner is None:
            return False
        return self._roots_unwritten(owner, _INHERITED_CHAIN_ROOTS)

    def call_is_foldable(
        self,
        node: JsCallExpression,
        *,
        receiver_type: type | None = None,
    ) -> bool:
        """
        Whether the call *node* may be evaluated to a constant and have its result replace it. This is the
        one admission gate every fold shares, so that a rule proven necessary at one site cannot be missing
        at another — the fold surface acquired its divergent hand-rolled checks precisely because each
        transform owned its own.

        Three questions must all answer yes:

        - The callee is still the built-in it is spelled as. A named callee (`parseInt(...)`,
          `String.fromCharCode(...)`) is judged by `trusted_intrinsic` on its root name; a call on a
          literal receiver is judged by `trusted_prototype` on the type that literal's syntax fixes; and a
          call on another call — a chain link — is judged by asking this whole question of that inner call.
          `receiver_type` covers the remaining case, a caller holding an already-evaluated receiver whose
          type it knows and this model does not.
        - Every function-valued argument writes nothing outside itself. `is_effect_free_when_discarded` is
          the right predicate rather than `is_pure`: a callback that mutates a fresh local and returns it —
          the `reduce` accumulator idiom — is not pure, but that mutation is the value being computed and an
          evaluator that runs the call reproduces it. Purity is not sufficient on its own either, since a
          callback writing a script-scope `var` reports `writes_captured=False` — that binding is not
          captured from its perspective — so the write would be dropped while the value folds.
          `written_bindings` records it by identity and catches it.
        - Nothing in the argument list is itself a call this gate does not also clear, so admitting an outer
          call cannot smuggle in an inner one. A nested built-in (`Math.floor(Math.abs(-1.7))`) is fine
          precisely because the same questions are asked of it.
        - No part of the call stores anything the residual would go on to read. The fold deletes the
          whole expression, so a store is lost wherever in it the store was written — an argument, the
          receiver, or the computed key naming the method; see `_call_stores_nothing`.

        Trust is not evaluability, and this answers only the first. `trusted_intrinsic` says `unknownFn` is
        undisturbed — true, and no help, since no such built-in exists to fold. Whether a callee can
        actually be evaluated is the caller's question, answered by its own registry lookup before it asks
        this one; conflating the two is what let a registry entry exist with no matching trust rule.
        """
        if not self._callee_is_trusted(node, receiver_type):
            return False
        if not self._call_stores_nothing(node):
            return False
        return all(self._argument_is_admissible(arg) for arg in node.arguments)

    def _call_stores_nothing(self, node: JsCallExpression) -> bool:
        """
        Whether the parts of *node* that name what is being called store anything: the receiver it is
        called on, and the computed key that says which method. The fold deletes the whole call
        expression, so a store written into either is lost exactly as one written into an argument is,
        and `Math[v = 'floor'](1.9)` drops the write to `v` while answering `1`.

        A receiver that is itself a call is not asked here. It is a link in a chain, and
        `_callee_is_trusted` already puts it through this same gate in full, where its own receiver,
        key and arguments are each accounted for.
        """
        callee = strip_parens(node.callee)
        if not isinstance(callee, JsMemberExpression):
            return True
        base = strip_parens(callee.object)
        if not isinstance(base, JsCallExpression) and not self._stores_nothing(base):
            return False
        return not callee.computed or self._stores_nothing(callee.property)

    def _callee_is_trusted(self, node: JsCallExpression, receiver_type: type | None) -> bool:
        callee = strip_parens(node.callee)
        if isinstance(callee, JsIdentifier):
            return self.trusted_intrinsic(callee) is not None
        if not isinstance(callee, JsMemberExpression):
            return False
        base = strip_parens(callee.object)
        if isinstance(base, JsIdentifier):
            return self.trusted_intrinsic(base) is not None
        if isinstance(base, JsCallExpression):
            # A chained call (`Buffer.from(x).toString('hex')`, `[1, 2].map(f).join('')`) has no name at
            # this link. Its receiver is whatever the inner call returned, so it is trustworthy exactly
            # when that inner call is admissible in full — including its arguments, since an effectful
            # argument to the inner link is just as observable as one to the outer.
            return self.call_is_foldable(base)
        literal_type = _LITERAL_RECEIVER_TYPES.get(type(base))
        if literal_type is not None:
            return self.trusted_prototype(literal_type)
        if receiver_type is None:
            return False
        return self.trusted_prototype(receiver_type)

    def _argument_is_admissible(self, arg: Node | None) -> bool:
        """
        Whether *arg* may be evaluated as part of a fold that then replaces the whole call, deleting
        the argument's text along with it.

        A function-valued argument is judged by what calling it would do, because the call is what the
        fold performs. A call is judged by this same gate in full, so that admitting an outer call
        cannot smuggle in an inner one. Everything else is judged by whether evaluating it can be
        dropped at all, which is the question `is_side_effect_free` already answers over the whole
        subtree — and it is the argument's *subtree* that matters, because a store need not be the
        argument itself. `Math.floor(v = 4)` is only the plainest spelling of it; the same store hides
        in a summand, a comma operand, a template substitution, and a compound or logical assignment,
        each of which the fold would delete while the residual keeps reading the old value.
        """
        node = strip_parens(arg)
        if isinstance(node, FUNCTION_NODES):
            summary = self.summary_of(node)
            return summary.is_effect_free_when_discarded and not summary.written_bindings
        if isinstance(node, JsCallExpression):
            return self.call_is_foldable(node)
        return self._stores_nothing(node)

    def _stores_nothing(self, node: Node | None) -> bool:
        """
        Whether evaluating the expression *node* performs no store, so that a fold which replaces it
        with the value it computed loses nothing a later statement could read.

        A fold deletes the expression it replaces. Where that expression assigned, updated or deleted
        something, the store went with it and the residual program keeps reading the old value, which
        is what makes `Math.floor(v = 4)` answer `4` and leave `v` at `0`. The store is rarely the
        whole argument — it hides in a summand, a comma operand, a template substitution, a compound
        or a logical assignment — so the question is asked of the whole subtree and not of the shape
        at its root.

        A `yield` or an `await` is refused under the same heading: neither stores, but both hand
        control somewhere that can, and a fold that runs them decides when they resume.

        A function written down inside the expression stores nothing by being evaluated, since it is a
        value and only calling it could store; its body is therefore not walked.

        A call is refused when its callee can be named and that callee is known to write. It is
        admitted when the callee cannot be named, which is where this predicate stops being a proof:
        `s.charCodeAt(i)` on a parameter resolves to nothing this model can summarize and stores
        nothing, and refusing every unnameable call would decline the string decoders this tool exists
        to read. A `new` expression is admitted on the same terms. Admitting them is what the gate did
        for every call in this position before, so the rule only ever narrows what folds.
        """
        if node is None:
            return True
        pending: list[Node] = [node]
        while pending:
            current = pending.pop()
            if isinstance(current, FUNCTION_NODES):
                continue
            if isinstance(current, (JsAssignmentExpression, JsUpdateExpression)):
                return False
            if isinstance(current, JsUnaryExpression) and current.operator == 'delete':
                return False
            if isinstance(current, (JsYieldExpression, JsAwaitExpression)):
                return False
            if isinstance(current, JsCallExpression):
                callee = self.unambiguous_callee(current)
                if callee is not None and self.summary_of(callee).written_bindings:
                    return False
            pending.extend(current.children())
        return True

    def _is_trusted_global_read(self, member: JsMemberExpression) -> bool:
        """
        Whether reading *member* off the global object runs no user getter, so the read carries no
        observable effect: a non-computed access of a trusted intrinsic-named data property on the global
        object, sound only under the `global_pristine` precondition. This mirrors the intrinsic-call trust
        of `_resolve_callee`, lifted from methods to global data-property reads.
        """
        if not self.global_pristine or member.computed:
            return False
        prop = member.property
        if not isinstance(prop, JsIdentifier) or prop.name not in _GLOBAL_DATA_PROPERTIES:
            return False
        return member.object is not None and self._base_is_global_object(member.object)

    def _base_is_global_object(self, node: Node) -> bool:
        """
        Whether *node* denotes the global object itself: an unshadowed global-object alias identifier,
        always safe because the global object is never in a temporal dead zone. A local that only holds
        the global from an establishing definition is resolved separately by `_trusted_global_alias_read`,
        whose caller orders that definition before the read.
        """
        if isinstance(node, JsIdentifier) and node.name in GLOBAL_OBJECT_ALIASES:
            return self.model.lookup(node.name, self.model.scope_of(node)) is None
        return False

    def member_read_getter_free(
        self,
        member: JsMemberExpression,
        established: Callable[[Binding, JsMemberExpression], bool] | None = None,
    ) -> bool:
        """
        Whether reading *member* runs no user getter, so it carries no observable effect: a getter-free
        read off a pristine value (a fresh literal or a pristine intrinsic root) or a trusted global
        data-property read off a syntactic global-object alias — always, since neither is nullish — or off
        a local single-assigned to the global object, which holds it only from its establishing definition
        onward. The local case qualifies only when *established* confirms that definition reaches the read,
        an ordering this effect model cannot decide on its own (see
        `refinery.lib.scripts.js.analysis.reaching.ReachingModel.value_preserved`).
        """
        if self._getter_free_read(member):
            return True
        if established is None:
            return False
        binding = self._trusted_global_alias_read(member)
        return binding is not None and established(binding, member)

    def _trusted_global_alias_read(self, member: JsMemberExpression) -> Binding | None:
        """
        The local binding *member*'s base reads when *member* is a non-computed access of a trusted
        global data property through a single-assignment local whose value is provably the global object
        — a `globalThis` alias or a `globalThis || ...` guard — under `global_pristine`; `None`
        otherwise. The binding is returned rather than a verdict because whether it already holds the
        global where it is read is an ordering question for a layer that sees control flow.
        """
        if not self.global_pristine or member.computed:
            return None
        prop = member.property
        if not isinstance(prop, JsIdentifier) or prop.name not in _GLOBAL_DATA_PROPERTIES:
            return None
        base = member.object
        if not isinstance(base, JsIdentifier) or base.name in GLOBAL_OBJECT_ALIASES:
            return None
        binding = self.model.resolve(base)
        if binding is None or self.model.reflection_can_reach(binding):
            return None
        return binding if self._value_is_global_object(self.model.singular_value(binding)) else None

    def _value_is_global_object(self, node: Node | None) -> bool:
        """
        Whether *node*, the value a local is single-assigned, is provably the global object: the
        canonical `globalThis`, or a `globalThis || ...` existence guard whose truthy left is exactly it.
        A host alias that may be `undefined` is excluded, so a read through the local cannot throw on a
        nullish base.
        """
        return self.intrinsic_of(node) is GLOBAL_OBJECT

    def _base_is_safe(self, node: Node) -> bool:
        """
        Whether a property access on *node* cannot throw because *node* is known not to be nullish: a
        container literal whose access is plain, a primitive literal other than `null`, the global object, a
        pristine intrinsic root, or a never-rebound rest parameter. A rest parameter is bound to a fresh array
        at function entry, before any body statement runs — no temporal-dead-zone or hoisted-`undefined` window
        a flow-insensitive check could miss — so a member access on it is safe wherever it appears, provided the
        name is never reassigned to a value that could be nullish. A `var`/`let`/`const` local initialized to a
        literal is deliberately NOT admitted here: its initializer may not have run yet at the access
        (`function(){ a.x = 1; var a = []; }` throws), which this flow-insensitive predicate cannot rule out.
        The global object is recognized through `_base_is_global_object`, so an alias spelling a
        local declaration shadows names that local and is decided by the same rules as any other
        name: a shadowed `window` is the hoisted-`undefined` window this predicate refuses
        elsewhere.

        Non-nullishness is a *different* question from freshness and from getter-freeness, so this shares only
        the container-literal atom with its neighbours: a primitive qualifies here and is not fresh, while a
        fresh local qualifies as fresh and not here. It needs no prototype question either, unlike
        `_base_getter_safe`: no patch to any prototype can make `[1, 2]` nullish, and a throwing getter
        reached through a patched chain is that predicate's concern. There is no member-chain arm, because
        `root.a` may be `undefined` however safe *root* is, and the second read would then throw.
        """
        if container_literal_access_is_plain(node):
            return True
        if isinstance(node, (JsStringLiteral, JsNumericLiteral, JsBooleanLiteral)):
            return True
        if isinstance(node, JsIdentifier):
            if self._base_is_global_object(node):
                return True
            if isinstance(self.intrinsic_of(node), str):
                return True
            binding = self.model.resolve(node)
            return binding is not None and self._is_rest_param(binding) and not binding.writes
        return False

    def _base_getter_safe(self, node: Node) -> bool:
        """
        Whether reading a property of *node* cannot run a user-defined getter, so the read carries no
        hidden effect: a literal, or a pristine intrinsic root, whose entire prototype chain the program
        leaves alone. Unlike `_base_is_safe`, the global object does not qualify — a global property such as
        `location` may be an accessor — so a read through it is treated as an unknown call. Unlike
        freshness, a primitive and an intrinsic root do qualify: neither is a newly built container, and
        neither runs user code on a read of a pristine chain.

        Syntax alone settles the *type* of a literal base but not its behaviour, which is why every arm ends
        in `read_chain_intact` rather than returning on the node kind. A literal was previously cleared on
        syntax alone, and that deleted reads which really did run a getter installed on the corresponding
        prototype — Node-confirmed for array, object, string, boolean, function, and arrow bases, plus an
        intrinsic root reached through `Object.prototype`. A container literal must additionally declare no
        accessor of its own, the shared `container_literal_access_is_plain` question, since a getter written
        into the literal needs no prototype at all. There is no member-chain arm, for the reason given on
        `_base_is_safe`.
        """
        inner = strip_parens(node)
        value_type = _LITERAL_READ_TYPES.get(type(inner))
        if value_type is not None:
            if self._literal_declares_accessor(inner):
                return False
            return self.read_chain_intact(value_type)
        if isinstance(inner, JsIdentifier) and isinstance(self.intrinsic_of(inner), str):
            return self._prototypes_intact('Object', _INTRINSIC_CHAIN_ROOTS)
        return False

    def _literal_declares_accessor(self, node: Node) -> bool:
        """
        Whether *node* is a container literal that declares an accessor of its own, so a member access on it
        runs user code with no prototype involved. Separate from the chain question because it needs no
        model: the getter is written into the expression.
        """
        if not isinstance(node, (JsObjectExpression, JsArrayExpression)):
            return False
        return not container_literal_access_is_plain(node)

    def _getter_free_read(self, member: JsMemberExpression) -> bool:
        """
        Whether reading *member* runs no user getter and cannot fire a poison-pill accessor: the base is a
        getter-safe value (a fresh literal or a pristine intrinsic root) or a trusted global-object data
        property, and the property is not one of the poison-pill names whose read may throw or run an
        `Object.prototype` accessor. This is the single getter-freeness gate the summary scan and
        `is_side_effect_free` share.
        """
        if _is_poison_pill_property(member):
            return False
        if member.object is not None and self._base_getter_safe(member.object):
            return True
        return self._is_trusted_global_read(member)

Methods

def summary_of(self, func)

The effect summary of a function node (or the script). An unknown node is reported as impure.

Expand source code Browse git
def summary_of(self, func: Node) -> EffectSummary:
    """
    The effect summary of a function node (or the script). An unknown node is reported as impure.
    """
    return self._summaries.get(id(func), EffectSummary(calls_unknown=True))
def mutated_bindings(self, func)

The outer bindings (captured locals and globals) a call to func may write, directly or through any function it transitively calls, each identified by its Binding rather than its name so a caller can ask whether one specific binding is mutated. Empty for a function with no such writes and for an unknown node alike — use summary_of(func).calls_unknown to tell those apart.

Expand source code Browse git
def mutated_bindings(self, func: Node) -> frozenset[Binding]:
    """
    The outer bindings (captured locals and globals) a call to *func* may write, directly or through
    any function it transitively calls, each identified by its `Binding` rather than its name so a
    caller can ask whether one specific binding is mutated. Empty for a function with no such writes
    and for an unknown node alike — use `summary_of(func).calls_unknown` to tell those apart.
    """
    return frozenset(self.summary_of(func).written_bindings)
def function_can_mutate(self, func, binding)

Whether a call to func may write binding, itself or through a transitive callee.

Expand source code Browse git
def function_can_mutate(self, func: Node, binding: Binding) -> bool:
    """
    Whether a call to *func* may write *binding*, itself or through a transitive callee.
    """
    return binding in self.summary_of(func).written_bindings
def function_escapes(self, func)

Whether func may be invoked at a point the surrounding scope cannot enumerate as a resolvable name(…) call site: an anonymous function (an IIFE, a callback, stored and called later), or a named function whose binding is reassigned, redeclared, or referenced anywhere other than as the callee of a direct call (aliased, passed as an argument, f.call(…)). A reference inside a dynamic scope — a name a with body resolves at runtime — counts too: the model cannot order or resolve it, so the function may be invoked or aliased there with no static call site. A call to such a function can land at a point no call site pins down; a function only ever called directly by name has all its invocations enumerated by those call sites.

Expand source code Browse git
def function_escapes(self, func: Node) -> bool:
    """
    Whether *func* may be invoked at a point the surrounding scope cannot enumerate as a resolvable
    `name(...)` call site: an anonymous function (an IIFE, a callback, stored and called later), or a
    named function whose binding is reassigned, redeclared, or referenced anywhere other than as the
    callee of a direct call (aliased, passed as an argument, `f.call(...)`). A reference inside a
    dynamic scope — a name a `with` body resolves at runtime — counts too: the model cannot order or
    resolve it, so the function may be invoked or aliased there with no static call site. A call to
    such a function can land at a point no call site pins down; a function only ever called directly
    by name has all its invocations enumerated by those call sites.
    """
    binding = self.model.naming_binding(func)
    if binding is None:
        return True
    if binding.writes or binding.dynamic_refs or len(binding.declarations) != 1:
        return True
    for ref in self.model.references(binding):
        parent = ref.parent
        if isinstance(parent, JsCallExpression) and parent.callee is ref:
            continue
        return True
    return False
def mutators_escape(self, binding)

Whether some function that may write binding — itself or through a transitive callee — escapes (function_escapes), so a write to binding may occur at a point no call site enumerates. When true, the places binding changes cannot be pinned down, and a caller reasoning about where its value survives must treat it as volatile everywhere. Memoized per binding.

Expand source code Browse git
def mutators_escape(self, binding: Binding) -> bool:
    """
    Whether some function that may write *binding* — itself or through a transitive callee — escapes
    (`function_escapes`), so a write to *binding* may occur at a point no call site enumerates. When
    true, the places *binding* changes cannot be pinned down, and a caller reasoning about where its
    value survives must treat it as volatile everywhere. Memoized per binding.
    """
    cached = self._mutators_escape_cache.get(id(binding))
    if cached is None:
        cached = any(
            func is not self.model.root
            and binding in self.summary_of(func).written_bindings
            and self.function_escapes(func)
            for func in self._functions
        )
        self._mutators_escape_cache[id(binding)] = cached
    return cached
def some_function_can_mutate(self, binding)

Whether any function this file writes may write binding, itself or through a transitive callee. The answer a caller needs where a call runs a function it cannot name: not knowing which one runs, it has to reckon with every one that could. Memoized per binding.

Expand source code Browse git
def some_function_can_mutate(self, binding: Binding) -> bool:
    """
    Whether any function this file writes may write *binding*, itself or through a transitive
    callee. The answer a caller needs where a call runs a function it cannot name: not knowing
    which one runs, it has to reckon with every one that could. Memoized per binding.
    """
    cached = self._some_mutator_cache.get(id(binding))
    if cached is None:
        cached = any(
            func is not self.model.root and self.function_can_mutate(func, binding)
            for func in self._functions
        )
        self._some_mutator_cache[id(binding)] = cached
    return cached
def is_pure_call(self, call)

Whether evaluating call has no observable effect: it invokes a trusted pure intrinsic (under the pristine-intrinsics precondition) or a local function whose summary is pure.

Expand source code Browse git
def is_pure_call(self, call: JsCallExpression | JsNewExpression) -> bool:
    """
    Whether evaluating *call* has no observable effect: it invokes a trusted pure intrinsic (under
    the pristine-intrinsics precondition) or a local function whose summary is pure.
    """
    callee = self._resolve_callee(call)
    if callee is _PURE:
        return True
    if isinstance(callee, Node):
        return self.summary_of(callee).is_pure
    return False
def is_pure_call_discarded(self, call)

Whether evaluating call and discarding its result has no observable effect. Like is_pure_call but resolved through EffectSummary.is_effect_free_when_discarded, so a callee whose only residual effect is a write it confines to its returned value qualifies — that write is unobservable once the result is thrown away. A caller may use this only in a position it has proven discards the value.

Expand source code Browse git
def is_pure_call_discarded(self, call: JsCallExpression | JsNewExpression) -> bool:
    """
    Whether evaluating *call* and discarding its result has no observable effect. Like `is_pure_call`
    but resolved through `EffectSummary.is_effect_free_when_discarded`, so a callee whose only residual
    effect is a write it confines to its returned value qualifies — that write is unobservable once the
    result is thrown away. A caller may use this only in a position it has proven discards the value.
    """
    callee = self._resolve_callee(call)
    if callee is _PURE:
        return True
    if isinstance(callee, Node):
        return self.summary_of(callee).is_effect_free_when_discarded
    return False
def call_clearable(self, call, callee_established)

Whether call's callee is established — in place before the call runs — given callee_established, the caller's test for a resolved named local callee. A trusted pure intrinsic and an inline function-expression callee (defined at the call site, hence always in place) qualify unconditionally; a call resolving to a single named local function qualifies when callee_established accepts it; an unresolved or ambiguous callee does not. The resolution, the intrinsic case, and the inline-callee case live here so callers supply only the ordering judgment their layer can make. This certifies establishment ONLY, not purity — a caller deciding whether a call may be dropped must conjoin it with is_pure_call, as side_effect_free() does, since an established callee may still run an effectful body.

Expand source code Browse git
def call_clearable(
    self,
    call: JsCallExpression | JsNewExpression,
    callee_established: Callable[[Node], bool],
) -> bool:
    """
    Whether *call*'s callee is established — in place before the call runs — given *callee_established*,
    the caller's test for a resolved named local callee. A trusted pure intrinsic and an inline
    function-expression callee (defined at the call site, hence always in place) qualify
    unconditionally; a call resolving to a single named local function qualifies when
    *callee_established* accepts it; an unresolved or ambiguous callee does not. The resolution, the
    intrinsic case, and the inline-callee case live here so callers supply only the ordering judgment
    their layer can make. This certifies establishment ONLY, not purity — a caller deciding whether a
    call may be dropped must conjoin it with `is_pure_call`, as `side_effect_free` does, since an
    established callee may still run an effectful body.
    """
    resolved = self._resolve_callee(call)
    if resolved is _PURE:
        return True
    if isinstance(resolved, Node):
        if isinstance(strip_parens(call.callee), (JsFunctionExpression, JsArrowFunctionExpression)):
            return True
        return callee_established(resolved)
    return False
def is_side_effect_free(self, node, defunct=None, member_safe=None, call_established=None, discarded=False)

Whether evaluating node can be dropped or reordered without an observable side effect, with the call leaf resolved through this model's is_pure_call: a call to a proven-pure function or trusted intrinsic is free, recursing into its arguments. defunct names bindings being removed, whose calls and property reads are treated as free. This is the model-aware form of the model-free side_effect_free() in this module, which clears only calls to a defunct name; unlike it, an identifier read that resolves through a with body's dynamic scope is rejected here — reading the bare name may fire the with object's getter or throw (see SemanticModel.read_has_dynamic_effect()) — while a function value whose body performs such a read stays free, since defining it runs nothing. A caller with control-flow context passes member_safe to also clear a getter-free read through a local global-object alias it can prove established before the read; the default clears only the syntactic global case (_is_trusted_global_read).

With discarded the caller asserts node's own value is thrown away, so a top-level call leaf is cleared through is_pure_call_discarded and a callee that only mutates a local it returns is droppable — the removal contexts of JsUnusedCodeRemoval supply it.

Expand source code Browse git
def is_side_effect_free(
    self,
    node: Node,
    defunct: set[str] | None = None,
    member_safe: Callable[[JsMemberExpression], bool] | None = None,
    call_established: Callable[[JsCallExpression | JsNewExpression], bool] | None = None,
    discarded: bool = False,
) -> bool:
    """
    Whether evaluating *node* can be dropped or reordered without an observable side effect, with
    the call leaf resolved through this model's `is_pure_call`: a call to a proven-pure function or
    trusted intrinsic is free, recursing into its arguments. *defunct* names bindings being removed,
    whose calls and property reads are treated as free. This is the model-aware form of the
    model-free `side_effect_free` in this module, which clears only calls to a defunct name; unlike
    it, an identifier read that resolves through a `with` body's dynamic
    scope is rejected here — reading the bare name may fire the `with` object's getter or throw (see
    `refinery.lib.scripts.js.analysis.model.SemanticModel.read_has_dynamic_effect`) — while a
    function value whose body performs such a read stays free, since defining it runs nothing. A
    caller with control-flow context passes *member_safe* to also clear a getter-free read through a
    local global-object alias it can prove established before the read; the default clears only the
    syntactic global case (`_is_trusted_global_read`).

    With *discarded* the caller asserts *node*'s own value is thrown away, so a top-level call leaf is
    cleared through `is_pure_call_discarded` and a callee that only mutates a local it returns is
    droppable — the removal contexts of `JsUnusedCodeRemoval` supply it.
    """
    return side_effect_free(
        node,
        defunct,
        self.is_pure_call,
        self.model.read_has_dynamic_effect,
        member_safe or self._getter_free_read,
        call_established or self._established_call_default,
        discarded,
        self.is_pure_call_discarded,
    )
def binding_is_immutable_container(self, binding, *, member_calls_mutate=True, exclude=None)

Whether binding holds a container — an object or array — whose element and property values are stable after construction, so that an access into it may be soundly inlined at its read sites. Every reference must read through the container (obj.k, obj[i]) or plainly rebind the name (obj = ..., whose value the caller resolves by domination); a write through the container (obj.k = v, obj[i]++, delete obj[i], a for-of or destructuring target) makes it mutable. A method invoked on the container (obj.m(…)) may mutate it — an array's sort/push/splice and so on — so by default it too counts as mutable; a caller that knows the container's methods cannot mutate it (an object literal with no this-bound property) may pass member_calls_mutate false to permit such calls. A reference that escapes is safe in two cases: it aliases another binding that is itself an immutable container (alias-following the textual predicates this replaces could not do, and the reason a reassigned-and-aliased lookup array stays inlinable), or it is passed to a statically known function as an argument whose parameter is itself an immutable container (so the callee neither mutates nor further-escapes it). Any other escape — returned, stored as a property, passed to a call that cannot be resolved — is treated conservatively as mutable. A mutation through a dynamic scope is modelled: a with body that names the container — a member write, method call, reassignment, or escape — is attributed to it as a dynamic reference and judged by the same role logic, so a with that never names it keeps it foldable, and a direct eval in a local container's own function makes it mutable. The one residual is a script-scope container reached by an opaque global surface — a direct eval, Function, timer, or dynamic global write whose code cannot be read — which cannot be frozen without also freezing the lookup arrays real samples fold, so it is left to the caller's reflection reasoning, the trust an unresolved external call already receives.

The query is over a resolved binding, so it is shadowing-correct, and it descends through alias chains, callee parameters, and nested functions, so a capturing closure that mutates the container is caught. The answer is fixed for the model's lifetime — a binding's reference set does not change — so it is memoized per (binding, member_calls_mutate). A caller may pass exclude to disregard references within that subtree — asking whether the container is stable across the rest of the program, ignoring a read site about to be relocated into it; such a query is not memoized, since the answer depends on the excluded region.

Expand source code Browse git
def binding_is_immutable_container(
    self, binding: Binding, *, member_calls_mutate: bool = True, exclude: Node | None = None,
) -> bool:
    """
    Whether *binding* holds a container — an object or array — whose element and property values are
    stable after construction, so that an access into it may be soundly inlined at its read sites.
    Every reference must read through the container (`obj.k`, `obj[i]`) or plainly rebind the name
    (`obj = ...`, whose value the caller resolves by domination); a write through the container
    (`obj.k = v`, `obj[i]++`, `delete obj[i]`, a `for-of` or destructuring target) makes it mutable.
    A method invoked on the container (`obj.m(...)`) may mutate it — an array's `sort`/`push`/`splice`
    and so on — so by default it too counts as mutable; a caller that knows the container's methods
    cannot mutate it (an object literal with no `this`-bound property) may pass *member_calls_mutate*
    false to permit such calls. A reference that escapes is safe in two cases: it aliases another
    binding that is itself an immutable container (alias-following the textual predicates this
    replaces could not do, and the reason a reassigned-and-aliased lookup array stays inlinable), or
    it is passed to a statically known function as an argument whose parameter is itself an immutable
    container (so the callee neither mutates nor further-escapes it). Any other escape — returned,
    stored as a property, passed to a call that cannot be resolved — is treated conservatively as
    mutable. A mutation through a dynamic scope is modelled: a `with` body that names the container —
    a member write, method call, reassignment, or escape — is attributed to it as a dynamic reference
    and judged by the same role logic, so a `with` that never names it keeps it foldable, and a direct
    `eval` in a local container's own function makes it mutable. The one residual is a script-scope
    container reached by an opaque global surface — a direct `eval`, `Function`, timer, or dynamic
    global write whose code cannot be read — which cannot be frozen without also freezing the lookup
    arrays real samples fold, so it is left to the caller's reflection reasoning, the trust an
    unresolved external call already receives.

    The query is over a *resolved binding*, so it is shadowing-correct, and it descends through
    alias chains, callee parameters, and nested functions, so a capturing closure that mutates the
    container is caught. The answer is fixed for the model's lifetime — a binding's reference set does
    not change — so it is memoized per `(binding, member_calls_mutate)`. A caller may pass *exclude*
    to disregard references within that subtree — asking whether the container is stable across the
    rest of the program, ignoring a read site about to be relocated into it; such a query is not
    memoized, since the answer depends on the excluded region.
    """
    if exclude is not None:
        return self._immutable_container(binding, set(), member_calls_mutate, exclude)
    key = (id(binding), member_calls_mutate)
    cached = self._immutable_cache.get(key)
    if cached is None:
        cached = self._immutable_container(binding, set(), member_calls_mutate)
        self._immutable_cache[key] = cached
    return cached
def static_callee(self, call)

The function a call invokes, resolved permissively through function_of: a direct function or arrow expression callee, or an identifier bound to a single function — a declaration, a var/let/const initializer, or the value a name is assigned exactly once. For a name that held a value and was then reassigned this returns the post-reassignment value, which is the running target only where that reassignment is established before the call; a consumer that cannot order the reassignment against the call must use unambiguous_callee instead. None for a method call, a parameter, a redeclared or dynamically-rebindable binding, or an unresolved name.

Expand source code Browse git
def static_callee(
    self, call: JsCallExpression
) -> JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression | None:
    """
    The function a call invokes, resolved permissively through `function_of`: a direct function or
    arrow expression callee, or an identifier bound to a single function — a declaration, a
    `var`/`let`/`const` initializer, or the value a name is assigned exactly once. For a name that
    held a value and was then reassigned this returns the post-reassignment value, which is the
    running target only where that reassignment is established before the call; a consumer that
    cannot order the reassignment against the call must use `unambiguous_callee` instead. `None` for
    a method call, a parameter, a redeclared or dynamically-rebindable binding, or an unresolved name.
    """
    callee = call.callee
    if isinstance(callee, (JsFunctionExpression, JsArrowFunctionExpression)):
        return callee
    if not isinstance(callee, JsIdentifier):
        return None
    return self.function_of(self.model.resolve(callee))
def a_name_this_file_binds_holds_the_callee(self, call)

Whether call names its callee with an identifier this file binds, while static_callee declines to say which function that binding holds.

A caller reasoning about what a call may have done reads static_callee answering None in two ways, and this tells them apart. Where the callee is a method, a host function, or a name nothing here declares, None means the call runs something outside this file's reckoning, which is a standing condition every such caller was written under. Where it is a name this file binds, None means the model saw the binding and would not state its value - one Annex B copies into a block's enclosing scope is such a function - and what it runs may be any of the ones written here, one that writes the very binding being reasoned about included.

Expand source code Browse git
def a_name_this_file_binds_holds_the_callee(self, call: JsCallExpression) -> bool:
    """
    Whether *call* names its callee with an identifier this file binds, while `static_callee`
    declines to say which function that binding holds.

    A caller reasoning about what a call may have done reads `static_callee` answering `None` in
    two ways, and this tells them apart. Where the callee is a method, a host function, or a
    name nothing here declares, `None` means the call runs something outside this file's
    reckoning, which is a standing condition every such caller was written under. Where it is a
    name this file binds, `None` means the model saw the binding and would not state its value
    - one Annex B copies into a block's enclosing scope is such a function - and what it runs
    may be any of the ones written here, one that writes the very binding being reasoned about
    included.
    """
    callee = strip_parens(call.callee)
    if not isinstance(callee, JsIdentifier):
        return False
    if self.model.resolve(callee) is None:
        return False
    return self.static_callee(call) is None
def unambiguous_callee(self, call)

The ordering-free twin of static_callee, for a consumer that reasons about a call without knowing where it sits in execution order. Identical except an identifier callee resolves through unambiguous_function, so a name that held a value and was then reassigned — whose running target depends on the call's position relative to the reassignment — yields None rather than the post-reassignment value.

Expand source code Browse git
def unambiguous_callee(
    self, call: JsCallExpression
) -> JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression | None:
    """
    The ordering-free twin of `static_callee`, for a consumer that reasons about a call without
    knowing where it sits in execution order. Identical except an identifier callee resolves through
    `unambiguous_function`, so a name that held a value and was then reassigned — whose running target
    depends on the call's position relative to the reassignment — yields `None` rather than the
    post-reassignment value.
    """
    return _unambiguous_callee(self.model, call)
def function_of(self, binding)

The single function a binding stably resolves to — a sole declaration's function declaration or function/arrow initializer, or a name assigned a function exactly once (f = function(){}, the form namespace flattening leaves) — or None when the binding is absent, redeclared, reassigned to more than one value, dynamically rebindable, or not bound to a function. A lone assignment counts because the name denotes that one function wherever it is not in the value's temporal dead zone; a caller that also needs the value established before a use orders it separately. The binding-level twin of static_callee, and the function-typed specialization of SemanticModel.singular_value: it filters that value-resolution to a function node.

Expand source code Browse git
def function_of(
    self, binding: Binding | None
) -> JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression | None:
    """
    The single function a *binding* stably resolves to — a sole declaration's function declaration or
    function/arrow initializer, or a name assigned a function exactly once (`f = function(){}`, the
    form namespace flattening leaves) — or `None` when the binding is absent, redeclared, reassigned
    to more than one value, dynamically rebindable, or not bound to a function. A lone assignment
    counts because the name denotes that one function wherever it is not in the value's temporal dead
    zone; a caller that also needs the value established before a use orders it separately. The
    binding-level twin of `static_callee`, and the function-typed specialization of
    `SemanticModel.singular_value`: it filters that value-resolution to a function node.
    """
    value = self.model.singular_value(binding)
    if isinstance(value, FUNCTION_NODES):
        return value
    return None
def unambiguous_function(self, binding)

The single function binding names for a consumer that resolves calls without execution ordering — the interpreter — or None. function_of narrowed to that ordering-free view: a pure function declaration, or a hoisted var/let assigned a function exactly once (var f; f = function(){}, the bare-assignment form namespace flattening leaves), qualifies; a name that already carried a value from its declaration — a function/class declaration, an initialized declarator, or a parameter — and is then reassigned holds two values across its life and is refused. This reproduces the filter the evaluator's visible-functions map applied before interpretation routed resolution through the model.

Expand source code Browse git
def unambiguous_function(
    self, binding: Binding | None
) -> JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression | None:
    """
    The single function *binding* names for a consumer that resolves calls without execution ordering
    — the interpreter — or `None`. `function_of` narrowed to that ordering-free view: a pure function
    declaration, or a hoisted `var`/`let` assigned a function exactly once (`var f; f = function(){}`,
    the bare-assignment form namespace flattening leaves), qualifies; a name that already carried a
    value from its declaration — a function/class declaration, an initialized declarator, or a
    parameter — and is then reassigned holds two values across its life and is refused. This reproduces
    the filter the evaluator's visible-functions map applied before interpretation routed resolution
    through the model.
    """
    return _unambiguous_function(self.model, binding)
def intrinsic_of(self, node)

The pristine intrinsic value node provably denotes: GLOBAL_OBJECT for the global object, an intrinsic root name ('Array', 'String', …) for a named intrinsic, or None. A name is returned only under intrinsics_pristine and where the identifier is unshadowed at this use site, so the result may be value-trusted — used to construct, to clear a getter-free static read, or to fold A || B. Every value it can return — globalThis and every _PURE_INTRINSIC_ROOTS member — is truthy, so A || B evaluates to A whenever intrinsic_of(A) is not None; a contributor extending this must preserve that truthiness invariant and never return a falsy name such as NaN/undefined.

It deliberately does NOT follow a local alias through its value — intrinsic_of of an identifier bound to var x = Array is None — because a local's value holds only where it is established, a control-flow fact this flow-insensitive query cannot certify; a consumer that owns dominance resolves the alias itself against singular_value. It likewise does not treat <global-object>.Name as a value: that read's getter-freeness rests on global_pristine, a weaker premise than value trust, so it stays the concern of _is_trusted_global_read.

Expand source code Browse git
def intrinsic_of(self, node: Node | None) -> str | _GlobalObject | None:
    """
    The pristine intrinsic value *node* provably denotes: `GLOBAL_OBJECT` for the global object, an
    intrinsic root name (`'Array'`, `'String'`, …) for a named intrinsic, or `None`. A name is
    returned only under `intrinsics_pristine` and where the identifier is unshadowed at this use site,
    so the result may be *value-trusted* — used to construct, to clear a getter-free static read, or
    to fold `A || B`. Every value it can return — `globalThis` and every `_PURE_INTRINSIC_ROOTS`
    member — is truthy, so `A || B` evaluates to `A` whenever `intrinsic_of(A)` is not `None`; a
    contributor extending this must preserve that truthiness invariant and never return a falsy name
    such as `NaN`/`undefined`.

    It deliberately does NOT follow a local alias through its value — `intrinsic_of` of an identifier
    bound to `var x = Array` is `None` — because a local's value holds only where it is established, a
    control-flow fact this flow-insensitive query cannot certify; a consumer that owns dominance
    resolves the alias itself against `singular_value`. It likewise does not treat `<global-object>.Name`
    as a value: that read's getter-freeness rests on `global_pristine`, a weaker premise than value
    trust, so it stays the concern of `_is_trusted_global_read`.
    """
    node = strip_parens(node)
    if isinstance(node, JsIdentifier):
        if node.name == 'globalThis' and self.model.lookup(node.name, self.model.scope_of(node)) is None:
            return GLOBAL_OBJECT
        if node.name in _PURE_INTRINSIC_ROOTS and self._is_global_intrinsic(node):
            return node.name
        return None
    if isinstance(node, JsLogicalExpression) and node.operator == '||':
        return self.intrinsic_of(node.left)
    return None
def trusted_intrinsic(self, node)

The global name node denotes, when that one name is provably still the built-in, or None. A name qualifies when the program never binds it, never assigns to it, never writes or updates a property anywhere on it, and exposes no reflection surface through which it could be replaced at runtime.

This differs from intrinsic_of in scope of the question, not in strictness. intrinsic_of rests on intrinsics_pristine, one program-wide flag over a fixed root set, so a single Object.prototype.x = 1 withdraws trust from every intrinsic in the file — including Math, which that line cannot affect. This query answers per name, so the same program still folds Math.floor while declining Object.keys. Its callers are the constant folds, which need to know whether this built-in is intact; intrinsic_of answers the stronger question of whether a value may be trusted for construction or A || B folding, and keeps its own callers and vocabulary.

The name is not checked against a list of blessed intrinsics: whether a fold knows how to evaluate the call is the caller's question, answered by its own registry lookup. Conflating the two is what let a registry entry exist with no matching trust rule.

Shadowing needs no per-site scope resolution, because a name bound anywhere in the program is already disqualified: _globals_written collects every binding in every scope. That is stricter than JavaScript requires — a shadow inside an unrelated function does not affect this use site — and deliberately so. A name the program binds at all is one an obfuscator may be routing values through, and the price of refusing is an unfolded call rather than a wrong value.

Expand source code Browse git
def trusted_intrinsic(self, node: Node | None) -> str | None:
    """
    The global name *node* denotes, when that one name is provably still the built-in, or `None`. A
    name qualifies when the program never binds it, never assigns to it, never writes or updates a
    property anywhere on it, and exposes no reflection surface through which it could be replaced at
    runtime.

    This differs from `intrinsic_of` in *scope of the question*, not in strictness. `intrinsic_of`
    rests on `intrinsics_pristine`, one program-wide flag over a fixed root set, so a single
    `Object.prototype.x = 1` withdraws trust from every intrinsic in the file — including `Math`,
    which that line cannot affect. This query answers per name, so the same program still folds
    `Math.floor` while declining `Object.keys`. Its callers are the constant folds, which need to know
    whether *this* built-in is intact; `intrinsic_of` answers the stronger question of whether a value
    may be trusted for construction or `A || B` folding, and keeps its own callers and vocabulary.

    The name is not checked against a list of blessed intrinsics: whether a fold *knows how* to
    evaluate the call is the caller's question, answered by its own registry lookup. Conflating the two
    is what let a registry entry exist with no matching trust rule.

    Shadowing needs no per-site scope resolution, because a name bound *anywhere* in the program is
    already disqualified: `_globals_written` collects every binding in every scope. That is stricter
    than JavaScript requires — a shadow inside an unrelated function does not affect this use site —
    and deliberately so. A name the program binds at all is one an obfuscator may be routing values
    through, and the price of refusing is an unfolded call rather than a wrong value.
    """
    node = strip_parens(node)
    if not isinstance(node, JsIdentifier):
        return None
    if self.model.has_reflection_surface():
        return None
    if node.name in self._globals_written:
        return None
    return node.name
def trusted_prototype(self, value_type)

Whether the prototype supplying value_type's methods is provably unmodified, so a method call on a receiver of that type still means what the language says. A method call on a literal receiver names no global, which is exactly why it needs its own question: trusted_intrinsic can only judge names the expression mentions, and 'ab'.toUpperCase() mentions none.

The owning intrinsic is looked up rather than assumed, and then asked the same per-name question a named callee gets — String.prototype.toUpperCase = f and Object.defineProperty(Array.prototype, …) are both already recorded as writes to String and Array by _global_writes_by_name. A type with no known owner is never trusted.

Expand source code Browse git
def trusted_prototype(self, value_type: type) -> bool:
    """
    Whether the prototype supplying *value_type*'s methods is provably unmodified, so a method call on
    a receiver of that type still means what the language says. A method call on a literal receiver
    names no global, which is exactly why it needs its own question: `trusted_intrinsic` can only
    judge names the expression mentions, and `'ab'.toUpperCase()` mentions none.

    The owning intrinsic is looked up rather than assumed, and then asked the same per-name question a
    named callee gets — `String.prototype.toUpperCase = f` and
    `Object.defineProperty(Array.prototype, ...)` are both already recorded as writes to `String` and
    `Array` by `_global_writes_by_name`. A type with no known owner is never trusted.
    """
    owner = _PROTOTYPE_OWNERS.get(value_type.__name__)
    if owner is None:
        return False
    if self.model.has_reflection_surface():
        return False
    return owner not in self._globals_written
def global_key_written(self, name, key)

Whether the program writes the property key on a chain rooted at the global name: Object.prototype.constructor = C, delete Object.getPrototypeOf, or a descriptor installed for that key. The per-name question _globals_written answers is too coarse for a caller that cares which property was replaced — a file patching Object.prototype.z has written Object, and refusing everything about Object on that basis refuses the very files the question is asked about.

The write is attributed to a name rather than to a receiver, which is what makes it answerable at all: the receiver of Object.prototype.constructor = C is a value no static analysis names, while the chain it is written through is rooted in one that is. A name whose written keys cannot be bounded — one the program binds, hands to code this analysis cannot read, writes a computed key on, or installs a descriptor on from a value it cannot read — answers True for every key, and so does a name outside _KEYED_WRITE_ROOTS, which the scan records nothing about at all.

Expand source code Browse git
def global_key_written(self, name: str, key: str) -> bool:
    """
    Whether the program writes the property *key* on a chain rooted at the global *name*:
    `Object.prototype.constructor = C`, `delete Object.getPrototypeOf`, or a descriptor
    installed for that key. The per-name question `_globals_written` answers is too coarse for a
    caller that cares which property was replaced — a file patching `Object.prototype.z` has
    written `Object`, and refusing everything about `Object` on that basis refuses the very
    files the question is asked about.

    The write is attributed to a *name* rather than to a receiver, which is what makes it
    answerable at all: the receiver of `Object.prototype.constructor = C` is a value no static
    analysis names, while the chain it is written through is rooted in one that is. A name whose
    written keys cannot be bounded — one the program binds, hands to code this analysis cannot
    read, writes a computed key on, or installs a descriptor on from a value it cannot read —
    answers `True` for every key, and so does a name outside `_KEYED_WRITE_ROOTS`, which the
    scan records nothing about at all.
    """
    if name not in _KEYED_WRITE_ROOTS:
        return True
    keys = self._global_keys_written.get(name, frozenset())
    return keys is None or key in keys
def read_chain_intact(self, value_type)

Whether every prototype a plain property read on a value of value_type consults is unmodified, so the read touches a data slot and runs nothing. Strictly stronger than trusted_prototype, which answers the neighbouring question for a method call, and the two must not be merged: a method resolves on the prototype that owns it, so Array.prototype.join shadows anything installed on Object.prototype and a patch there cannot change what [1, 2].join('-') means. A read of an arbitrary name has no such shadow — Object.prototype roots every chain, so a getter installed there is reached by a read on an array literal, on a primitive, and on Math alike.

Confirmed against Node in both directions rather than reasoned from the specification: patching Object.prototype.join leaves [1, 2].join('-') intact, while patching Object.prototype.zz makes [1, 2].zz run a getter.

Two separate facts have to hold and this is their conjunction: no chain root was written, which is chain_roots_unwritten, and no reflective surface could have written one without saying so. A caller for which the second costs more than it buys takes the first alone — see the note there for what that trade is and where it is made.

Expand source code Browse git
def read_chain_intact(self, value_type: type) -> bool:
    """
    Whether every prototype a plain property read on a value of *value_type* consults is unmodified, so
    the read touches a data slot and runs nothing. Strictly stronger than `trusted_prototype`, which
    answers the neighbouring question for a method *call*, and the two must not be merged: a method
    resolves on the prototype that owns it, so `Array.prototype.join` shadows anything installed on
    `Object.prototype` and a patch there cannot change what `[1, 2].join('-')` means. A read of an
    arbitrary name has no such shadow — `Object.prototype` roots every chain, so a getter installed
    there is reached by a read on an array literal, on a primitive, and on `Math` alike.

    Confirmed against Node in both directions rather than reasoned from the specification: patching
    `Object.prototype.join` leaves `[1, 2].join('-')` intact, while patching `Object.prototype.zz`
    makes `[1, 2].zz` run a getter.

    Two separate facts have to hold and this is their conjunction: no chain root was written,
    which is `chain_roots_unwritten`, and no reflective surface could have written one without
    saying so. A caller for which the second costs more than it buys takes the first alone — see
    the note there for what that trade is and where it is made.
    """
    owner = _PROTOTYPE_OWNERS.get(value_type.__name__)
    if owner is None:
        return False
    return self._prototypes_intact(owner, _INHERITED_CHAIN_ROOTS)
def chain_roots_unwritten(self, value_type)

Whether the program writes no prototype a plain property read on a value of value_type consults. This is read_chain_intact without its reflection term, and the difference is not a weakening of the same question but a different one: read_chain_intact also refuses wherever SemanticModel.has_reflection_surface holds, and that predicate answers whether code could reference a global by name — true of new Function('return this'), which writes no prototype at all.

The distinction is what the answer costs where it is wrong, and it decides who may ask this rather than being a judgement each caller makes for itself. A caller folding one expression pays an unfolded expression for refusing, so it may as well refuse under a surface, and every one of them asks read_chain_intact. A caller deciding whether a whole pass may run pays the pass, and a reflective surface is exactly what the real obfuscated files carry: measured on the samples this project tests against, the surface is present in the input and gone from the finished output, so a pass gated on it never runs and never clears the surface that was gating it. Only those callers ask this — today namespace flattening and the dispatcher unwrapper — and they accept that an unresolvable eval could in principle have written a prototype, which is no worse than the nothing they asked before.

The two facts separate cleanly on the evidence rather than by assumption: every program in the defect ledger that reaches a wrong answer here writes a chain root and has no reflective surface, and every sample that has a surface writes no chain root.

Expand source code Browse git
def chain_roots_unwritten(self, value_type: type) -> bool:
    """
    Whether the program writes no prototype a plain property read on a value of *value_type*
    consults. This is `read_chain_intact` without its reflection term, and the difference is not
    a weakening of the same question but a different one: `read_chain_intact` also refuses
    wherever `SemanticModel.has_reflection_surface` holds, and that predicate answers whether
    code could reference a global *by name* — true of `new Function('return this')`, which
    writes no prototype at all.

    The distinction is what the answer costs where it is wrong, and it decides who may ask this
    rather than being a judgement each caller makes for itself. A caller folding one expression
    pays an unfolded expression for refusing, so it may as well refuse under a surface, and
    every one of them asks `read_chain_intact`. A caller deciding whether a whole *pass* may run
    pays the pass, and a reflective surface is exactly what the real obfuscated files carry:
    measured on the samples this project tests against, the surface is present in the input and
    gone from the finished output, so a pass gated on it never runs and never clears the surface
    that was gating it. Only those callers ask this — today namespace flattening and the
    dispatcher unwrapper — and they accept that an unresolvable `eval` could in principle have
    written a prototype, which is no worse than the nothing they asked before.

    The two facts separate cleanly on the evidence rather than by assumption: every program in
    the defect ledger that reaches a wrong answer here writes a chain root and has no reflective
    surface, and every sample that has a surface writes no chain root.
    """
    owner = _PROTOTYPE_OWNERS.get(value_type.__name__)
    if owner is None:
        return False
    return self._roots_unwritten(owner, _INHERITED_CHAIN_ROOTS)
def call_is_foldable(self, node, *, receiver_type=None)

Whether the call node may be evaluated to a constant and have its result replace it. This is the one admission gate every fold shares, so that a rule proven necessary at one site cannot be missing at another — the fold surface acquired its divergent hand-rolled checks precisely because each transform owned its own.

Three questions must all answer yes:

  • The callee is still the built-in it is spelled as. A named callee (parseInt(…), String.fromCharCode(…)) is judged by trusted_intrinsic on its root name; a call on a literal receiver is judged by trusted_prototype on the type that literal's syntax fixes; and a call on another call — a chain link — is judged by asking this whole question of that inner call. receiver_type covers the remaining case, a caller holding an already-evaluated receiver whose type it knows and this model does not.
  • Every function-valued argument writes nothing outside itself. is_effect_free_when_discarded is the right predicate rather than is_pure: a callback that mutates a fresh local and returns it — the reduce accumulator idiom — is not pure, but that mutation is the value being computed and an evaluator that runs the call reproduces it. Purity is not sufficient on its own either, since a callback writing a script-scope var reports writes_captured=False — that binding is not captured from its perspective — so the write would be dropped while the value folds. written_bindings records it by identity and catches it.
  • Nothing in the argument list is itself a call this gate does not also clear, so admitting an outer call cannot smuggle in an inner one. A nested built-in (Math.floor(Math.abs(-1.7))) is fine precisely because the same questions are asked of it.
  • No part of the call stores anything the residual would go on to read. The fold deletes the whole expression, so a store is lost wherever in it the store was written — an argument, the receiver, or the computed key naming the method; see _call_stores_nothing.

Trust is not evaluability, and this answers only the first. trusted_intrinsic says unknownFn is undisturbed — true, and no help, since no such built-in exists to fold. Whether a callee can actually be evaluated is the caller's question, answered by its own registry lookup before it asks this one; conflating the two is what let a registry entry exist with no matching trust rule.

Expand source code Browse git
def call_is_foldable(
    self,
    node: JsCallExpression,
    *,
    receiver_type: type | None = None,
) -> bool:
    """
    Whether the call *node* may be evaluated to a constant and have its result replace it. This is the
    one admission gate every fold shares, so that a rule proven necessary at one site cannot be missing
    at another — the fold surface acquired its divergent hand-rolled checks precisely because each
    transform owned its own.

    Three questions must all answer yes:

    - The callee is still the built-in it is spelled as. A named callee (`parseInt(...)`,
      `String.fromCharCode(...)`) is judged by `trusted_intrinsic` on its root name; a call on a
      literal receiver is judged by `trusted_prototype` on the type that literal's syntax fixes; and a
      call on another call — a chain link — is judged by asking this whole question of that inner call.
      `receiver_type` covers the remaining case, a caller holding an already-evaluated receiver whose
      type it knows and this model does not.
    - Every function-valued argument writes nothing outside itself. `is_effect_free_when_discarded` is
      the right predicate rather than `is_pure`: a callback that mutates a fresh local and returns it —
      the `reduce` accumulator idiom — is not pure, but that mutation is the value being computed and an
      evaluator that runs the call reproduces it. Purity is not sufficient on its own either, since a
      callback writing a script-scope `var` reports `writes_captured=False` — that binding is not
      captured from its perspective — so the write would be dropped while the value folds.
      `written_bindings` records it by identity and catches it.
    - Nothing in the argument list is itself a call this gate does not also clear, so admitting an outer
      call cannot smuggle in an inner one. A nested built-in (`Math.floor(Math.abs(-1.7))`) is fine
      precisely because the same questions are asked of it.
    - No part of the call stores anything the residual would go on to read. The fold deletes the
      whole expression, so a store is lost wherever in it the store was written — an argument, the
      receiver, or the computed key naming the method; see `_call_stores_nothing`.

    Trust is not evaluability, and this answers only the first. `trusted_intrinsic` says `unknownFn` is
    undisturbed — true, and no help, since no such built-in exists to fold. Whether a callee can
    actually be evaluated is the caller's question, answered by its own registry lookup before it asks
    this one; conflating the two is what let a registry entry exist with no matching trust rule.
    """
    if not self._callee_is_trusted(node, receiver_type):
        return False
    if not self._call_stores_nothing(node):
        return False
    return all(self._argument_is_admissible(arg) for arg in node.arguments)
def member_read_getter_free(self, member, established=None)

Whether reading member runs no user getter, so it carries no observable effect: a getter-free read off a pristine value (a fresh literal or a pristine intrinsic root) or a trusted global data-property read off a syntactic global-object alias — always, since neither is nullish — or off a local single-assigned to the global object, which holds it only from its establishing definition onward. The local case qualifies only when established confirms that definition reaches the read, an ordering this effect model cannot decide on its own (see ReachingModel.value_preserved()).

Expand source code Browse git
def member_read_getter_free(
    self,
    member: JsMemberExpression,
    established: Callable[[Binding, JsMemberExpression], bool] | None = None,
) -> bool:
    """
    Whether reading *member* runs no user getter, so it carries no observable effect: a getter-free
    read off a pristine value (a fresh literal or a pristine intrinsic root) or a trusted global
    data-property read off a syntactic global-object alias — always, since neither is nullish — or off
    a local single-assigned to the global object, which holds it only from its establishing definition
    onward. The local case qualifies only when *established* confirms that definition reaches the read,
    an ordering this effect model cannot decide on its own (see
    `refinery.lib.scripts.js.analysis.reaching.ReachingModel.value_preserved`).
    """
    if self._getter_free_read(member):
        return True
    if established is None:
        return False
    binding = self._trusted_global_alias_read(member)
    return binding is not None and established(binding, member)