Module refinery.lib.scripts.js.deobfuscation.constants

Inline constant variable references in JavaScript.

Expand source code Browse git
"""
Inline constant variable references in JavaScript.
"""
from __future__ import annotations

from typing import NamedTuple

from refinery.lib.scripts import (
    Node,
    _clone_node,
    _remove_from_parent,
    _replace_in_parent,
    is_attached,
)
from refinery.lib.scripts.js.analysis.cache import ModelCache, model_cache
from refinery.lib.scripts.js.analysis.dominance import DominanceModel
from refinery.lib.scripts.js.analysis.effects import EffectModel
from refinery.lib.scripts.js.analysis.model import (
    FUNCTION_NODES,
    Binding,
    Role,
    SemanticModel,
    enclosing_function,
    is_invocation_target,
    is_member_write_target,
    pattern_identifiers,
    reference_role,
)
from refinery.lib.scripts.js.analysis.reaching import ReachingModel
from refinery.lib.scripts.js.deobfuscation.helpers import (
    BatchedScopeTransformer,
    a_host_reaches_the_binding,
    collect_identifier_names,
    is_literal,
    remove_declarator,
    substitute_use_position,
    walk_scope,
)
from refinery.lib.scripts.js.model import (
    JsArrayExpression,
    JsArrayPattern,
    JsArrowFunctionExpression,
    JsAssignmentExpression,
    JsAwaitExpression,
    JsCallExpression,
    JsClassExpression,
    JsExpressionStatement,
    JsForInStatement,
    JsForOfStatement,
    JsFunctionDeclaration,
    JsFunctionExpression,
    JsIdentifier,
    JsMemberExpression,
    JsNewExpression,
    JsNumericLiteral,
    JsObjectExpression,
    JsObjectPattern,
    JsScript,
    JsStringLiteral,
    JsTaggedTemplateExpression,
    JsUnaryExpression,
    JsUpdateExpression,
    JsVariableDeclaration,
    JsVariableDeclarator,
    JsVarKind,
    JsYieldExpression,
    strip_parens,
)
from refinery.lib.scripts.js.numbers import exact_integer


def _pattern_identifiers(pattern: Node) -> set[str]:
    """
    Extract all identifier names from a destructuring pattern (array or object pattern). These are
    the variables being assigned to.
    """
    return {n.name for n in pattern.walk() if isinstance(n, JsIdentifier)}


class _CandidateEntry(NamedTuple):
    declarator: JsVariableDeclarator | None
    value: Node


class _MemberArrayEntry(NamedTuple):
    assignment: JsAssignmentExpression
    array: JsArrayExpression


class _Substitution(NamedTuple):
    target: Node
    value: Node
    key: str


class _DeclaratorRemoval(NamedTuple):
    declarator: JsVariableDeclarator
    key: str


class _MemberArrayRemoval(NamedTuple):
    assignment: JsAssignmentExpression
    key: str


class _ScopePlan(NamedTuple):
    scope: Node
    substitutions: list[_Substitution]
    declarator_removals: list[_DeclaratorRemoval]
    member_array_removals: list[_MemberArrayRemoval]
    decl_ids: set[int]
    planned: set[str]


def _candidate_decl_ids(candidates: dict[str, list[_CandidateEntry]]) -> set[int]:
    """
    Collect the `id()` values of all declaration-site identifier nodes across candidate entries.
    Used to distinguish binding occurrences from reference occurrences during scope walks.
    """
    result: set[int] = set()
    for entries in candidates.values():
        for entry in entries:
            if (d := entry.declarator) is not None:
                result.add(id(d.id))
    return result


def _is_primitive_and_pure(node: Node) -> bool:
    """
    Return whether evaluating *node* is guaranteed to produce no observable side effects and the
    result is a primitive value (not an object, array, or function). This is stricter than
    `refinery.lib.scripts.js.analysis.effects.side_effect_free` — it rejects expressions
    that allocate objects or access properties, because inlining such expressions into a new
    location can change reference identity or trigger getters at a different point in execution.

    The deliberate complement of `EffectModel._fresh_kind`, over primitives rather than containers, and not
    to be merged with it: this admits exactly the values that have no identity to duplicate, where that one
    admits containers whose identity is known to be new. Their answers are disjoint by construction.

    A `delete` is observable — it removes a binding or property — so its unary form is rejected while
    the operator's other uses, none of which touch what they name, stay admitted.
    """
    for n in node.walk():
        if isinstance(n, (
            JsCallExpression,
            JsNewExpression,
            JsAssignmentExpression,
            JsUpdateExpression,
            JsYieldExpression,
            JsAwaitExpression,
            JsTaggedTemplateExpression,
            JsMemberExpression,
            JsObjectExpression,
            JsArrayExpression,
            JsFunctionExpression,
            JsArrowFunctionExpression,
            JsClassExpression,
        )):
            return False
        if isinstance(n, JsUnaryExpression) and n.operator == 'delete':
            return False
    return True


def _count_scope_references(
    scope: Node,
    names: set[str],
    decl_ids: set[int],
    *,
    walk_full: bool = False,
    count_member_access: bool = False,
) -> dict[str, int]:
    """
    Count identifier references within *scope* for each name in *names*, excluding declaration
    sites in *decl_ids* and simple (`=`) assignment write targets. A compound assignment (`x += e`,
    `x <<= e`) reads its target, so its left side is counted as a reference and the variable stays
    live. When *walk_full* is True, the entire subtree
    is traversed (including nested function bodies); otherwise only the current scope is walked.
    When *count_member_access* is True, computed member accesses like `name[idx]` are counted
    separately (the identifier inside the member is counted and the walk continues so the member
    node itself is not double-counted).
    """
    walker = scope.walk() if walk_full else walk_scope(scope, include_root_body=True)
    counts: dict[str, int] = {}
    for node in walker:
        if count_member_access and isinstance(node, JsMemberExpression) and node.computed:
            obj = node.object
            if isinstance(obj, JsIdentifier) and id(obj) not in decl_ids and obj.name in names:
                counts[obj.name] = counts.get(obj.name, 0) + 1
                continue
        if not isinstance(node, JsIdentifier):
            continue
        if id(node) in decl_ids:
            continue
        name = node.name
        if name not in names:
            continue
        parent = node.parent
        if isinstance(parent, JsAssignmentExpression) and parent.left is node and parent.operator == '=':
            continue
        if isinstance(parent, JsMemberExpression) and parent.property is node and not parent.computed:
            continue
        if count_member_access:
            if isinstance(parent, JsMemberExpression) and parent.object is node and parent.computed:
                continue
        counts[name] = counts.get(name, 0) + 1
    return counts


def _is_literal_array(node: Node) -> bool:
    """
    Return whether *node* is a `refinery.lib.scripts.js.model.JsArrayExpression` where every element
    is a literal.
    """
    if not isinstance(node, JsArrayExpression):
        return False
    return all(el is not None and is_literal(el) for el in node.elements)


def _is_member_array_safe(scope: Node, prefix_name: str, prop_name: str) -> bool:
    """
    Verify that `prefix.prop` (a member-expression array) is never mutated after its initial
    assignment. Checks that: (1) the property is never written to via an element write —
    `prefix.prop[i] = ...`, but also `prefix.prop[i]++`, `delete prefix.prop[i]`, and any other write
    target, decided by the shared `is_member_write_target` climb rather than a hand-rolled
    assignment-only test that a compound, update, or `delete` slips past; (2) the property value is
    never passed as an argument or assigned to another variable (aliased); (3) no method calls that
    could mutate the array exist (`prefix.prop.push(...)` etc.).
    """
    for node in scope.walk():
        if not isinstance(node, JsMemberExpression):
            continue
        if node.object is None or node.property is None:
            continue
        obj = node.object
        if not isinstance(obj, JsIdentifier) or obj.name != prefix_name:
            continue
        if not isinstance(node.property, JsIdentifier) or node.property.name != prop_name:
            continue
        parent = node.parent
        if isinstance(parent, JsMemberExpression) and parent.object is node:
            if parent.computed:
                if is_member_write_target(parent):
                    return False
            else:
                if is_invocation_target(parent):
                    return False
            continue
        if isinstance(parent, JsAssignmentExpression) and parent.left is node:
            continue
        if isinstance(parent, JsCallExpression):
            if not is_invocation_target(node):
                return False
    return True


def _is_constant_value(node: Node) -> bool:
    """
    Return whether *node* is a constant value eligible for multi-use inlining: a scalar literal or
    an all-literal array.
    """
    return is_literal(node) or _is_literal_array(node)


def _is_intrinsic_alias_value(effects: EffectModel, node: Node) -> bool:
    """
    Whether *node* is a bare identifier naming a pristine intrinsic (or the global object) — a stable,
    unshadowed value that may be inlined at a cross-function use as itself, emitting the same name. Like a
    literal it never goes stale (the intrinsic is pristine and the binding is single-assigned), so it
    joins the `const`-like class the cross-function inliner admits; the emitted name is separately checked
    unshadowed at each use, since intrinsic pristineness only rules out shadowing at the script scope.
    """
    return isinstance(node, JsIdentifier) and effects.intrinsic_of(node) is not None


def _is_const_qualified(declarator: JsVariableDeclarator) -> bool:
    parent = declarator.parent
    return isinstance(parent, JsVariableDeclaration) and parent.kind is JsVarKind.CONST


def _calls_a_function_of_this_file_by_an_unstated_name(scope: Node, effects: EffectModel) -> bool:
    """
    Whether *scope* holds a call naming its callee by an identifier this file binds while the model
    declines to say which function that name holds.

    Such a call may run any function written here, one that writes a candidate included, and
    `_collect_call_sites` cannot report it: that answers with the functions a call may reach, and
    this one reaches none it can name. Where it is true, a candidate survives only if no function of
    this file writes it at all, which is what the substitution needs: it is the only guard standing
    between a non-escaping mutator and a constant inlined past its write.
    """
    return any(
        effects.a_name_this_file_binds_holds_the_callee(node)
        for node in walk_scope(scope, include_root_body=True)
        if isinstance(node, JsCallExpression)
    )


def _collect_called_functions(scope: Node, effects: EffectModel) -> list[Node]:
    """
    The statically resolvable callees within *scope*, each listed once in first-seen order. A call
    whose target `EffectModel.static_callee` cannot pin down (a method, a reassigned or redeclared
    binding, an unresolved name) contributes nothing.
    """
    called_funcs: list[Node] = []
    seen: set[int] = set()
    for node in walk_scope(scope, include_root_body=True):
        if isinstance(node, JsCallExpression):
            target = effects.static_callee(node)
            if target is not None and id(target) not in seen:
                seen.add(id(target))
                called_funcs.append(target)
    return called_funcs


def _reader_qualifies(
    value: Node, reader: Node, binding: Binding,
    effects: EffectModel, dominance: DominanceModel,
) -> bool:
    """
    Whether a candidate that is not `const`-qualified — a `var` carrying its own initializer,
    whose value a call could rewrite — may be inlined into the reader function *reader* holding
    *value*, the question both arms of the cross-function substitution ask, stated once so the
    computed-member and plain-identifier arms cannot disagree. The reader must not write the
    binding, and the value must run before every one of its invocations, of which it must have at
    least one (`runs_before_every_invocation`): a reader nothing invokes answers the ordering
    vacuously and is refused, while an anonymous IIFE or arrow is always orderable, its single
    reference point being the function expression itself — the closure cannot be invoked before it
    is created. A `const`-qualified candidate, or an intrinsic alias, needs neither: its value
    cannot be replaced short of a dynamic rebind, which the candidate collection has already
    refused.
    """
    if effects.function_can_mutate(reader, binding):
        return False
    return dominance.runs_before_every_invocation(value, reader)


def _planned_keys(substitutions: list[_Substitution]) -> set[str]:
    return {sub.key for sub in substitutions}


class JsConstantInlining(BatchedScopeTransformer[_ScopePlan]):
    """
    Inline variables that are assigned once and never mutated. Literal-valued variables are inlined
    at all use sites; single-use variables with side-effect-free initializers are inlined when no
    intervening mutation could alter the referenced identifiers. All-literal arrays declared with
    `const` are inlined element-by-element when accessed with numeric literal indices.

    One invocation decides in rounds: each round traverses the tree once, every scope decides one
    round of edits against the model snapshot the round opened with, and the round's plans apply
    once the traversal ends, inner scopes first. A round that lands no edit ends the invocation.

    Every edit the batch applies removes something — a read, an index access, a declarator, an
    assignment statement — and installs no binding, so the emission registry has no reader here:
    the one bare name a substitution can write is an intrinsic alias, checked unshadowed at the
    site it goes.

    Non-interference, per fact the decisions read:

    - `ReachingModel.value_preserved` — whether a definition's value reaches a read unchanged.
      Batch edits remove reads, write sites, and declarators; none adds a kill, so a value that
      reached a read unchanged still does. A declined read — one a kill stood between — may become
      inlinable once the edit removing the kill applies, which the next round decides; the refusal
      the batch acted on stays valid.
    - `DominanceModel.runs_before_function` and `runs_before_every_invocation` — whether a value
      runs before a function, or before every invocation of one. Batch edits remove statements and
      reads, so the set of invocation points can only shrink; a value that preceded them all still
      does.
    - The effect summaries — `EffectModel.mutated_bindings`, `EffectModel.function_escapes`,
      `EffectModel.static_callee`, `EffectModel.function_can_mutate` — over functions the batch
      never rewrites. Removing a call site removes a mutation opportunity rather than adding one,
      so a function that could not write a binding still cannot.
    - The reference counts that gate removals: no removal decision reads one — a count taken
      before the round's plans apply cannot see the reads another plan of the same round deletes,
      and a substitution whose value is an identifier adds a read the entry snapshot never saw —
      so the plan counts at apply time, on the tree its substitutions leave behind, and
      `is_attached` refuses the substitutions an earlier plan detached.
    """

    def __init__(self, max_inline_length: int = 64):
        super().__init__()
        self.max_inline_length = max_inline_length
        self._root: JsScript | None = None
        self._edits_applied = 0

    def visit_JsScript(self, node: JsScript):
        self._root = node
        while True:
            self._edits_applied = 0
            super().visit_JsScript(node)
            if not self._edits_applied:
                break
        return None

    def _process_scope(self, scope: Node) -> None:
        assert self._root is not None
        cache = model_cache(self, self._root)
        candidates, mutated = self._collect_candidates(scope, cache.effects)
        member_arrays = self._collect_member_array_candidates(scope)
        if not candidates and not member_arrays:
            return
        substitutions = self._decide_constants(scope, candidates, cache)
        if member_arrays:
            substitutions.extend(self._decide_member_arrays(scope, member_arrays))
        if not substitutions:
            substitutions = self._decide_expressions(scope, candidates, mutated, cache)
        if not substitutions:
            return
        planned = _planned_keys(substitutions)
        decl_ids = _candidate_decl_ids(candidates)
        self._submit(_ScopePlan(
            scope,
            substitutions,
            self._decide_dead_declarators(candidates, planned, cache),
            self._decide_dead_member_arrays(member_arrays, planned) if member_arrays else [],
            decl_ids,
            planned,
        ))

    def _collect_candidates(
        self,
        scope: Node,
        effects: EffectModel,
    ) -> tuple[dict[str, list[_CandidateEntry]], set[str]]:
        """
        Collect constant declaration entries per variable. Each entry is a `_CandidateEntry` of

            (declarator, constant_value)

        Also returns the set of fully rejected (mutated) names — those reassigned, updated,
        destructured, or written by an escaping function, whose value cannot be pinned to a single
        definition. A candidate a dynamic scope could rewrite with no static write site is no longer
        rejected outright: the reaching query orders every located rebind hazard
        (`SemanticModel.binding_reflection_kill_sites`) against each read, so the reads that stand
        before the hazard fold and the ones after it stay. The one rebind that keeps its rejection
        is the nodeless one — the write a call makes on entry, which the text does not spell and
        no ordering can place (`binding_dynamic_rebind_sites` answering `None`) — and a candidate
        the analyst declared a host reaches by name is rejected the same way: the host reads it once
        the file has run, and may have rewritten it before any read the substitution would fold, so
        neither its declarator nor any read of it may be replaced. A write through a global-object
        alias is rejected alongside. The points past which a surviving candidate's value no longer
        holds are not enumerated here; the reaching query derives them from the effect model at
        each use.
        """
        candidates: dict[str, list[_CandidateEntry]] = {}
        rejected: set[str] = set()
        uninitialized: dict[str, JsVariableDeclarator] = {}

        for node in walk_scope(scope, include_root_body=True):
            if isinstance(node, JsVariableDeclaration):
                for decl in node.declarations:
                    if not isinstance(decl, JsVariableDeclarator):
                        continue
                    if not isinstance(decl.id, JsIdentifier):
                        for ident in pattern_identifiers(decl.id):
                            rejected.add(ident.name)
                            candidates.pop(ident.name, None)
                            uninitialized.pop(ident.name, None)
                        continue
                    name = decl.id.name
                    if name in rejected:
                        continue
                    if decl.init is None:
                        if name not in candidates:
                            uninitialized[name] = decl
                        continue
                    if name in candidates:
                        rejected.add(name)
                        candidates.pop(name, None)
                        uninitialized.pop(name, None)
                        continue
                    candidates[name] = [_CandidateEntry(decl, decl.init)]
                    uninitialized.pop(name, None)

            if isinstance(node, JsAssignmentExpression):
                left = strip_parens(node.left)
                if isinstance(left, JsIdentifier):
                    name = left.name
                    if (
                        node.operator == '='
                        and name in uninitialized
                        and name not in candidates
                        and name not in rejected
                    ):
                        decl = uninitialized.pop(name)
                        rhs = node.right
                        if rhs is None:
                            rejected.add(name)
                        else:
                            candidates[name] = [_CandidateEntry(decl, rhs)]
                    else:
                        rejected.add(name)
                        candidates.pop(name, None)
                        uninitialized.pop(name, None)
                elif isinstance(left, (JsArrayPattern, JsObjectPattern)):
                    for name in _pattern_identifiers(left):
                        rejected.add(name)
                        candidates.pop(name, None)
                        uninitialized.pop(name, None)

            if isinstance(node, JsUpdateExpression):
                target = strip_parens(node.argument)
                if isinstance(target, JsIdentifier):
                    name = target.name
                    rejected.add(name)
                    candidates.pop(name, None)

            if isinstance(node, (JsForInStatement, JsForOfStatement)):
                left = strip_parens(node.left)
                loop_targets: set[str] = set()
                if isinstance(left, JsVariableDeclaration):
                    for decl in left.declarations:
                        if isinstance(decl, JsVariableDeclarator) and decl.id is not None:
                            loop_targets |= _pattern_identifiers(decl.id)
                elif isinstance(left, JsIdentifier):
                    loop_targets.add(left.name)
                elif isinstance(left, (
                    JsArrayExpression, JsObjectExpression, JsArrayPattern, JsObjectPattern,
                )):
                    loop_targets |= _pattern_identifiers(left)
                for name in loop_targets:
                    rejected.add(name)
                    candidates.pop(name, None)
                    uninitialized.pop(name, None)

        model = effects.model
        candidate_bindings: dict[str, Binding] = {}
        for cand_name, cand_entries in candidates.items():
            decl = cand_entries[0].declarator
            if decl is not None and isinstance(decl.id, JsIdentifier):
                binding = model.binding_of(decl.id)
                if binding is not None:
                    candidate_bindings[cand_name] = binding

        def _reject(target_name: str) -> None:
            rejected.add(target_name)
            candidates.pop(target_name, None)
            uninitialized.pop(target_name, None)
            candidate_bindings.pop(target_name, None)

        functions = [node for node in scope.walk() if isinstance(node, FUNCTION_NODES)]

        for cand_name, binding in list(candidate_bindings.items()):
            if (
                binding.has_global_member_write
                or model.binding_dynamic_rebind_sites(binding) is None
                or a_host_reaches_the_binding(model, binding, self.options)
            ):
                _reject(cand_name)

        for func in functions:
            written = effects.mutated_bindings(func)
            if not written:
                continue
            touched = [n for n, binding in candidate_bindings.items() if binding in written]
            if not touched:
                continue
            if effects.function_escapes(func):
                for cand_name in touched:
                    _reject(cand_name)

        return candidates, rejected

    @staticmethod
    def _candidate_binding(entry: _CandidateEntry, model: SemanticModel) -> Binding | None:
        """
        The binding a candidate entry defines, resolved through its declarator identifier, or `None` when
        the entry carries no single-identifier declarator. This is the binding whose value the reaching
        query tracks from the definition to a use.
        """
        decl = entry.declarator
        if decl is None or not isinstance(decl.id, JsIdentifier):
            return None
        return model.binding_of(decl.id)

    def _decide_constants(
        self,
        scope: Node,
        candidates: dict[str, list[_CandidateEntry]],
        cache: ModelCache,
    ) -> list[_Substitution]:
        """
        Decide the constant (literal and literal-array) inlines of one round. A reference is inlined
        only where the definition's value provably reaches it unchanged (`ReachingModel.value_preserved`),
        covering both scalar references and computed index access into all-literal arrays. The value
        each decision records is the entry snapshot's own node; the plan clones it where it applies.
        """
        bloat_blocked: set[str] = set()

        decl_ids = _candidate_decl_ids(candidates)
        ref_counts = _count_scope_references(
            scope, set(candidates), decl_ids, count_member_access=True,
        )

        for name, entries in candidates.items():
            if len(entries) != 1:
                continue
            value = entries[0].value
            count = ref_counts.get(name, 0)
            if count <= 1:
                continue
            if isinstance(value, JsStringLiteral) and value.value is not None:
                if len(value.value) > self.max_inline_length:
                    bloat_blocked.add(name)

        effects = cache.effects
        reaching = cache.reaching
        model = effects.model

        constant_names = {
            name for name, entries in candidates.items()
            if any(_is_constant_value(e.value) or _is_intrinsic_alias_value(effects, e.value) for e in entries)
        }
        if not constant_names:
            return []

        substitutions: list[_Substitution] = []
        for node in walk_scope(scope, include_root_body=True):
            if isinstance(node, JsMemberExpression) and node.computed:
                obj = node.object
                if (
                    isinstance(obj, JsIdentifier)
                    and id(obj) not in decl_ids
                    and obj.name in constant_names
                    and obj.name not in bloat_blocked
                    and self._index_array_immutable(obj, effects, direct_eval_ordered=True)
                ):
                    entry = candidates[obj.name][0]
                    binding = self._candidate_binding(entry, model)
                    if binding is not None and reaching.value_preserved(binding, entry.value, node):
                        element = self._index_access_element(node, entry)
                        if element is not None:
                            substitutions.append(_Substitution(node, element, obj.name))
                    continue
            if not isinstance(node, JsIdentifier):
                continue
            if id(node) in decl_ids:
                continue
            name = node.name
            if name not in constant_names or name in bloat_blocked:
                continue
            parent = node.parent
            if reference_role(node) is not Role.READ:
                continue
            if isinstance(parent, JsMemberExpression) and parent.object is node and parent.computed:
                continue
            entry = candidates[name][0]
            if not is_literal(entry.value):
                continue
            binding = self._candidate_binding(entry, model)
            if binding is None or model.resolve(node) is not binding:
                continue
            if not reaching.value_preserved(binding, entry.value, node):
                continue
            substitutions.append(_Substitution(node, entry.value, name))

        substitutions.extend(self._decide_const_across_functions(
            scope, candidates, decl_ids, bloat_blocked, cache,
        ))

        return substitutions

    @staticmethod
    def _index_array_immutable(
        obj: JsIdentifier, effects: EffectModel, direct_eval_ordered: bool = False,
    ) -> bool:
        """
        Whether the array binding referenced by *obj* is an immutable, non-escaping container, so that
        `obj[idx]` may be inlined to its literal element. Resolved through the binding (not the textual
        name), so shadowing is respected; the model memoizes the judgment for the model's lifetime. A
        name that does not resolve to a local binding (a free or global array) is treated as unsafe.
        *direct_eval_ordered* carries the caller's promise that it has ordered the direct-`eval`
        sites against the read it is folding — the `value_preserved` check the same arm
        makes — so a local array in a function holding a direct `eval` stays inlinable where the
        read is ordered before every eval site.
        """
        binding = effects.model.resolve(obj)
        if binding is None:
            return False
        return effects.binding_is_immutable_container(
            binding, direct_eval_ordered=direct_eval_ordered)

    @staticmethod
    def _index_access_element(member: JsMemberExpression, entry: _CandidateEntry) -> Node | None:
        """
        The literal element an index access into a candidate's all-literal array resolves to, or
        `None` when the access names no in-bounds literal element.
        """
        prop = member.property
        if not isinstance(prop, JsNumericLiteral):
            return None
        idx = exact_integer(prop.value)
        if idx is None:
            return None
        value = entry.value
        if not isinstance(value, JsArrayExpression):
            return None
        if not (0 <= idx < len(value.elements)):
            return None
        element = value.elements[idx]
        if element is None or not is_literal(element):
            return None
        return element

    def _decide_const_across_functions(
        self,
        scope: Node,
        candidates: dict[str, list[_CandidateEntry]],
        decl_ids: set[int],
        bloat_blocked: set[str],
        cache: ModelCache,
    ) -> list[_Substitution]:
        """
        Decide the constant-valued inlines of one round that cross into nested function bodies. A
        `const`-qualified candidate, or an uninitialized `var` later assigned a single constant, is
        inlined into a function only when the value provably runs before every invocation of that
        function (`DominanceModel.runs_before_function`) — otherwise a call could read the value
        early: a stale read for the `var` form, a temporal-dead-zone throw for the `const` form. A
        `var`/`let` candidate that carries its own initializer is additionally restricted by
        `_reader_qualifies` to a reader that neither writes the binding nor runs an invocation the
        value does not precede — a named function invoked in scope, an anonymous IIFE, an arrow, or
        a function stored in a binding, each of whose invocation points is enumerable and ordered;
        an uncalled named function is refused, since its ordering answer is the vacuous one an
        empty set of reference points gives. The interprocedural runs-before check subsumes the
        earlier escape and statement-position heuristics: a function cannot be invoked before a
        reference to it has been evaluated, so it orders the value against every point the function
        is referenced — recursing up the call graph for a reference that lies inside another
        function — and inlines only when the value dominates all of them, refusing whenever a
        reference cannot be ordered (its binding is reassigned or redeclared, or it lies on a call
        cycle).

        A candidate a dynamic scope could rewrite is refused here
        (`binding_maybe_reassigned_dynamically`): this consumer's ordering runs the value before an
        *invocation*, and an invocation cannot be ordered against the site that rewrites the value
        — a name read once can be stored and invoked arbitrarily later, past the hazard — so the
        reads that fold on the located hazards are the same-function ones, which the reaching
        query answers at the use itself.
        """
        effects = cache.effects
        dominance = cache.dominance
        model = effects.model
        cross_candidates: dict[str, list[_CandidateEntry]] = {}
        cross_bindings: dict[str, Binding] = {}
        const_names: set[str] = set()
        for name, entries in candidates.items():
            if name in bloat_blocked:
                continue
            if len(entries) != 1:
                continue
            entry = entries[0]
            if entry.declarator is None or not isinstance(entry.declarator.id, JsIdentifier):
                continue
            intrinsic_alias = _is_intrinsic_alias_value(effects, entry.value)
            if not (_is_constant_value(entry.value) or intrinsic_alias):
                continue
            binding = model.binding_of(entry.declarator.id)
            if binding is None:
                continue
            cross_candidates[name] = entries
            cross_bindings[name] = binding
            if _is_const_qualified(entry.declarator) or entry.declarator.init is None or intrinsic_alias:
                const_names.add(name)

        if not cross_candidates:
            return []

        outer = model.scope_of(scope)
        assert outer is not None
        owner = enclosing_function(scope)

        called_funcs = _collect_called_functions(scope, effects)
        a_callee_is_unstated = _calls_a_function_of_this_file_by_an_unstated_name(scope, effects)

        for name in [
            candidate for candidate, binding in cross_bindings.items()
            if model.binding_maybe_reassigned_dynamically(binding)
            or (a_callee_is_unstated and effects.some_function_can_mutate(binding))
            or any(effects.function_can_mutate(func, binding) for func in called_funcs)
        ]:
            del cross_candidates[name]
            del cross_bindings[name]
        if not cross_candidates:
            return []

        substitutions: list[_Substitution] = []
        for node in scope.walk():
            if isinstance(node, JsMemberExpression) and node.computed:
                obj = node.object
                if (
                    isinstance(obj, JsIdentifier)
                    and id(obj) not in decl_ids
                    and obj.name in cross_candidates
                    and self._index_array_immutable(obj, effects)
                ):
                    name = obj.name
                    enclosing = enclosing_function(obj)
                    if enclosing is None or enclosing is owner:
                        continue
                    if model.resolve(obj) is not cross_bindings[name]:
                        continue
                    if name in const_names:
                        if not dominance.runs_before_function(
                            cross_candidates[name][0].value, enclosing,
                        ):
                            continue
                    elif not _reader_qualifies(
                        cross_candidates[name][0].value, enclosing,
                        cross_bindings[name], effects, dominance,
                    ):
                        continue
                    if model.is_shadowed(name, obj, outer):
                        continue
                    element = self._index_access_element(node, cross_candidates[name][0])
                    if element is not None:
                        substitutions.append(_Substitution(node, element, name))
                    continue
            if not isinstance(node, JsIdentifier):
                continue
            if id(node) in decl_ids:
                continue
            name = node.name
            if name not in cross_candidates:
                continue
            parent = node.parent
            if reference_role(node) is not Role.READ:
                continue
            if isinstance(parent, JsMemberExpression) and parent.object is node and parent.computed:
                continue
            if isinstance(parent, JsVariableDeclarator) and parent.id is node:
                continue
            if isinstance(parent, (JsFunctionDeclaration, JsFunctionExpression)) and parent.id is node:
                continue
            entry = cross_candidates[name][0]
            intrinsic_alias = _is_intrinsic_alias_value(effects, entry.value)
            if not (is_literal(entry.value) or intrinsic_alias):
                continue
            enclosing = enclosing_function(node)
            if enclosing is None or enclosing is owner:
                continue
            if model.resolve(node) is not cross_bindings[name]:
                continue
            if name in const_names:
                if not dominance.runs_before_function(entry.value, enclosing):
                    continue
            elif not _reader_qualifies(
                entry.value, enclosing, cross_bindings[name], effects, dominance,
            ):
                continue
            if model.is_shadowed(name, node, outer):
                continue
            if (
                intrinsic_alias
                and isinstance(entry.value, JsIdentifier)
                and model.lookup(entry.value.name, model.scope_of(node)) is not None
            ):
                continue
            substitutions.append(_Substitution(node, entry.value, name))
        return substitutions

    def _decide_expressions(
        self,
        scope: Node,
        candidates: dict[str, list[_CandidateEntry]],
        mutated: set[str],
        cache: ModelCache,
    ) -> list[_Substitution]:
        """
        Decide the single-use, side-effect-free, non-literal expression inlines of one round.
        Relocating the initializer to its use is sound only when the value it computed still holds
        there — which means the candidate's own binding reaches the use unchanged *and* every
        variable the initializer reads holds, at the use, the value it held at the definition.
        `ReachingModel.value_preserved` decides each: the candidate binding for ordering and its
        own kills, then one query per resolved free variable. The `_is_primitive_and_pure` gate
        keeps the initializer free of side effects and reference identity, and the `mutated` gate
        rejects a free variable written through a name no binding resolves (a global reassigned in
        scope), which the binding-keyed reaching query cannot see. A candidate whose initializer
        reads a bare name through a `with` body's dynamic scope needs no separate gate here: such
        an initializer is inside the `with` body, so the candidate's own binding is written in a
        dynamic scope and the reaching query orders that hazard against the relocated read.
        """
        decl_ids = _candidate_decl_ids(candidates)
        ref_counts = _count_scope_references(scope, set(candidates), decl_ids)

        to_inline: dict[str, _CandidateEntry] = {}
        for name, entries in candidates.items():
            if len(entries) != 1:
                continue
            entry = entries[0]
            init = entry.value
            count = ref_counts.get(name, 0)
            if is_literal(init) or _is_literal_array(init) or count != 1:
                continue
            if not _is_primitive_and_pure(init):
                continue
            if collect_identifier_names(init) & mutated:
                continue
            to_inline[name] = entry

        if not to_inline:
            return []

        reaching = cache.reaching
        model = cache.effects.model

        substitutions: list[_Substitution] = []
        for node in walk_scope(scope, include_root_body=True):
            if not isinstance(node, JsIdentifier):
                continue
            if id(node) in decl_ids:
                continue
            name = node.name
            if name not in to_inline:
                continue
            if reference_role(node) is not Role.READ:
                continue
            entry = to_inline[name]
            binding = self._candidate_binding(entry, model)
            if binding is None or not reaching.value_preserved(binding, entry.value, node):
                continue
            if not self._free_variables_preserved(entry.value, node, model, reaching):
                continue
            substitutions.append(_Substitution(node, entry.value, name))
        return substitutions

    @staticmethod
    def _free_variables_preserved(
        value: Node, use: Node, model: SemanticModel, reaching: ReachingModel,
    ) -> bool:
        """
        Whether re-evaluating *value* at *use* yields the same result it had where *value* was
        defined. Each name *value* reads must resolve, from *use*'s scope, to the binding it read
        where it was defined — a name a block or function around *use* shadows would read a
        different binding there, so a global read at the definition becomes a local read once
        substituted — and, where that binding exists, must still hold the value it held
        (`ReachingModel.value_preserved`). A free name that stays free preserves its value by the
        caller's `mutated` gate, so only its resolution is checked here. This is the twin of
        `JsCallWrapperInliner._forwarded_callee_reaches`, which guards the same capture for a
        forwarded callee.
        """
        use_scope = model.scope_of(use)
        for ident in value.walk():
            if not isinstance(ident, JsIdentifier) or not model.is_reference(ident):
                continue
            binding = model.resolve(ident)
            if model.lookup(ident.name, use_scope) is not binding:
                return False
            if binding is not None and not reaching.value_preserved(binding, value, use):
                return False
        return True

    def _decide_dead_declarators(
        self,
        candidates: dict[str, list[_CandidateEntry]],
        planned: set[str],
        cache: ModelCache,
    ) -> list[_DeclaratorRemoval]:
        """
        Decide which declarators of the round-inlined names are up for removal. No reference count
        is read here — one taken before the round's plans apply cannot see the reads another plan
        of the same round deletes — so every name the round substitutes is proposed and the plan
        counts references at apply time. An exported binding keeps its declarator even with every
        local read inlined, because an importer still reads it across the module boundary;
        removing it would leave an `export` naming a binding the module no longer declares. A
        binding that code the model cannot read could name keeps its declarator for the same
        reason: every read this file spells may have folded, and the surface — a direct `eval`,
        a span of source the model never read, a `with` body — reads the binding through no
        reference the inlining counted, so removing the declaration would turn its value into
        a `ReferenceError`. An opaque global write is not such a surface: it stores a property
        and runs nothing, and the reads it could replace were ordered against it before they
        folded, so the property it may write is the same residual the fold already concedes.
        """
        model = cache.model
        removals: list[_DeclaratorRemoval] = []
        for name in planned:
            entries = candidates.get(name)
            if entries is None:
                continue
            for entry in entries:
                if entry.declarator is None:
                    continue
                binding = self._candidate_binding(entry, model)
                if binding is not None and (
                    binding.exported
                    or model.reachable_by_opaque_reflection(binding)
                    or bool(binding.dynamic_refs)
                ):
                    continue
                removals.append(_DeclaratorRemoval(entry.declarator, name))
        return removals

    @staticmethod
    def _collect_member_array_candidates(scope: Node) -> dict[str, _MemberArrayEntry]:
        """
        Collect member-expression assignments of all-literal arrays: `X.Y = [literals...]`.
        Returns a dict keyed by `"X.Y"` to `_MemberArrayEntry`. Only single-assignment,
        non-aliased properties qualify.
        """
        candidates: dict[str, _MemberArrayEntry] = {}
        rejected: set[str] = set()
        prefix_rejected: set[str] = set()

        for node in walk_scope(scope, include_root_body=True):
            if not isinstance(node, JsAssignmentExpression) or node.operator != '=':
                continue
            lhs = node.left
            if not isinstance(lhs, JsMemberExpression) or lhs.computed:
                continue
            if not isinstance(lhs.object, JsIdentifier) or not isinstance(lhs.property, JsIdentifier):
                continue
            key = F'{lhs.object.name}.{lhs.property.name}'
            if key in rejected:
                continue
            rhs = node.right
            if not isinstance(rhs, JsArrayExpression) or not _is_literal_array(rhs):
                rejected.add(key)
                candidates.pop(key, None)
                continue
            if key in candidates:
                rejected.add(key)
                candidates.pop(key)
                continue
            candidates[key] = _MemberArrayEntry(node, rhs)

        if not candidates:
            return candidates

        prefix_names = {k.split('.', 1)[0] for k in candidates}
        for node in walk_scope(scope, include_root_body=True):
            if isinstance(node, JsAssignmentExpression) and node.operator == '=':
                if isinstance(node.left, JsIdentifier) and node.left.name in prefix_names:
                    prefix_rejected.add(node.left.name)
            if isinstance(node, JsUpdateExpression) and isinstance(node.argument, JsIdentifier):
                if node.argument.name in prefix_names:
                    prefix_rejected.add(node.argument.name)

        if prefix_rejected:
            candidates = {
                k: v for k, v in candidates.items()
                if k.split('.', 1)[0] not in prefix_rejected
            }

        for key in list(candidates):
            prefix, prop = key.split('.', 1)
            if not _is_member_array_safe(scope, prefix, prop):
                del candidates[key]

        return candidates

    def _decide_member_arrays(
        self,
        scope: Node,
        member_arrays: dict[str, _MemberArrayEntry],
    ) -> list[_Substitution]:
        """
        Decide the `X.Y[N]` → element inlines of one round for all collected member-array
        candidates. Walks the full subtree (including nested function bodies) since these arrays
        are scope-level constants.
        """
        substitutions: list[_Substitution] = []
        for node in scope.walk():
            if not isinstance(node, JsMemberExpression) or not node.computed:
                continue
            prop = node.property
            if not isinstance(prop, JsNumericLiteral):
                continue
            obj = node.object
            if not isinstance(obj, JsMemberExpression) or obj.computed:
                continue
            if not isinstance(obj.object, JsIdentifier) or not isinstance(obj.property, JsIdentifier):
                continue
            key = F'{obj.object.name}.{obj.property.name}'
            entry = member_arrays.get(key)
            if entry is None:
                continue
            idx = exact_integer(prop.value)
            if idx is None or not (0 <= idx < len(entry.array.elements)):
                continue
            element = entry.array.elements[idx]
            if element is None or not is_literal(element):
                continue
            substitutions.append(_Substitution(node, element, key))
        return substitutions

    def _decide_dead_member_arrays(
        self,
        member_arrays: dict[str, _MemberArrayEntry],
        planned: set[str],
    ) -> list[_MemberArrayRemoval]:
        return [
            _MemberArrayRemoval(entry.assignment, key)
            for key, entry in member_arrays.items()
            if key in planned
        ]

    @staticmethod
    def _count_member_array_accesses(scope: Node, keys: set[str]) -> dict[str, int]:
        """
        The computed `X.Y[...]` accesses standing in *scope*'s subtree per key in *keys*, counted
        over the tree as it stands when asked — the plan asks once its substitutions have landed.
        """
        remaining: dict[str, int] = {}
        for node in scope.walk():
            if not isinstance(node, JsMemberExpression) or not node.computed:
                continue
            obj = node.object
            if not isinstance(obj, JsMemberExpression) or obj.computed:
                continue
            if not isinstance(obj.object, JsIdentifier) or not isinstance(obj.property, JsIdentifier):
                continue
            key = F'{obj.object.name}.{obj.property.name}'
            if key in keys:
                remaining[key] = remaining.get(key, 0) + 1
        return remaining

    def _apply_plan(self, plan: _ScopePlan) -> None:
        for sub in plan.substitutions:
            if not is_attached(sub.target):
                continue
            clone = _clone_node(sub.value)
            if isinstance(sub.target, JsIdentifier):
                substituted = substitute_use_position(sub.target, clone)
            else:
                substituted = _replace_in_parent(sub.target, clone)
            if not substituted:
                continue
            self.mark_changed()
            self._edits_applied += 1
        if plan.declarator_removals:
            ref_counts = _count_scope_references(
                plan.scope, plan.planned, plan.decl_ids, walk_full=True,
            )
            for removal in plan.declarator_removals:
                if ref_counts.get(removal.key, 0) > 0:
                    continue
                if not is_attached(removal.declarator):
                    continue
                remove_declarator(removal.declarator)
                self.mark_changed()
                self._edits_applied += 1
        if plan.member_array_removals:
            keys = {removal.key for removal in plan.member_array_removals}
            remaining = self._count_member_array_accesses(plan.scope, keys)
            for removal in plan.member_array_removals:
                if remaining.get(removal.key, 0) > 0:
                    continue
                if not is_attached(removal.assignment):
                    continue
                stmt = removal.assignment.parent
                if isinstance(stmt, JsExpressionStatement) and _remove_from_parent(stmt):
                    self.mark_changed()
                    self._edits_applied += 1

Classes

class JsConstantInlining (max_inline_length=64)

Inline variables that are assigned once and never mutated. Literal-valued variables are inlined at all use sites; single-use variables with side-effect-free initializers are inlined when no intervening mutation could alter the referenced identifiers. All-literal arrays declared with const are inlined element-by-element when accessed with numeric literal indices.

One invocation decides in rounds: each round traverses the tree once, every scope decides one round of edits against the model snapshot the round opened with, and the round's plans apply once the traversal ends, inner scopes first. A round that lands no edit ends the invocation.

Every edit the batch applies removes something — a read, an index access, a declarator, an assignment statement — and installs no binding, so the emission registry has no reader here: the one bare name a substitution can write is an intrinsic alias, checked unshadowed at the site it goes.

Non-interference, per fact the decisions read:

  • ReachingModel.value_preserved — whether a definition's value reaches a read unchanged. Batch edits remove reads, write sites, and declarators; none adds a kill, so a value that reached a read unchanged still does. A declined read — one a kill stood between — may become inlinable once the edit removing the kill applies, which the next round decides; the refusal the batch acted on stays valid.
  • DominanceModel.runs_before_function and runs_before_every_invocation — whether a value runs before a function, or before every invocation of one. Batch edits remove statements and reads, so the set of invocation points can only shrink; a value that preceded them all still does.
  • The effect summaries — EffectModel.mutated_bindings, EffectModel.function_escapes, EffectModel.static_callee, EffectModel.function_can_mutate — over functions the batch never rewrites. Removing a call site removes a mutation opportunity rather than adding one, so a function that could not write a binding still cannot.
  • The reference counts that gate removals: no removal decision reads one — a count taken before the round's plans apply cannot see the reads another plan of the same round deletes, and a substitution whose value is an identifier adds a read the entry snapshot never saw — so the plan counts at apply time, on the tree its substitutions leave behind, and is_attached refuses the substitutions an earlier plan detached.
Expand source code Browse git
class JsConstantInlining(BatchedScopeTransformer[_ScopePlan]):
    """
    Inline variables that are assigned once and never mutated. Literal-valued variables are inlined
    at all use sites; single-use variables with side-effect-free initializers are inlined when no
    intervening mutation could alter the referenced identifiers. All-literal arrays declared with
    `const` are inlined element-by-element when accessed with numeric literal indices.

    One invocation decides in rounds: each round traverses the tree once, every scope decides one
    round of edits against the model snapshot the round opened with, and the round's plans apply
    once the traversal ends, inner scopes first. A round that lands no edit ends the invocation.

    Every edit the batch applies removes something — a read, an index access, a declarator, an
    assignment statement — and installs no binding, so the emission registry has no reader here:
    the one bare name a substitution can write is an intrinsic alias, checked unshadowed at the
    site it goes.

    Non-interference, per fact the decisions read:

    - `ReachingModel.value_preserved` — whether a definition's value reaches a read unchanged.
      Batch edits remove reads, write sites, and declarators; none adds a kill, so a value that
      reached a read unchanged still does. A declined read — one a kill stood between — may become
      inlinable once the edit removing the kill applies, which the next round decides; the refusal
      the batch acted on stays valid.
    - `DominanceModel.runs_before_function` and `runs_before_every_invocation` — whether a value
      runs before a function, or before every invocation of one. Batch edits remove statements and
      reads, so the set of invocation points can only shrink; a value that preceded them all still
      does.
    - The effect summaries — `EffectModel.mutated_bindings`, `EffectModel.function_escapes`,
      `EffectModel.static_callee`, `EffectModel.function_can_mutate` — over functions the batch
      never rewrites. Removing a call site removes a mutation opportunity rather than adding one,
      so a function that could not write a binding still cannot.
    - The reference counts that gate removals: no removal decision reads one — a count taken
      before the round's plans apply cannot see the reads another plan of the same round deletes,
      and a substitution whose value is an identifier adds a read the entry snapshot never saw —
      so the plan counts at apply time, on the tree its substitutions leave behind, and
      `is_attached` refuses the substitutions an earlier plan detached.
    """

    def __init__(self, max_inline_length: int = 64):
        super().__init__()
        self.max_inline_length = max_inline_length
        self._root: JsScript | None = None
        self._edits_applied = 0

    def visit_JsScript(self, node: JsScript):
        self._root = node
        while True:
            self._edits_applied = 0
            super().visit_JsScript(node)
            if not self._edits_applied:
                break
        return None

    def _process_scope(self, scope: Node) -> None:
        assert self._root is not None
        cache = model_cache(self, self._root)
        candidates, mutated = self._collect_candidates(scope, cache.effects)
        member_arrays = self._collect_member_array_candidates(scope)
        if not candidates and not member_arrays:
            return
        substitutions = self._decide_constants(scope, candidates, cache)
        if member_arrays:
            substitutions.extend(self._decide_member_arrays(scope, member_arrays))
        if not substitutions:
            substitutions = self._decide_expressions(scope, candidates, mutated, cache)
        if not substitutions:
            return
        planned = _planned_keys(substitutions)
        decl_ids = _candidate_decl_ids(candidates)
        self._submit(_ScopePlan(
            scope,
            substitutions,
            self._decide_dead_declarators(candidates, planned, cache),
            self._decide_dead_member_arrays(member_arrays, planned) if member_arrays else [],
            decl_ids,
            planned,
        ))

    def _collect_candidates(
        self,
        scope: Node,
        effects: EffectModel,
    ) -> tuple[dict[str, list[_CandidateEntry]], set[str]]:
        """
        Collect constant declaration entries per variable. Each entry is a `_CandidateEntry` of

            (declarator, constant_value)

        Also returns the set of fully rejected (mutated) names — those reassigned, updated,
        destructured, or written by an escaping function, whose value cannot be pinned to a single
        definition. A candidate a dynamic scope could rewrite with no static write site is no longer
        rejected outright: the reaching query orders every located rebind hazard
        (`SemanticModel.binding_reflection_kill_sites`) against each read, so the reads that stand
        before the hazard fold and the ones after it stay. The one rebind that keeps its rejection
        is the nodeless one — the write a call makes on entry, which the text does not spell and
        no ordering can place (`binding_dynamic_rebind_sites` answering `None`) — and a candidate
        the analyst declared a host reaches by name is rejected the same way: the host reads it once
        the file has run, and may have rewritten it before any read the substitution would fold, so
        neither its declarator nor any read of it may be replaced. A write through a global-object
        alias is rejected alongside. The points past which a surviving candidate's value no longer
        holds are not enumerated here; the reaching query derives them from the effect model at
        each use.
        """
        candidates: dict[str, list[_CandidateEntry]] = {}
        rejected: set[str] = set()
        uninitialized: dict[str, JsVariableDeclarator] = {}

        for node in walk_scope(scope, include_root_body=True):
            if isinstance(node, JsVariableDeclaration):
                for decl in node.declarations:
                    if not isinstance(decl, JsVariableDeclarator):
                        continue
                    if not isinstance(decl.id, JsIdentifier):
                        for ident in pattern_identifiers(decl.id):
                            rejected.add(ident.name)
                            candidates.pop(ident.name, None)
                            uninitialized.pop(ident.name, None)
                        continue
                    name = decl.id.name
                    if name in rejected:
                        continue
                    if decl.init is None:
                        if name not in candidates:
                            uninitialized[name] = decl
                        continue
                    if name in candidates:
                        rejected.add(name)
                        candidates.pop(name, None)
                        uninitialized.pop(name, None)
                        continue
                    candidates[name] = [_CandidateEntry(decl, decl.init)]
                    uninitialized.pop(name, None)

            if isinstance(node, JsAssignmentExpression):
                left = strip_parens(node.left)
                if isinstance(left, JsIdentifier):
                    name = left.name
                    if (
                        node.operator == '='
                        and name in uninitialized
                        and name not in candidates
                        and name not in rejected
                    ):
                        decl = uninitialized.pop(name)
                        rhs = node.right
                        if rhs is None:
                            rejected.add(name)
                        else:
                            candidates[name] = [_CandidateEntry(decl, rhs)]
                    else:
                        rejected.add(name)
                        candidates.pop(name, None)
                        uninitialized.pop(name, None)
                elif isinstance(left, (JsArrayPattern, JsObjectPattern)):
                    for name in _pattern_identifiers(left):
                        rejected.add(name)
                        candidates.pop(name, None)
                        uninitialized.pop(name, None)

            if isinstance(node, JsUpdateExpression):
                target = strip_parens(node.argument)
                if isinstance(target, JsIdentifier):
                    name = target.name
                    rejected.add(name)
                    candidates.pop(name, None)

            if isinstance(node, (JsForInStatement, JsForOfStatement)):
                left = strip_parens(node.left)
                loop_targets: set[str] = set()
                if isinstance(left, JsVariableDeclaration):
                    for decl in left.declarations:
                        if isinstance(decl, JsVariableDeclarator) and decl.id is not None:
                            loop_targets |= _pattern_identifiers(decl.id)
                elif isinstance(left, JsIdentifier):
                    loop_targets.add(left.name)
                elif isinstance(left, (
                    JsArrayExpression, JsObjectExpression, JsArrayPattern, JsObjectPattern,
                )):
                    loop_targets |= _pattern_identifiers(left)
                for name in loop_targets:
                    rejected.add(name)
                    candidates.pop(name, None)
                    uninitialized.pop(name, None)

        model = effects.model
        candidate_bindings: dict[str, Binding] = {}
        for cand_name, cand_entries in candidates.items():
            decl = cand_entries[0].declarator
            if decl is not None and isinstance(decl.id, JsIdentifier):
                binding = model.binding_of(decl.id)
                if binding is not None:
                    candidate_bindings[cand_name] = binding

        def _reject(target_name: str) -> None:
            rejected.add(target_name)
            candidates.pop(target_name, None)
            uninitialized.pop(target_name, None)
            candidate_bindings.pop(target_name, None)

        functions = [node for node in scope.walk() if isinstance(node, FUNCTION_NODES)]

        for cand_name, binding in list(candidate_bindings.items()):
            if (
                binding.has_global_member_write
                or model.binding_dynamic_rebind_sites(binding) is None
                or a_host_reaches_the_binding(model, binding, self.options)
            ):
                _reject(cand_name)

        for func in functions:
            written = effects.mutated_bindings(func)
            if not written:
                continue
            touched = [n for n, binding in candidate_bindings.items() if binding in written]
            if not touched:
                continue
            if effects.function_escapes(func):
                for cand_name in touched:
                    _reject(cand_name)

        return candidates, rejected

    @staticmethod
    def _candidate_binding(entry: _CandidateEntry, model: SemanticModel) -> Binding | None:
        """
        The binding a candidate entry defines, resolved through its declarator identifier, or `None` when
        the entry carries no single-identifier declarator. This is the binding whose value the reaching
        query tracks from the definition to a use.
        """
        decl = entry.declarator
        if decl is None or not isinstance(decl.id, JsIdentifier):
            return None
        return model.binding_of(decl.id)

    def _decide_constants(
        self,
        scope: Node,
        candidates: dict[str, list[_CandidateEntry]],
        cache: ModelCache,
    ) -> list[_Substitution]:
        """
        Decide the constant (literal and literal-array) inlines of one round. A reference is inlined
        only where the definition's value provably reaches it unchanged (`ReachingModel.value_preserved`),
        covering both scalar references and computed index access into all-literal arrays. The value
        each decision records is the entry snapshot's own node; the plan clones it where it applies.
        """
        bloat_blocked: set[str] = set()

        decl_ids = _candidate_decl_ids(candidates)
        ref_counts = _count_scope_references(
            scope, set(candidates), decl_ids, count_member_access=True,
        )

        for name, entries in candidates.items():
            if len(entries) != 1:
                continue
            value = entries[0].value
            count = ref_counts.get(name, 0)
            if count <= 1:
                continue
            if isinstance(value, JsStringLiteral) and value.value is not None:
                if len(value.value) > self.max_inline_length:
                    bloat_blocked.add(name)

        effects = cache.effects
        reaching = cache.reaching
        model = effects.model

        constant_names = {
            name for name, entries in candidates.items()
            if any(_is_constant_value(e.value) or _is_intrinsic_alias_value(effects, e.value) for e in entries)
        }
        if not constant_names:
            return []

        substitutions: list[_Substitution] = []
        for node in walk_scope(scope, include_root_body=True):
            if isinstance(node, JsMemberExpression) and node.computed:
                obj = node.object
                if (
                    isinstance(obj, JsIdentifier)
                    and id(obj) not in decl_ids
                    and obj.name in constant_names
                    and obj.name not in bloat_blocked
                    and self._index_array_immutable(obj, effects, direct_eval_ordered=True)
                ):
                    entry = candidates[obj.name][0]
                    binding = self._candidate_binding(entry, model)
                    if binding is not None and reaching.value_preserved(binding, entry.value, node):
                        element = self._index_access_element(node, entry)
                        if element is not None:
                            substitutions.append(_Substitution(node, element, obj.name))
                    continue
            if not isinstance(node, JsIdentifier):
                continue
            if id(node) in decl_ids:
                continue
            name = node.name
            if name not in constant_names or name in bloat_blocked:
                continue
            parent = node.parent
            if reference_role(node) is not Role.READ:
                continue
            if isinstance(parent, JsMemberExpression) and parent.object is node and parent.computed:
                continue
            entry = candidates[name][0]
            if not is_literal(entry.value):
                continue
            binding = self._candidate_binding(entry, model)
            if binding is None or model.resolve(node) is not binding:
                continue
            if not reaching.value_preserved(binding, entry.value, node):
                continue
            substitutions.append(_Substitution(node, entry.value, name))

        substitutions.extend(self._decide_const_across_functions(
            scope, candidates, decl_ids, bloat_blocked, cache,
        ))

        return substitutions

    @staticmethod
    def _index_array_immutable(
        obj: JsIdentifier, effects: EffectModel, direct_eval_ordered: bool = False,
    ) -> bool:
        """
        Whether the array binding referenced by *obj* is an immutable, non-escaping container, so that
        `obj[idx]` may be inlined to its literal element. Resolved through the binding (not the textual
        name), so shadowing is respected; the model memoizes the judgment for the model's lifetime. A
        name that does not resolve to a local binding (a free or global array) is treated as unsafe.
        *direct_eval_ordered* carries the caller's promise that it has ordered the direct-`eval`
        sites against the read it is folding — the `value_preserved` check the same arm
        makes — so a local array in a function holding a direct `eval` stays inlinable where the
        read is ordered before every eval site.
        """
        binding = effects.model.resolve(obj)
        if binding is None:
            return False
        return effects.binding_is_immutable_container(
            binding, direct_eval_ordered=direct_eval_ordered)

    @staticmethod
    def _index_access_element(member: JsMemberExpression, entry: _CandidateEntry) -> Node | None:
        """
        The literal element an index access into a candidate's all-literal array resolves to, or
        `None` when the access names no in-bounds literal element.
        """
        prop = member.property
        if not isinstance(prop, JsNumericLiteral):
            return None
        idx = exact_integer(prop.value)
        if idx is None:
            return None
        value = entry.value
        if not isinstance(value, JsArrayExpression):
            return None
        if not (0 <= idx < len(value.elements)):
            return None
        element = value.elements[idx]
        if element is None or not is_literal(element):
            return None
        return element

    def _decide_const_across_functions(
        self,
        scope: Node,
        candidates: dict[str, list[_CandidateEntry]],
        decl_ids: set[int],
        bloat_blocked: set[str],
        cache: ModelCache,
    ) -> list[_Substitution]:
        """
        Decide the constant-valued inlines of one round that cross into nested function bodies. A
        `const`-qualified candidate, or an uninitialized `var` later assigned a single constant, is
        inlined into a function only when the value provably runs before every invocation of that
        function (`DominanceModel.runs_before_function`) — otherwise a call could read the value
        early: a stale read for the `var` form, a temporal-dead-zone throw for the `const` form. A
        `var`/`let` candidate that carries its own initializer is additionally restricted by
        `_reader_qualifies` to a reader that neither writes the binding nor runs an invocation the
        value does not precede — a named function invoked in scope, an anonymous IIFE, an arrow, or
        a function stored in a binding, each of whose invocation points is enumerable and ordered;
        an uncalled named function is refused, since its ordering answer is the vacuous one an
        empty set of reference points gives. The interprocedural runs-before check subsumes the
        earlier escape and statement-position heuristics: a function cannot be invoked before a
        reference to it has been evaluated, so it orders the value against every point the function
        is referenced — recursing up the call graph for a reference that lies inside another
        function — and inlines only when the value dominates all of them, refusing whenever a
        reference cannot be ordered (its binding is reassigned or redeclared, or it lies on a call
        cycle).

        A candidate a dynamic scope could rewrite is refused here
        (`binding_maybe_reassigned_dynamically`): this consumer's ordering runs the value before an
        *invocation*, and an invocation cannot be ordered against the site that rewrites the value
        — a name read once can be stored and invoked arbitrarily later, past the hazard — so the
        reads that fold on the located hazards are the same-function ones, which the reaching
        query answers at the use itself.
        """
        effects = cache.effects
        dominance = cache.dominance
        model = effects.model
        cross_candidates: dict[str, list[_CandidateEntry]] = {}
        cross_bindings: dict[str, Binding] = {}
        const_names: set[str] = set()
        for name, entries in candidates.items():
            if name in bloat_blocked:
                continue
            if len(entries) != 1:
                continue
            entry = entries[0]
            if entry.declarator is None or not isinstance(entry.declarator.id, JsIdentifier):
                continue
            intrinsic_alias = _is_intrinsic_alias_value(effects, entry.value)
            if not (_is_constant_value(entry.value) or intrinsic_alias):
                continue
            binding = model.binding_of(entry.declarator.id)
            if binding is None:
                continue
            cross_candidates[name] = entries
            cross_bindings[name] = binding
            if _is_const_qualified(entry.declarator) or entry.declarator.init is None or intrinsic_alias:
                const_names.add(name)

        if not cross_candidates:
            return []

        outer = model.scope_of(scope)
        assert outer is not None
        owner = enclosing_function(scope)

        called_funcs = _collect_called_functions(scope, effects)
        a_callee_is_unstated = _calls_a_function_of_this_file_by_an_unstated_name(scope, effects)

        for name in [
            candidate for candidate, binding in cross_bindings.items()
            if model.binding_maybe_reassigned_dynamically(binding)
            or (a_callee_is_unstated and effects.some_function_can_mutate(binding))
            or any(effects.function_can_mutate(func, binding) for func in called_funcs)
        ]:
            del cross_candidates[name]
            del cross_bindings[name]
        if not cross_candidates:
            return []

        substitutions: list[_Substitution] = []
        for node in scope.walk():
            if isinstance(node, JsMemberExpression) and node.computed:
                obj = node.object
                if (
                    isinstance(obj, JsIdentifier)
                    and id(obj) not in decl_ids
                    and obj.name in cross_candidates
                    and self._index_array_immutable(obj, effects)
                ):
                    name = obj.name
                    enclosing = enclosing_function(obj)
                    if enclosing is None or enclosing is owner:
                        continue
                    if model.resolve(obj) is not cross_bindings[name]:
                        continue
                    if name in const_names:
                        if not dominance.runs_before_function(
                            cross_candidates[name][0].value, enclosing,
                        ):
                            continue
                    elif not _reader_qualifies(
                        cross_candidates[name][0].value, enclosing,
                        cross_bindings[name], effects, dominance,
                    ):
                        continue
                    if model.is_shadowed(name, obj, outer):
                        continue
                    element = self._index_access_element(node, cross_candidates[name][0])
                    if element is not None:
                        substitutions.append(_Substitution(node, element, name))
                    continue
            if not isinstance(node, JsIdentifier):
                continue
            if id(node) in decl_ids:
                continue
            name = node.name
            if name not in cross_candidates:
                continue
            parent = node.parent
            if reference_role(node) is not Role.READ:
                continue
            if isinstance(parent, JsMemberExpression) and parent.object is node and parent.computed:
                continue
            if isinstance(parent, JsVariableDeclarator) and parent.id is node:
                continue
            if isinstance(parent, (JsFunctionDeclaration, JsFunctionExpression)) and parent.id is node:
                continue
            entry = cross_candidates[name][0]
            intrinsic_alias = _is_intrinsic_alias_value(effects, entry.value)
            if not (is_literal(entry.value) or intrinsic_alias):
                continue
            enclosing = enclosing_function(node)
            if enclosing is None or enclosing is owner:
                continue
            if model.resolve(node) is not cross_bindings[name]:
                continue
            if name in const_names:
                if not dominance.runs_before_function(entry.value, enclosing):
                    continue
            elif not _reader_qualifies(
                entry.value, enclosing, cross_bindings[name], effects, dominance,
            ):
                continue
            if model.is_shadowed(name, node, outer):
                continue
            if (
                intrinsic_alias
                and isinstance(entry.value, JsIdentifier)
                and model.lookup(entry.value.name, model.scope_of(node)) is not None
            ):
                continue
            substitutions.append(_Substitution(node, entry.value, name))
        return substitutions

    def _decide_expressions(
        self,
        scope: Node,
        candidates: dict[str, list[_CandidateEntry]],
        mutated: set[str],
        cache: ModelCache,
    ) -> list[_Substitution]:
        """
        Decide the single-use, side-effect-free, non-literal expression inlines of one round.
        Relocating the initializer to its use is sound only when the value it computed still holds
        there — which means the candidate's own binding reaches the use unchanged *and* every
        variable the initializer reads holds, at the use, the value it held at the definition.
        `ReachingModel.value_preserved` decides each: the candidate binding for ordering and its
        own kills, then one query per resolved free variable. The `_is_primitive_and_pure` gate
        keeps the initializer free of side effects and reference identity, and the `mutated` gate
        rejects a free variable written through a name no binding resolves (a global reassigned in
        scope), which the binding-keyed reaching query cannot see. A candidate whose initializer
        reads a bare name through a `with` body's dynamic scope needs no separate gate here: such
        an initializer is inside the `with` body, so the candidate's own binding is written in a
        dynamic scope and the reaching query orders that hazard against the relocated read.
        """
        decl_ids = _candidate_decl_ids(candidates)
        ref_counts = _count_scope_references(scope, set(candidates), decl_ids)

        to_inline: dict[str, _CandidateEntry] = {}
        for name, entries in candidates.items():
            if len(entries) != 1:
                continue
            entry = entries[0]
            init = entry.value
            count = ref_counts.get(name, 0)
            if is_literal(init) or _is_literal_array(init) or count != 1:
                continue
            if not _is_primitive_and_pure(init):
                continue
            if collect_identifier_names(init) & mutated:
                continue
            to_inline[name] = entry

        if not to_inline:
            return []

        reaching = cache.reaching
        model = cache.effects.model

        substitutions: list[_Substitution] = []
        for node in walk_scope(scope, include_root_body=True):
            if not isinstance(node, JsIdentifier):
                continue
            if id(node) in decl_ids:
                continue
            name = node.name
            if name not in to_inline:
                continue
            if reference_role(node) is not Role.READ:
                continue
            entry = to_inline[name]
            binding = self._candidate_binding(entry, model)
            if binding is None or not reaching.value_preserved(binding, entry.value, node):
                continue
            if not self._free_variables_preserved(entry.value, node, model, reaching):
                continue
            substitutions.append(_Substitution(node, entry.value, name))
        return substitutions

    @staticmethod
    def _free_variables_preserved(
        value: Node, use: Node, model: SemanticModel, reaching: ReachingModel,
    ) -> bool:
        """
        Whether re-evaluating *value* at *use* yields the same result it had where *value* was
        defined. Each name *value* reads must resolve, from *use*'s scope, to the binding it read
        where it was defined — a name a block or function around *use* shadows would read a
        different binding there, so a global read at the definition becomes a local read once
        substituted — and, where that binding exists, must still hold the value it held
        (`ReachingModel.value_preserved`). A free name that stays free preserves its value by the
        caller's `mutated` gate, so only its resolution is checked here. This is the twin of
        `JsCallWrapperInliner._forwarded_callee_reaches`, which guards the same capture for a
        forwarded callee.
        """
        use_scope = model.scope_of(use)
        for ident in value.walk():
            if not isinstance(ident, JsIdentifier) or not model.is_reference(ident):
                continue
            binding = model.resolve(ident)
            if model.lookup(ident.name, use_scope) is not binding:
                return False
            if binding is not None and not reaching.value_preserved(binding, value, use):
                return False
        return True

    def _decide_dead_declarators(
        self,
        candidates: dict[str, list[_CandidateEntry]],
        planned: set[str],
        cache: ModelCache,
    ) -> list[_DeclaratorRemoval]:
        """
        Decide which declarators of the round-inlined names are up for removal. No reference count
        is read here — one taken before the round's plans apply cannot see the reads another plan
        of the same round deletes — so every name the round substitutes is proposed and the plan
        counts references at apply time. An exported binding keeps its declarator even with every
        local read inlined, because an importer still reads it across the module boundary;
        removing it would leave an `export` naming a binding the module no longer declares. A
        binding that code the model cannot read could name keeps its declarator for the same
        reason: every read this file spells may have folded, and the surface — a direct `eval`,
        a span of source the model never read, a `with` body — reads the binding through no
        reference the inlining counted, so removing the declaration would turn its value into
        a `ReferenceError`. An opaque global write is not such a surface: it stores a property
        and runs nothing, and the reads it could replace were ordered against it before they
        folded, so the property it may write is the same residual the fold already concedes.
        """
        model = cache.model
        removals: list[_DeclaratorRemoval] = []
        for name in planned:
            entries = candidates.get(name)
            if entries is None:
                continue
            for entry in entries:
                if entry.declarator is None:
                    continue
                binding = self._candidate_binding(entry, model)
                if binding is not None and (
                    binding.exported
                    or model.reachable_by_opaque_reflection(binding)
                    or bool(binding.dynamic_refs)
                ):
                    continue
                removals.append(_DeclaratorRemoval(entry.declarator, name))
        return removals

    @staticmethod
    def _collect_member_array_candidates(scope: Node) -> dict[str, _MemberArrayEntry]:
        """
        Collect member-expression assignments of all-literal arrays: `X.Y = [literals...]`.
        Returns a dict keyed by `"X.Y"` to `_MemberArrayEntry`. Only single-assignment,
        non-aliased properties qualify.
        """
        candidates: dict[str, _MemberArrayEntry] = {}
        rejected: set[str] = set()
        prefix_rejected: set[str] = set()

        for node in walk_scope(scope, include_root_body=True):
            if not isinstance(node, JsAssignmentExpression) or node.operator != '=':
                continue
            lhs = node.left
            if not isinstance(lhs, JsMemberExpression) or lhs.computed:
                continue
            if not isinstance(lhs.object, JsIdentifier) or not isinstance(lhs.property, JsIdentifier):
                continue
            key = F'{lhs.object.name}.{lhs.property.name}'
            if key in rejected:
                continue
            rhs = node.right
            if not isinstance(rhs, JsArrayExpression) or not _is_literal_array(rhs):
                rejected.add(key)
                candidates.pop(key, None)
                continue
            if key in candidates:
                rejected.add(key)
                candidates.pop(key)
                continue
            candidates[key] = _MemberArrayEntry(node, rhs)

        if not candidates:
            return candidates

        prefix_names = {k.split('.', 1)[0] for k in candidates}
        for node in walk_scope(scope, include_root_body=True):
            if isinstance(node, JsAssignmentExpression) and node.operator == '=':
                if isinstance(node.left, JsIdentifier) and node.left.name in prefix_names:
                    prefix_rejected.add(node.left.name)
            if isinstance(node, JsUpdateExpression) and isinstance(node.argument, JsIdentifier):
                if node.argument.name in prefix_names:
                    prefix_rejected.add(node.argument.name)

        if prefix_rejected:
            candidates = {
                k: v for k, v in candidates.items()
                if k.split('.', 1)[0] not in prefix_rejected
            }

        for key in list(candidates):
            prefix, prop = key.split('.', 1)
            if not _is_member_array_safe(scope, prefix, prop):
                del candidates[key]

        return candidates

    def _decide_member_arrays(
        self,
        scope: Node,
        member_arrays: dict[str, _MemberArrayEntry],
    ) -> list[_Substitution]:
        """
        Decide the `X.Y[N]` → element inlines of one round for all collected member-array
        candidates. Walks the full subtree (including nested function bodies) since these arrays
        are scope-level constants.
        """
        substitutions: list[_Substitution] = []
        for node in scope.walk():
            if not isinstance(node, JsMemberExpression) or not node.computed:
                continue
            prop = node.property
            if not isinstance(prop, JsNumericLiteral):
                continue
            obj = node.object
            if not isinstance(obj, JsMemberExpression) or obj.computed:
                continue
            if not isinstance(obj.object, JsIdentifier) or not isinstance(obj.property, JsIdentifier):
                continue
            key = F'{obj.object.name}.{obj.property.name}'
            entry = member_arrays.get(key)
            if entry is None:
                continue
            idx = exact_integer(prop.value)
            if idx is None or not (0 <= idx < len(entry.array.elements)):
                continue
            element = entry.array.elements[idx]
            if element is None or not is_literal(element):
                continue
            substitutions.append(_Substitution(node, element, key))
        return substitutions

    def _decide_dead_member_arrays(
        self,
        member_arrays: dict[str, _MemberArrayEntry],
        planned: set[str],
    ) -> list[_MemberArrayRemoval]:
        return [
            _MemberArrayRemoval(entry.assignment, key)
            for key, entry in member_arrays.items()
            if key in planned
        ]

    @staticmethod
    def _count_member_array_accesses(scope: Node, keys: set[str]) -> dict[str, int]:
        """
        The computed `X.Y[...]` accesses standing in *scope*'s subtree per key in *keys*, counted
        over the tree as it stands when asked — the plan asks once its substitutions have landed.
        """
        remaining: dict[str, int] = {}
        for node in scope.walk():
            if not isinstance(node, JsMemberExpression) or not node.computed:
                continue
            obj = node.object
            if not isinstance(obj, JsMemberExpression) or obj.computed:
                continue
            if not isinstance(obj.object, JsIdentifier) or not isinstance(obj.property, JsIdentifier):
                continue
            key = F'{obj.object.name}.{obj.property.name}'
            if key in keys:
                remaining[key] = remaining.get(key, 0) + 1
        return remaining

    def _apply_plan(self, plan: _ScopePlan) -> None:
        for sub in plan.substitutions:
            if not is_attached(sub.target):
                continue
            clone = _clone_node(sub.value)
            if isinstance(sub.target, JsIdentifier):
                substituted = substitute_use_position(sub.target, clone)
            else:
                substituted = _replace_in_parent(sub.target, clone)
            if not substituted:
                continue
            self.mark_changed()
            self._edits_applied += 1
        if plan.declarator_removals:
            ref_counts = _count_scope_references(
                plan.scope, plan.planned, plan.decl_ids, walk_full=True,
            )
            for removal in plan.declarator_removals:
                if ref_counts.get(removal.key, 0) > 0:
                    continue
                if not is_attached(removal.declarator):
                    continue
                remove_declarator(removal.declarator)
                self.mark_changed()
                self._edits_applied += 1
        if plan.member_array_removals:
            keys = {removal.key for removal in plan.member_array_removals}
            remaining = self._count_member_array_accesses(plan.scope, keys)
            for removal in plan.member_array_removals:
                if remaining.get(removal.key, 0) > 0:
                    continue
                if not is_attached(removal.assignment):
                    continue
                stmt = removal.assignment.parent
                if isinstance(stmt, JsExpressionStatement) and _remove_from_parent(stmt):
                    self.mark_changed()
                    self._edits_applied += 1

Ancestors

Methods

def visit_JsScript(self, node)
Expand source code Browse git
def visit_JsScript(self, node: JsScript):
    self._root = node
    while True:
        self._edits_applied = 0
        super().visit_JsScript(node)
        if not self._edits_applied:
            break
    return None

Inherited members