Module refinery.lib.scripts.js.deobfuscation.cff.statemachine

Recover original code from generator-based state-machine CFF dispatchers.

Handles the pattern where a function body is replaced with a generator function containing a while/switch state machine driven by multiple state variables whose sum is the switch discriminant. Each case updates the state via relative += assignments.

Expand source code Browse git
"""
Recover original code from generator-based state-machine CFF dispatchers.

Handles the pattern where a function body is replaced with a generator function containing a
while/switch state machine driven by multiple state variables whose sum is the switch
discriminant. Each case updates the state via relative `+=` assignments.
"""
from __future__ import annotations

from collections import deque
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, NamedTuple

from refinery.lib.scripts import Expression, Node, Statement, _clone_node, _replace_in_parent
from refinery.lib.scripts.js.deobfuscation.helpers import (
    BodyProcessingTransformer,
    access_key,
    eval_binary_op,
    is_reference,
    make_numeric_literal,
    member_key,
    property_key,
)
from refinery.lib.scripts.js.model import (
    JsArrayExpression,
    JsArrayPattern,
    JsArrowFunctionExpression,
    JsAssignmentExpression,
    JsAssignmentPattern,
    JsBinaryExpression,
    JsBlockStatement,
    JsBooleanLiteral,
    JsBreakStatement,
    JsCallExpression,
    JsCatchClause,
    JsContinueStatement,
    JsExpressionStatement,
    JsFunctionDeclaration,
    JsFunctionExpression,
    JsIdentifier,
    JsIfStatement,
    JsLabeledStatement,
    JsLogicalExpression,
    JsMemberExpression,
    JsNumericLiteral,
    JsObjectExpression,
    JsObjectPattern,
    JsProperty,
    JsRestElement,
    JsReturnStatement,
    JsScript,
    JsSequenceExpression,
    JsSpreadElement,
    JsStringLiteral,
    JsSwitchCase,
    JsSwitchStatement,
    JsUnaryExpression,
    JsUpdateExpression,
    JsVariableDeclaration,
    JsVariableDeclarator,
    JsVarKind,
    JsWhileStatement,
    JsWithStatement,
)

if TYPE_CHECKING:
    _StateEnv = dict[str, int | float]

_MAX_STEPS = 2000


class _CallSiteInfo(NamedTuple):
    initial_state: list[int | float]
    did_return_var: str | None
    result_var: str | None
    scaffolding_end: int


class _WrapperFunctionInfo(NamedTuple):
    initial_state: list[int | float]
    rest_param_name: str | None


@dataclass
class _SMRawAssignment:
    """
    A single unevaluated state variable assignment: `name op= rhs`.
    """
    name: str
    operator: str
    rhs: Expression


@dataclass
class _SMLinearTransition:
    """
    Unconditional state transition: a sequence of `+=` or `=` assignments to state variables.
    """
    assignments: list[_SMRawAssignment]


@dataclass
class _SMConditionalTransition:
    """
    Conditional state transition: an if/else where each branch sets different state values.
    """
    condition: Expression
    true_assignments: list[_SMRawAssignment]
    false_assignments: list[_SMRawAssignment]
    true_prefix: list[Statement] = field(default_factory=list)
    false_prefix: list[Statement] = field(default_factory=list)


@dataclass
class _SMExitTransition:
    """
    The state machine reaches the end state after this block.
    """
    pass


if TYPE_CHECKING:
    _SMTransition = _SMLinearTransition | _SMConditionalTransition | _SMExitTransition


@dataclass
class _SMBlock:
    """
    A single state in the machine: payload statements plus a transition.
    """
    state_id: int | float
    payload: list[Statement]
    transition: _SMTransition


@dataclass
class _GeneratorCFFMatch:
    """
    Structural match result for a generator-based state-machine CFF pattern.
    """
    generator_name: str
    state_var_names: list[str]
    initial_state: list[int | float]
    end_state: int | float
    switch_stmt: JsSwitchStatement
    switch_label: str | None
    scope_param_name: str | None
    arg_var_name: str | None
    did_return_var: str | None
    result_var: str | None
    gen_decl_index: int
    scaffolding_end: int
    with_redirect_var: str | None = None
    scope_default_props: list[str] = field(default_factory=list)
    scope_default_inits: dict[str, Expression] = field(default_factory=dict)
    arg_params: list[str] = field(default_factory=list)
    scope_prop_names: set[str] = field(default_factory=set)
    namespaces: set[str] = field(default_factory=set)
    namespace_homes: dict[str, tuple[str, ...]] = field(default_factory=dict)
    scope_arg_namespaces: dict[str, Expression] = field(default_factory=dict)

    @property
    def qualifies_namespaces(self) -> bool:
        """
        Whether this generator uses the with-redirect namespace-qualification path: a single scope
        default namespace together with a redirect variable. When false, bare identifiers inside the
        `with` are left unqualified.
        """
        return self.with_redirect_var is not None and len(self.scope_default_props) == 1


def _eval_expr(node: Expression, env: _StateEnv) -> int | float | None:
    """
    Recursively evaluate an arithmetic expression against a variable environment. Returns `None`
    when the expression cannot be resolved.
    """
    if isinstance(node, JsNumericLiteral):
        return node.value
    if isinstance(node, JsIdentifier):
        return env.get(node.name)
    if isinstance(node, JsMemberExpression):
        key = member_key(node)
        if key is not None:
            return env.get(key)
        return None
    if isinstance(node, JsUnaryExpression) and node.prefix and node.operand is not None:
        if node.operator == '-':
            inner = _eval_expr(node.operand, env)
            return -inner if inner is not None else None
        if node.operator == '+':
            return _eval_expr(node.operand, env)
    if isinstance(node, JsLogicalExpression) and node.left is not None and node.right is not None:
        if node.operator == '&&':
            lhs = _eval_expr(node.left, env)
            if lhs is None:
                return None
            if not lhs:
                return lhs
            return _eval_expr(node.right, env)
        if node.operator == '||':
            lhs = _eval_expr(node.left, env)
            if lhs is None:
                return None
            if lhs:
                return lhs
            return _eval_expr(node.right, env)
    if isinstance(node, JsBinaryExpression) and node.left is not None and node.right is not None:
        lhs = _eval_expr(node.left, env)
        rhs = _eval_expr(node.right, env)
        if lhs is None or rhs is None:
            return None
        result = eval_binary_op(node.operator, lhs, rhs)
        if result is None:
            return None
        if isinstance(result, bool):
            return int(result)
        if isinstance(result, float):
            try:
                int_val = int(result)
            except (OverflowError, ValueError):
                return None
            if result == int_val:
                return int_val
        return result
    return None


def _is_discriminant_sum(node: Expression, var_names: list[str]) -> bool:
    """
    Check whether an expression is the sum of the given state variable identifiers.
    """
    collected: list[str] = []
    _collect_sum_idents(node, collected)
    return sorted(collected) == sorted(var_names)


def _collect_sum_idents(node: Expression, out: list[str]) -> bool:
    if isinstance(node, JsIdentifier):
        out.append(node.name)
        return True
    if isinstance(node, JsBinaryExpression) and node.operator == '+':
        if node.left is not None and node.right is not None:
            return _collect_sum_idents(node.left, out) and _collect_sum_idents(node.right, out)
    return False


def _extract_with_redirect_var(
    with_obj: Expression | None,
    scope_param_name: str | None,
) -> str | None:
    """
    Parse the `with(scope.W || scope)` pattern to extract the redirect property name `W`.
    """
    if scope_param_name is None or with_obj is None:
        return None
    if not isinstance(with_obj, JsLogicalExpression) or with_obj.operator != '||':
        return None
    lhs = with_obj.left
    rhs = with_obj.right
    if not isinstance(rhs, JsIdentifier) or rhs.name != scope_param_name:
        return None
    if not isinstance(lhs, JsMemberExpression):
        return None
    if not isinstance(lhs.object, JsIdentifier) or lhs.object.name != scope_param_name:
        return None
    if lhs.computed:
        if not isinstance(lhs.property, JsStringLiteral):
            return None
        return lhs.property.value
    if not isinstance(lhs.property, JsIdentifier):
        return None
    return lhs.property.name


class _ScopeDefaults(NamedTuple):
    prop_names: list[str]
    initializers: dict[str, Expression]


def _extract_scope_default_props(
    params: list, scope_param_name: str | None,
) -> _ScopeDefaults:
    """
    Extract namespace property names and their initializer expressions from the scope parameter's
    default value. For the pattern `scope = { MpAqdCF: {} }` this returns a tuple of the form

        (['MpAqdCF'], {'MpAqdCF': <JsObjectExpression>})
    """
    if scope_param_name is None:
        return _ScopeDefaults([], {})
    for p in params:
        if not isinstance(p, JsAssignmentPattern):
            continue
        if not isinstance(p.left, JsIdentifier) or p.left.name != scope_param_name:
            continue
        if not isinstance(p.right, JsObjectExpression):
            return _ScopeDefaults([], {})
        names: list[str] = []
        inits: dict[str, Expression] = {}
        for prop in p.right.properties:
            if not isinstance(prop, JsProperty):
                continue
            key = property_key(prop)
            if key is not None:
                names.append(key)
                if prop.value is not None:
                    inits[key] = prop.value
        return _ScopeDefaults(names, inits)
    return _ScopeDefaults([], {})


def _namespace_member_home(
    node: Expression | None,
    scope_param_name: str | None,
    ns_names: set[str],
) -> tuple[str, str] | None:
    """
    If *node* is an assignment target that defines a namespace-local slot — either `NS.prop` on a
    bare namespace identifier or `scope.NS.prop` on the scope parameter — return the pair
    `(member_name, home_namespace)`. Any other target yields `None`.
    """
    if not isinstance(node, JsMemberExpression):
        return None
    obj = node.object
    if isinstance(obj, JsIdentifier):
        if obj.name in ns_names:
            member = access_key(node)
            if member is not None:
                return (member, obj.name)
        return None
    if (
        scope_param_name is not None
        and isinstance(obj, JsMemberExpression)
        and isinstance(obj.object, JsIdentifier)
        and obj.object.name == scope_param_name
    ):
        home = access_key(obj)
        if home is not None and home in ns_names:
            member = access_key(node)
            if member is not None:
                return (member, home)
    return None


def _collect_assignment_targets(
    target: Expression | None,
    scope_param_name: str | None,
    ns_names: set[str],
    out: dict[str, set[str]],
) -> None:
    """
    Record the home namespace of every namespace-local slot written by *target*, mapping each member
    name to the set of namespaces it is written under. Recurses through array and object
    destructuring patterns so that `[NS.a, NS.b] = …` and `({p: NS.c} = …)` are covered, including
    the object-literal spelling the parser leaves as an expression for a nested property value, so
    that a namespace member destructured at any depth still contributes its home.
    """
    home = _namespace_member_home(target, scope_param_name, ns_names)
    if home is not None:
        member, namespace = home
        out.setdefault(member, set()).add(namespace)
        return
    if isinstance(target, (JsArrayPattern, JsArrayExpression)):
        for elem in target.elements:
            if elem is not None:
                _collect_assignment_targets(elem, scope_param_name, ns_names, out)
    elif isinstance(target, (JsObjectPattern, JsObjectExpression)):
        for prop in target.properties:
            if isinstance(prop, JsProperty):
                _collect_assignment_targets(prop.value, scope_param_name, ns_names, out)
            elif isinstance(prop, (JsRestElement, JsSpreadElement)):
                _collect_assignment_targets(prop.argument, scope_param_name, ns_names, out)
    elif isinstance(target, JsAssignmentPattern):
        _collect_assignment_targets(target.left, scope_param_name, ns_names, out)
    elif isinstance(target, (JsRestElement, JsSpreadElement)):
        _collect_assignment_targets(target.argument, scope_param_name, ns_names, out)


def _redirect_assignment_target(
    expr: Expression,
    scope_param_name: str,
    redirect_var: str,
) -> str | None:
    """
    If *expr* is the single redirect-routing assignment `scope.redirect_var = scope.TARGET` (dot or
    computed on either side), return the TARGET namespace name. Any other expression yields `None`.
    """
    if not isinstance(expr, JsAssignmentExpression) or expr.operator != '=':
        return None
    lhs = expr.left
    if not isinstance(lhs, JsMemberExpression):
        return None
    if not isinstance(lhs.object, JsIdentifier) or lhs.object.name != scope_param_name:
        return None
    if access_key(lhs) != redirect_var:
        return None
    rhs = expr.right
    if not isinstance(rhs, JsMemberExpression):
        return None
    if not isinstance(rhs.object, JsIdentifier) or rhs.object.name != scope_param_name:
        return None
    return access_key(rhs)


def _scope_arg_object(
    node: Node,
    generator_name: str,
    num_state_vars: int,
) -> JsObjectExpression | None:
    """
    If *node* is a call `generator_name(states…, scopeObj, …)` whose argument at the scope position
    (index *num_state_vars*) is an object literal, return that object literal. This is the
    structural scope argument threaded through the shared generator by its wrapper functions.
    """
    if not isinstance(node, JsCallExpression):
        return None
    if not isinstance(node.callee, JsIdentifier) or node.callee.name != generator_name:
        return None
    args = node.arguments
    if len(args) <= num_state_vars:
        return None
    candidate = args[num_state_vars]
    if isinstance(candidate, JsObjectExpression):
        return candidate
    return None


def _collect_namespaces(
    switch_stmt: JsSwitchStatement,
    scope_param_name: str | None,
    scope_default_props: list[str],
    redirect_var: str | None,
    generator_name: str,
    num_state_vars: int,
) -> tuple[set[str], dict[str, Expression]]:
    """
    Collect the sibling namespace objects on the scope and the structural scope-argument namespaces.
    A namespace is any of: a scope default, a redirect target `scope.redirect_var = scope.TARGET`
    found anywhere in the switch (including inside nested functions), or a key of an object-literal
    scope argument passed to the shared generator. The second return value maps each object-literal
    scope-argument key whose value is an empty object literal to that initializer, identifying the
    namespaces that must be materialized as `var X = {}` beyond the scope defaults.
    """
    namespaces: set[str] = set(scope_default_props)
    scope_arg_inits: dict[str, Expression] = {}
    for node in switch_stmt.walk():
        if (
            scope_param_name is not None
            and redirect_var is not None
            and isinstance(node, JsAssignmentExpression)
        ):
            target = _redirect_assignment_target(node, scope_param_name, redirect_var)
            if target is not None:
                namespaces.add(target)
        scope_arg = _scope_arg_object(node, generator_name, num_state_vars)
        if scope_arg is None:
            continue
        for prop in scope_arg.properties:
            if not isinstance(prop, JsProperty):
                continue
            key = property_key(prop)
            if key is None:
                continue
            namespaces.add(key)
            if isinstance(prop.value, JsObjectExpression) and not prop.value.properties:
                scope_arg_inits.setdefault(key, prop.value)
    return namespaces, scope_arg_inits


def _collect_namespace_homes(
    switch_stmt: JsSwitchStatement,
    scope_param_name: str | None,
    ns_names: set[str],
) -> dict[str, tuple[str, ...]] | None:
    """
    Determine the canonical home namespace of every proven namespace-local member, or return
    `None` to decline recovery when a member is ambiguous. Each `=` assignment target in the
    switch — at any nesting depth, since the scope and namespace objects are closed over — is
    fed through the destructuring collector to accumulate `member -> {home…}`. A member written
    under exactly one namespace maps to that namespace as its home path. A member written under
    two or more namespaces has no single canonical home: under the `with`-redirect its meaning
    depends on which namespace the routing variable points at when each use executes, which
    redirect-independent qualification cannot express — so recovery of the whole generator is
    declined (`None`) rather than emitting a reference that would resolve to the wrong binding or
    a free variable. A name with no namespace-defining write at all is genuinely free/global and
    is absent from the map, so it stays bare and resolves to its outer binding once the `with` is
    dissolved. Any assignment operator (plain, compound, or logical) and the update operators
    (`++`/`--`) count as a defining write, since each names the slot as living on its namespace.
    """
    accumulated: dict[str, set[str]] = {}
    for node in switch_stmt.walk():
        if isinstance(node, JsAssignmentExpression):
            _collect_assignment_targets(node.left, scope_param_name, ns_names, accumulated)
        elif isinstance(node, JsUpdateExpression):
            _collect_assignment_targets(node.argument, scope_param_name, ns_names, accumulated)
    homes: dict[str, tuple[str, ...]] = {}
    for member, home_set in accumulated.items():
        if len(home_set) >= 2:
            return None
        homes[member] = (next(iter(home_set)),)
    return homes


def _match_generator_cff(body: list[Statement], idx: int) -> _GeneratorCFFMatch | None:
    """
    Starting at index *idx* in *body*, test whether the statement is a generator function
    declaration matching the state machine CFF pattern, with its call site following.
    """
    stmt = body[idx]
    if not isinstance(stmt, JsFunctionDeclaration):
        return None
    if not stmt.generator:
        return None
    if stmt.id is None:
        return None
    gen_name = stmt.id.name
    if stmt.body is None:
        return None
    params = stmt.params
    if len(params) < 3:
        return None
    scope_param_name: str | None = None
    arg_var_name: str | None = None
    state_var_names: list[str] = []
    for p in params:
        if isinstance(p, JsIdentifier):
            if scope_param_name is not None:
                arg_var_name = p.name
                break
            state_var_names.append(p.name)
        elif isinstance(p, JsAssignmentPattern) and isinstance(p.left, JsIdentifier):
            scope_param_name = p.left.name
        else:
            return None
    if not state_var_names:
        return None
    gen_body = stmt.body.body
    if len(gen_body) != 1:
        return None
    while_stmt = gen_body[0]
    if not isinstance(while_stmt, JsWhileStatement):
        return None
    if while_stmt.test is None or while_stmt.body is None:
        return None
    if not isinstance(while_stmt.test, JsBinaryExpression):
        return None
    if while_stmt.test.operator != '!==':
        return None
    lhs = while_stmt.test.left
    rhs = while_stmt.test.right
    if lhs is None or rhs is None:
        return None
    end_state: int | float | None = None
    if _is_discriminant_sum(lhs, state_var_names):
        end_state = _eval_expr(rhs, {})
    elif _is_discriminant_sum(rhs, state_var_names):
        end_state = _eval_expr(lhs, {})
    if end_state is None:
        return None
    inner: Statement | None = while_stmt.body
    if isinstance(inner, JsBlockStatement) and len(inner.body) == 1:
        inner = inner.body[0]
    with_redirect_var: str | None = None
    scope_default_props: list[str] = []
    scope_default_inits: dict[str, Expression] = {}
    if isinstance(inner, JsWithStatement):
        with_redirect_var = _extract_with_redirect_var(inner.object, scope_param_name)
        scope_default_props, scope_default_inits = _extract_scope_default_props(params, scope_param_name)
        inner = inner.body
    if isinstance(inner, JsBlockStatement) and len(inner.body) == 1:
        inner = inner.body[0]
    switch_label: str | None = None
    if isinstance(inner, JsLabeledStatement):
        if inner.label is not None:
            switch_label = inner.label.name
        inner = inner.body
    if not isinstance(inner, JsSwitchStatement):
        return None
    if inner.discriminant is None:
        return None
    if not _is_discriminant_sum(inner.discriminant, state_var_names):
        return None
    switch_stmt = inner
    call_info = _find_generator_call_site(body, idx, gen_name)
    if call_info is None:
        return None
    if len(call_info.initial_state) != len(state_var_names):
        return None
    namespaces: set[str] = set()
    namespace_homes: dict[str, tuple[str, ...]] = {}
    scope_arg_namespaces: dict[str, Expression] = {}
    if with_redirect_var is not None and len(scope_default_props) == 1:
        namespaces, scope_arg_namespaces = _collect_namespaces(
            switch_stmt,
            scope_param_name,
            scope_default_props,
            with_redirect_var,
            gen_name,
            len(state_var_names),
        )
        homes = _collect_namespace_homes(switch_stmt, scope_param_name, namespaces)
        if homes is None:
            return None
        namespace_homes = homes
    return _GeneratorCFFMatch(
        generator_name=gen_name,
        state_var_names=state_var_names,
        initial_state=call_info.initial_state,
        end_state=end_state,
        switch_stmt=switch_stmt,
        switch_label=switch_label,
        scope_param_name=scope_param_name,
        arg_var_name=arg_var_name,
        did_return_var=call_info.did_return_var,
        result_var=call_info.result_var,
        gen_decl_index=idx,
        scaffolding_end=call_info.scaffolding_end,
        with_redirect_var=with_redirect_var,
        scope_default_props=scope_default_props,
        scope_default_inits=scope_default_inits,
        namespaces=namespaces,
        namespace_homes=namespace_homes,
        scope_arg_namespaces=scope_arg_namespaces,
    )


def _find_generator_call_site(
    body: list[Statement],
    gen_idx: int,
    gen_name: str,
) -> _CallSiteInfo | None:
    """
    Scan forward from *gen_idx* to find the call site pattern:

        var didReturn;
        var result = genName(args)["next"]()["value"];
        if (didReturn) { return result; }

    Returns a `_CallSiteInfo` or `None`.
    Skips over intervening declarations (function declarations, other var decls) that are not
    part of the generator scaffolding.
    """
    pos = gen_idx + 1
    did_return_var: str | None = None
    result_var: str | None = None
    while pos < len(body):
        candidate = body[pos]
        if isinstance(candidate, JsVariableDeclaration):
            decls = candidate.declarations
            if (
                len(decls) == 1
                and isinstance(decls[0], JsVariableDeclarator)
                and isinstance(decls[0].id, JsIdentifier)
                and decls[0].init is None
            ):
                did_return_var = decls[0].id.name
                pos += 1
                continue
        if isinstance(candidate, JsFunctionDeclaration):
            pos += 1
            continue
        if isinstance(candidate, JsExpressionStatement):
            expr = candidate.expression
            if (
                isinstance(expr, JsAssignmentExpression)
                and expr.operator == '='
                and isinstance(expr.left, JsIdentifier)
                and isinstance(expr.right, JsUnaryExpression)
                and expr.right.operator == 'void'
            ):
                did_return_var = expr.left.name
                pos += 1
                continue
        break
    if pos >= len(body):
        return None
    call_expr = _extract_generator_call(body[pos], gen_name)
    if call_expr is None:
        return None
    call_node, result_var = call_expr
    initial_state: list[int | float] = []
    for arg in call_node.arguments:
        val = _eval_expr(arg, {})
        if val is None:
            return None
        initial_state.append(val)
    scaffolding_end = pos
    if scaffolding_end + 1 < len(body) and did_return_var is not None:
        guard = body[scaffolding_end + 1]
        if (
            isinstance(guard, JsIfStatement)
            and isinstance(guard.test, JsIdentifier)
            and guard.test.name == did_return_var
        ):
            scaffolding_end += 1
    return _CallSiteInfo(initial_state, did_return_var, result_var, scaffolding_end)


class _GeneratorCallInfo(NamedTuple):
    call_node: JsCallExpression
    result_var: str | None


def _extract_generator_call(
    stmt: Statement,
    gen_name: str,
) -> _GeneratorCallInfo | None:
    """
    Extract a generator call from a statement. Handles:
    - var X = gen(...)["next"]()["value"];
    - gen(...)["next"]()["value"];
    - return gen(...)["next"]()["value"];

    Returns a `(call, name)` pair (the inner call to gen and the result variable name) or `None`.
    """
    result_name: str | None = None
    expr: Expression | None = None
    if isinstance(stmt, JsVariableDeclaration):
        if len(stmt.declarations) != 1:
            return None
        decl = stmt.declarations[0]
        if not isinstance(decl, JsVariableDeclarator):
            return None
        if isinstance(decl.id, JsIdentifier):
            result_name = decl.id.name
        expr = decl.init
    elif isinstance(stmt, JsExpressionStatement):
        expr = stmt.expression
        if isinstance(expr, JsAssignmentExpression) and expr.operator == '=':
            if isinstance(expr.left, JsIdentifier):
                result_name = expr.left.name
            expr = expr.right
    elif isinstance(stmt, JsReturnStatement):
        expr = stmt.argument
    else:
        return None
    if expr is None:
        return None
    gen_call = _unwrap_next_value(expr)
    if gen_call is None:
        if isinstance(expr, JsCallExpression):
            gen_call = expr
        else:
            return None
    if not isinstance(gen_call, JsCallExpression):
        return None
    if not isinstance(gen_call.callee, JsIdentifier):
        return None
    if gen_call.callee.name != gen_name:
        return None
    return _GeneratorCallInfo(gen_call, result_name)


def _unwrap_next_value(node: Expression) -> JsCallExpression | None:
    """
    Unwrap the `gen(...)` call from `gen(...).next().value`. Works when `next` and `value` are
    accessed as properties or as keys.
    """
    if not isinstance(node, JsMemberExpression):
        return None
    key = access_key(node)
    if key != 'value':
        return None
    next_call = node.object
    if not isinstance(next_call, JsCallExpression) or next_call.arguments:
        return None
    next_access = next_call.callee
    if not isinstance(next_access, JsMemberExpression):
        return None
    if access_key(next_access) != 'next':
        return None
    gen_call = next_access.object
    if not isinstance(gen_call, JsCallExpression):
        return None
    return gen_call


def _detect_wrapper_function(
    node: Expression,
    gen_name: str,
    num_state_vars: int,
) -> _WrapperFunctionInfo | None:
    """
    Test whether *node* is a wrapper function expression of the form:

        function(...rest) { return gen(states..., scope, rest)["next"]()["value"]; }

    Returns a `_WrapperFunctionInfo` or `None`.
    """
    if not isinstance(node, JsFunctionExpression):
        return None
    if node.body is None:
        return None
    body = node.body.body
    if len(body) != 1:
        return None
    stmt = body[0]
    if not isinstance(stmt, JsReturnStatement) or stmt.argument is None:
        return None
    gen_call = _unwrap_next_value(stmt.argument)
    if gen_call is None:
        return None
    if not isinstance(gen_call.callee, JsIdentifier):
        return None
    if gen_call.callee.name != gen_name:
        return None
    args = gen_call.arguments
    if len(args) < num_state_vars + 1:
        return None
    initial_state: list[int | float] = []
    for arg in args[:num_state_vars]:
        val = _eval_expr(arg, {})
        if val is None:
            return None
        initial_state.append(val)
    rest_param_name: str | None = None
    params = node.params
    if params:
        last_param = params[-1]
        if isinstance(last_param, JsRestElement) and isinstance(last_param.argument, JsIdentifier):
            rest_param_name = last_param.argument.name
        elif isinstance(last_param, JsIdentifier):
            rest_param_name = last_param.name
    return _WrapperFunctionInfo(initial_state, rest_param_name)


@dataclass
class _StateMachine:
    """
    Complete parsed state machine with both statically-resolved and predicate-gated cases.
    """
    blocks: dict[int | float, _SMBlock]
    predicate_cases: list[tuple[Expression, _SMBlock]]
    default_block: _SMBlock | None = None


def _extract_state_blocks(
    match: _GeneratorCFFMatch,
) -> _StateMachine | None:
    """
    Parse the switch cases into a state machine. Cases with statically resolvable tests go into
    `blocks`; those with predicate tests (referencing state vars) go into `predicate_cases` for
    runtime resolution. A `default:` case becomes the fallback block.
    """
    var_names = match.state_var_names
    label = match.switch_label
    end_state = match.end_state
    blocks: dict[int | float, _SMBlock] = {}
    predicate_cases: list[tuple[Expression, _SMBlock]] = []
    default_block: _SMBlock | None = None
    pending_tests: list[JsSwitchCase] = []
    for case in match.switch_stmt.cases:
        if not isinstance(case, JsSwitchCase):
            return None
        if not case.body:
            pending_tests.append(case)
            continue
        all_cases = list(pending_tests) + [case]
        pending_tests.clear()
        stmts = list(case.body)
        parsed = _parse_case_body(stmts, var_names, label)
        if parsed is None:
            continue
        payload, transition = parsed
        has_default = any(c.test is None for c in all_cases)
        resolved = False
        block_obj = _SMBlock(state_id=0, payload=payload, transition=transition)
        for c in all_cases:
            if c.test is None:
                continue
            val = _eval_expr(c.test, {})
            if val is not None:
                if val != end_state and val not in blocks:
                    if block_obj.state_id == 0:
                        block_obj.state_id = val
                    blocks[val] = block_obj
                resolved = True
            else:
                predicate_cases.append((c.test, block_obj))
        if has_default:
            default_block = block_obj
        if not resolved and not has_default and not any(
            c.test is not None and _eval_expr(c.test, {}) is None for c in all_cases
        ):
            continue
    if not blocks and not predicate_cases and default_block is None:
        return None
    return _StateMachine(blocks=blocks, predicate_cases=predicate_cases, default_block=default_block)


def _parse_case_body(
    stmts: list[Statement],
    var_names: list[str],
    switch_label: str | None,
) -> tuple[list[Statement], _SMTransition] | None:
    """
    Separate a case body into payload statements and a state transition.
    """
    if not stmts:
        return None
    stmts = _strip_trailing_labeled_break(stmts, switch_label)
    if not stmts:
        return ([], _SMExitTransition())
    last = stmts[-1]
    if isinstance(last, JsExpressionStatement) and isinstance(last.expression, JsSequenceExpression):
        assignments = _extract_state_assignments(last.expression, var_names)
        if assignments is not None:
            non_state = _extract_non_state_expressions(last.expression, var_names)
            payload = list(stmts[:-1])
            if non_state:
                payload.append(JsExpressionStatement(expression=non_state))
            return (payload, _SMLinearTransition(assignments=assignments))
    trailing = _collect_trailing_state_assignments(stmts, var_names)
    if trailing is not None:
        assignments, split_idx = trailing
        return (stmts[:split_idx], _SMLinearTransition(assignments=assignments))
    if isinstance(last, JsIfStatement) and last.consequent is not None and last.alternate is not None:
        cond_result = _parse_conditional_transition(last, var_names, switch_label)
        if cond_result is not None:
            payload = stmts[:-1]
            return (payload, cond_result)
    if isinstance(last, JsReturnStatement):
        return (stmts, _SMExitTransition())
    return None


def _strip_trailing_labeled_break(stmts: list[Statement], label: str | None) -> list[Statement]:
    """
    Remove a trailing `break label;` that targets the switch label.
    """
    if not stmts:
        return stmts
    last = stmts[-1]
    if isinstance(last, JsBreakStatement):
        if last.label is None or (label is not None and last.label.name == label):
            return stmts[:-1]
    return stmts


def _extract_state_assignments(
    seq: JsSequenceExpression,
    var_names: list[str],
) -> list[_SMRawAssignment] | None:
    """
    Extract state variable assignments from a sequence expression without evaluating them.
    Non-state assignments (scope/with updates) are skipped.
    """
    result: list[_SMRawAssignment] = []
    for expr in seq.expressions:
        if not isinstance(expr, JsAssignmentExpression):
            continue
        if not isinstance(expr.left, JsIdentifier):
            continue
        name = expr.left.name
        if name not in var_names:
            continue
        if expr.right is None:
            return None
        if expr.operator not in ('=', '+='):
            return None
        result.append(_SMRawAssignment(name=name, operator=expr.operator, rhs=expr.right))
    if not result:
        return None
    return result


def _extract_non_state_expressions(
    seq: JsSequenceExpression,
    var_names: list[str],
) -> Expression | None:
    """
    Collect non-state-variable expressions from a sequence. Returns a single expression (or
    sequence expression) for the payload, or None if all expressions are state assignments.
    """
    remaining: list[Expression] = []
    for expr in seq.expressions:
        if isinstance(expr, JsAssignmentExpression) and isinstance(expr.left, JsIdentifier):
            if expr.left.name in var_names:
                continue
        remaining.append(expr)
    if not remaining:
        return None
    if len(remaining) == 1:
        return remaining[0]
    return JsSequenceExpression(expressions=remaining)


def _collect_trailing_state_assignments(
    stmts: list[Statement],
    var_names: list[str],
) -> tuple[list[_SMRawAssignment], int] | None:
    """
    Scan backwards from the end of the statement list to collect all consecutive state-variable
    assignment statements. Returns the collected assignments and the split index (where payload
    ends), or None if no trailing state assignments found.
    """
    assignments: list[_SMRawAssignment] = []
    i = len(stmts) - 1
    while i >= 0:
        stmt = stmts[i]
        if not isinstance(stmt, JsExpressionStatement):
            break
        if not isinstance(stmt.expression, JsAssignmentExpression):
            break
        expr = stmt.expression
        if not isinstance(expr.left, JsIdentifier):
            break
        if expr.left.name not in var_names:
            break
        if expr.right is None:
            break
        if expr.operator not in ('=', '+='):
            break
        assignments.append(_SMRawAssignment(name=expr.left.name, operator=expr.operator, rhs=expr.right))
        i -= 1
    if not assignments:
        return None
    assignments.reverse()
    return (assignments, i + 1)


def _extract_single_assignment(
    expr: JsAssignmentExpression,
    var_names: list[str],
) -> list[_SMRawAssignment] | None:
    """
    Extract a single state variable assignment.
    """
    if not isinstance(expr.left, JsIdentifier):
        return None
    name = expr.left.name
    if name not in var_names:
        return None
    if expr.right is None:
        return None
    if expr.operator not in ('=', '+='):
        return None
    return [_SMRawAssignment(name=name, operator=expr.operator, rhs=expr.right)]


def _apply_raw_transition(
    assignments: list[_SMRawAssignment],
    current: _StateEnv,
) -> _StateEnv | None:
    """
    Evaluate raw assignments against the current state to produce the new state.
    Left-to-right sequential semantics: each assignment sees the results of prior ones.
    """
    env: _StateEnv = dict(current)
    for assign in assignments:
        val = _eval_expr(assign.rhs, env)
        if val is None:
            return None
        if assign.operator == '+=':
            env[assign.name] = env.get(assign.name, 0) + val
        else:
            env[assign.name] = val
    return env


def _block_stmts(node: Statement) -> list[Statement] | None:
    if isinstance(node, JsBlockStatement):
        return list(node.body)
    return [node]


def _extract_trailing_assignments(
    stmts: list[Statement],
    var_names: list[str],
) -> tuple[list[_SMRawAssignment], list[Statement]] | None:
    """
    Extract the trailing state assignment from a list of statements and return
    (raw_assignments, prefix_statements). For mixed sequence expressions, non-state expressions
    are preserved in the prefix.
    """
    if not stmts:
        return None
    last = stmts[-1]
    if isinstance(last, JsExpressionStatement):
        if isinstance(last.expression, JsSequenceExpression):
            assigns = _extract_state_assignments(last.expression, var_names)
            if assigns is not None:
                non_state = _extract_non_state_expressions(last.expression, var_names)
                prefix = list(stmts[:-1])
                if non_state:
                    prefix.append(JsExpressionStatement(expression=non_state))
                return (assigns, prefix)
        elif isinstance(last.expression, JsAssignmentExpression):
            assigns = _extract_single_assignment(last.expression, var_names)
            if assigns is not None:
                return (assigns, stmts[:-1])
    return None


def _parse_conditional_transition(
    if_stmt: JsIfStatement,
    var_names: list[str],
    switch_label: str | None,
) -> _SMConditionalTransition | None:
    """
    Parse an if/else whose branches both perform state transitions.
    """
    if if_stmt.test is None:
        return None
    true_block = if_stmt.consequent
    false_block = if_stmt.alternate
    if true_block is None or false_block is None:
        return None
    true_stmts = _block_stmts(true_block)
    false_stmts = _block_stmts(false_block)
    if true_stmts is None or false_stmts is None:
        return None
    true_stmts = _strip_trailing_labeled_break(true_stmts, switch_label)
    false_stmts = _strip_trailing_labeled_break(false_stmts, switch_label)
    true_state = _extract_trailing_assignments(true_stmts, var_names)
    false_state = _extract_trailing_assignments(false_stmts, var_names)
    if true_state is None or false_state is None:
        return None
    true_assigns, true_prefix = true_state
    false_assigns, false_prefix = false_state
    return _SMConditionalTransition(
        condition=if_stmt.test,
        true_assignments=true_assigns,
        false_assignments=false_assigns,
        true_prefix=true_prefix,
        false_prefix=false_prefix,
    )


def _compute_discriminant(state: _StateEnv, var_names: list[str]) -> int | float:
    return sum(state.get(n, 0) for n in var_names)


def _lookup_block(machine: _StateMachine, disc: int | float, state: _StateEnv) -> _SMBlock | None:
    """
    Find the block matching the given discriminant. Tries static blocks first, then evaluates
    predicate tests against the current state, then falls back to the default block.
    """
    if disc in machine.blocks:
        return machine.blocks[disc]
    for test_expr, block in machine.predicate_cases:
        val = _eval_expr(test_expr, state)
        if val is not None and val == disc:
            return block
    return machine.default_block


def _apply_initial_state(var_names: list[str], values: list[int | float]) -> _StateEnv:
    return dict(zip(var_names, values))


def _is_state_var_assignment(expr: Expression, var_set: set[str]) -> bool:
    return (
        isinstance(expr, JsAssignmentExpression)
        and isinstance(expr.left, JsIdentifier)
        and expr.left.name in var_set
    )


def _apply_prefix_state_changes(
    prefix: list[Statement],
    var_names: list[str],
    env: _StateEnv,
) -> _StateEnv:
    """
    Scan prefix statements for assignments to state variables and apply them sequentially. This
    handles cases where a conditional's prefix modifies state variables before the trailing
    transition assignment.
    """
    result = dict(env)
    var_set = set(var_names)
    for stmt in prefix:
        if not isinstance(stmt, JsExpressionStatement):
            continue
        expr = stmt.expression
        exprs = expr.expressions if isinstance(expr, JsSequenceExpression) else [expr]
        for e in exprs:
            if not isinstance(e, JsAssignmentExpression):
                continue
            if not isinstance(e.left, JsIdentifier):
                continue
            if e.left.name not in var_set:
                continue
            if e.right is None:
                continue
            rhs_val = _eval_expr(e.right, result)
            if rhs_val is None:
                continue
            name = e.left.name
            if e.operator == '=':
                result[name] = rhs_val
            elif e.operator == '+=':
                result[name] = result.get(name, 0) + rhs_val
            elif e.operator == '-=':
                result[name] = result.get(name, 0) - rhs_val
    return result


def _strip_state_var_assignments(stmts: list[Statement], var_names: list[str]) -> list[Statement]:
    """
    Remove statements that are pure assignments to state variables. These are routing bookkeeping
    that should not appear in the recovered output. For sequence expressions, state var assignments
    are removed while preserving remaining payload expressions.
    """
    var_set = set(var_names)
    result: list[Statement] = []
    for stmt in stmts:
        if not isinstance(stmt, JsExpressionStatement):
            result.append(stmt)
            continue
        expr = stmt.expression
        if expr is None:
            result.append(stmt)
            continue
        if isinstance(expr, JsSequenceExpression):
            remaining = [e for e in expr.expressions if not _is_state_var_assignment(e, var_set)]
            if not remaining:
                continue
            if len(remaining) == 1:
                result.append(JsExpressionStatement(expression=remaining[0]))
            else:
                result.append(JsExpressionStatement(
                    expression=JsSequenceExpression(expressions=remaining),
                ))
        elif _is_state_var_assignment(expr, var_set):
            continue
        else:
            result.append(stmt)
    return result


def _process_branch_prefix(
    prefix: list[Statement],
    var_names: list[str],
    state: _StateEnv,
    match: _GeneratorCFFMatch,
) -> list[Statement]:
    """
    Process a conditional transition's branch prefix through the standard pipeline (strip state
    vars, substitute, filter bookkeeping, strip scope, qualify). Returns the processed statements
    ready for emission as branch-specific payload.
    """
    result = _strip_state_var_assignments(prefix, var_names)
    if not result:
        return []
    result = _substitute_state_vars(result, state)
    if not match.arg_params:
        for s in result:
            params = _extract_arg_param_names(s, match.arg_var_name)
            if params is not None:
                match.arg_params = params
                break
    _collect_scope_props(result, match.scope_param_name, match.scope_prop_names)
    result = [
        s for s in result
        if _extract_arg_param_names(s, match.arg_var_name) is None
    ]
    result = _strip_scope_param_prefix(result, match.scope_param_name)
    result = _qualify_with_identifiers(result, match)
    result = _filter_redirect_var_assignments(result, match)
    return result


_VIRTUAL_EXIT: int = -1


@dataclass
class _CFGNode:
    """
    A node in the control flow graph derived from the state machine. Keyed by block object
    identity (`id(block)`) so that the same logical block visited with different discriminants
    is recognized as a single CFG node — enabling loop detection.
    """
    node_id: int
    payload: list[Statement]
    condition: Expression | None
    successors: list[int] = field(default_factory=list)
    predecessors: list[int] = field(default_factory=list)
    true_prefix_payload: list[Statement] = field(default_factory=list)
    false_prefix_payload: list[Statement] = field(default_factory=list)


@dataclass
class _CFG:
    """
    Control flow graph built from symbolic execution of state machine transitions.
    """
    nodes: dict[int, _CFGNode]
    entry: int
    exit: int


@dataclass
class _NaturalLoop:
    """
    A natural loop identified by a back-edge in the CFG.
    """
    header: int
    body: set[int]
    tails: list[int]
    exits: set[int]


def _build_cfg(
    machine: _StateMachine,
    initial_state: _StateEnv,
    var_names: list[str],
    end_state: int | float,
    match: _GeneratorCFFMatch,
) -> tuple[_CFG, _StateEnv] | None:
    """
    Build a control flow graph by BFS from the initial state. Nodes are keyed by the identity
    of the `_SMBlock` object they correspond to, so the same block reached with different
    discriminants (as happens in loops with relative `+=` transitions) creates a single node
    with a back-edge. Returns the CFG and the accumulated state (including scope routing values).
    """
    entry_state = dict(initial_state)
    entry_disc = _compute_discriminant(entry_state, var_names)
    entry_block = _lookup_block(machine, entry_disc, entry_state)
    if entry_block is None:
        return None

    nodes: dict[int, _CFGNode] = {}
    routing_state: _StateEnv = dict(initial_state)
    queue: deque[tuple[_SMBlock, _StateEnv]] = deque()
    queue.append((entry_block, entry_state))
    steps = 0

    while queue and steps < _MAX_STEPS:
        steps += 1
        block, state = queue.popleft()
        node_id = id(block)

        if node_id in nodes:
            continue

        payload = _substitute_state_vars(block.payload, state)
        _track_scope_routing(payload, state)
        _track_scope_routing(payload, routing_state)
        if not match.arg_params:
            for s in payload:
                params = _extract_arg_param_names(s, match.arg_var_name)
                if params is not None:
                    match.arg_params = params
                    break
        _collect_scope_props(payload, match.scope_param_name, match.scope_prop_names)
        payload = [
            s for s in payload
            if _extract_arg_param_names(s, match.arg_var_name) is None
        ]
        payload = _strip_scope_param_prefix(payload, match.scope_param_name)
        payload = _qualify_with_identifiers(payload, match)
        payload = _filter_redirect_var_assignments(payload, match)

        condition: Expression | None = None
        successors: list[int] = []
        true_prefix_payload: list[Statement] = []
        false_prefix_payload: list[Statement] = []
        transition = block.transition

        if isinstance(transition, _SMExitTransition):
            successors = [_VIRTUAL_EXIT]
        elif isinstance(transition, _SMLinearTransition):
            new_env = _apply_raw_transition(transition.assignments, state)
            if new_env is None:
                return None
            next_disc = _compute_discriminant(new_env, var_names)
            if next_disc == end_state:
                successors = [_VIRTUAL_EXIT]
            else:
                next_block = _lookup_block(machine, next_disc, new_env)
                if next_block is None:
                    return None
                next_id = id(next_block)
                successors = [next_id]
                if next_id not in nodes:
                    queue.append((next_block, new_env))
        elif isinstance(transition, _SMConditionalTransition):
            condition = transition.condition
            true_base = _apply_prefix_state_changes(transition.true_prefix, var_names, state)
            false_base = _apply_prefix_state_changes(transition.false_prefix, var_names, state)
            true_env = _apply_raw_transition(transition.true_assignments, true_base)
            false_env = _apply_raw_transition(transition.false_assignments, false_base)
            if true_env is None or false_env is None:
                return None
            true_disc = _compute_discriminant(true_env, var_names)
            false_disc = _compute_discriminant(false_env, var_names)

            if true_disc == end_state:
                true_id = _VIRTUAL_EXIT
            else:
                true_block = _lookup_block(machine, true_disc, true_env)
                if true_block is None:
                    return None
                true_id = id(true_block)
                if true_id not in nodes:
                    queue.append((true_block, true_env))

            if false_disc == end_state:
                false_id = _VIRTUAL_EXIT
            else:
                false_block = _lookup_block(machine, false_disc, false_env)
                if false_block is None:
                    return None
                false_id = id(false_block)
                if false_id not in nodes:
                    queue.append((false_block, false_env))

            successors = [true_id, false_id]
            true_prefix_payload = _process_branch_prefix(
                transition.true_prefix, var_names, state, match,
            )
            false_prefix_payload = _process_branch_prefix(
                transition.false_prefix, var_names, state, match,
            )
            if match.qualifies_namespaces:
                condition = _qualify_condition(condition, state, match)
            else:
                wrapper = JsExpressionStatement(expression=_clone_node(condition))
                _substitute_in_scope(wrapper, state)
                if match.scope_param_name:
                    _strip_scope_prefix_walk(wrapper, match.scope_param_name)
                condition = wrapper.expression

        node = _CFGNode(
            node_id=node_id,
            payload=payload,
            condition=condition,
            successors=successors,
            true_prefix_payload=true_prefix_payload,
            false_prefix_payload=false_prefix_payload,
        )
        nodes[node_id] = node

    entry_id = id(entry_block)
    if entry_id not in nodes:
        return None

    exit_node = _CFGNode(node_id=_VIRTUAL_EXIT, payload=[], condition=None)
    nodes[_VIRTUAL_EXIT] = exit_node

    for n in nodes.values():
        for succ_id in n.successors:
            if succ_id in nodes:
                nodes[succ_id].predecessors.append(n.node_id)

    return (_CFG(nodes=nodes, entry=entry_id, exit=_VIRTUAL_EXIT), routing_state)


def _compute_idom(cfg: _CFG) -> dict[int, int | None]:
    """
    Compute immediate dominators using the Cooper-Harvey-Kennedy iterative algorithm.
    """
    entry = cfg.entry
    order = _reverse_postorder(cfg)
    node_to_idx = {d: i for i, d in enumerate(order)}
    idom: dict[int, int | None] = {entry: None}

    def intersect(a: int, b: int) -> int:
        ai = node_to_idx[a]
        bi = node_to_idx[b]
        while ai != bi:
            while ai > bi:
                a = idom[a]  # type: ignore
                ai = node_to_idx[a]
            while bi > ai:
                b = idom[b]  # type: ignore
                bi = node_to_idx[b]
        return a

    changed = True
    while changed:
        changed = False
        for disc in order:
            if disc == entry:
                continue
            node = cfg.nodes[disc]
            preds = [p for p in node.predecessors if p in idom]
            if not preds:
                continue
            new_idom = preds[0]
            for p in preds[1:]:
                new_idom = intersect(new_idom, p)
            if idom.get(disc) != new_idom:
                idom[disc] = new_idom
                changed = True

    return idom


def _reverse_postorder(cfg: _CFG) -> list[int]:
    """
    Compute reverse postorder traversal of the CFG from entry.
    """
    visited: set[int] = set()
    order: list[int] = []

    def dfs(disc: int):
        stack: list[tuple[int, int]] = [(disc, 0)]
        while stack:
            current, idx = stack.pop()
            if idx == 0:
                if current in visited:
                    continue
                visited.add(current)
            node = cfg.nodes.get(current)
            if node is None:
                order.append(current)
                continue
            succs = [s for s in node.successors if s in cfg.nodes]
            if idx < len(succs):
                stack.append((current, idx + 1))
                s = succs[idx]
                if s not in visited:
                    stack.append((s, 0))
            else:
                order.append(current)

    dfs(cfg.entry)
    order.reverse()
    return order


def _dominates(idom: dict[int, int | None], a: int, b: int) -> bool:
    """
    Check if node `a` dominates node `b`.
    """
    current = b
    while current is not None:
        if current == a:
            return True
        current = idom.get(current)
    return False


def _compute_ipdom(
    cfg: _CFG,
    exit_id: int,
    region: set[int] | None = None,
) -> dict[int, int | None]:
    """
    Compute immediate post-dominators using Cooper-Harvey-Kennedy on the reverse CFG.
    Post-dominator of X = first node Y that ALL paths from X to exit must pass through.
    """
    exit_preds: list[int] = []
    if exit_id not in cfg.nodes:
        for nid, node in cfg.nodes.items():
            if region is not None and nid not in region:
                continue
            if exit_id in node.successors:
                exit_preds.append(nid)

    visited: set[int] = set()
    rpo: list[int] = []

    def _get_reverse_succs(nid: int) -> list[int]:
        node = cfg.nodes.get(nid)
        if node is None:
            if nid == exit_id:
                return exit_preds
            return []
        preds = node.predecessors
        if region is not None:
            preds = [p for p in preds if p in region]
        return preds

    stack: list[tuple[int, int]] = [(exit_id, 0)]
    while stack:
        current, idx = stack.pop()
        if idx == 0:
            if current in visited:
                continue
            visited.add(current)
        preds = _get_reverse_succs(current)
        if idx < len(preds):
            stack.append((current, idx + 1))
            p = preds[idx]
            if p not in visited:
                stack.append((p, 0))
        else:
            rpo.append(current)

    rpo.reverse()
    node_to_idx = {d: i for i, d in enumerate(rpo)}
    ipdom: dict[int, int | None] = {exit_id: None}

    def intersect(a: int, b: int) -> int:
        ai: int = node_to_idx[a]
        bi: int = node_to_idx[b]
        while ai != bi:
            while ai > bi:
                a = ipdom[a]  # type: ignore
                ai = node_to_idx[a]
            while bi > ai:
                b = ipdom[b]  # type: ignore
                bi = node_to_idx[b]
        return a

    changed = True
    while changed:
        changed = False
        for disc in rpo:
            if disc == exit_id:
                continue
            node = cfg.nodes.get(disc)
            if node is None:
                continue
            succs = [s for s in node.successors if s in ipdom]
            if region is not None:
                succs = [s for s in succs if s in region or s == exit_id]
            if not succs:
                continue
            new_ipdom = succs[0]
            for s in succs[1:]:
                new_ipdom = intersect(new_ipdom, s)
            if ipdom.get(disc) != new_ipdom:
                ipdom[disc] = new_ipdom
                changed = True

    return ipdom


def _find_loops(cfg: _CFG, idom: dict[int, int | None]) -> list[_NaturalLoop]:
    """
    Identify natural loops from back-edges. A back-edge is (tail -> header) where header
    dominates tail. The loop body is the set of nodes that can reach the tail without leaving
    the header's dominance.
    """
    back_edges: list[tuple[int, int]] = []
    for disc, node in cfg.nodes.items():
        for succ in node.successors:
            if succ in cfg.nodes and _dominates(idom, succ, disc):
                back_edges.append((disc, succ))

    loops_by_header: dict[int, _NaturalLoop] = {}
    for tail, header in back_edges:
        if header not in loops_by_header:
            body = _compute_loop_body(cfg, header, tail)
            exits: set[int] = set()
            for b in body:
                n = cfg.nodes[b]
                for s in n.successors:
                    if s not in body and s in cfg.nodes:
                        exits.add(b)
            loops_by_header[header] = _NaturalLoop(
                header=header, body=body, tails=[tail], exits=exits,
            )
        else:
            loop = loops_by_header[header]
            loop.tails.append(tail)
            extra = _compute_loop_body(cfg, header, tail)
            loop.body |= extra
            for b in loop.body:
                n = cfg.nodes[b]
                for s in n.successors:
                    if s not in loop.body and s in cfg.nodes:
                        loop.exits.add(b)

    return list(loops_by_header.values())


def _compute_loop_body(cfg: _CFG, header: int, tail: int) -> set[int]:
    """
    Compute the natural loop body: all nodes that can reach `tail` without going through
    `header`, plus `header` itself.
    """
    body: set[int] = {header}
    if tail == header:
        return body
    body.add(tail)
    worklist: list[int] = [tail]
    while worklist:
        node_disc = worklist.pop()
        n = cfg.nodes.get(node_disc)
        if n is None:
            continue
        for pred in n.predecessors:
            if pred not in body and pred in cfg.nodes:
                body.add(pred)
                worklist.append(pred)
    return body


def _structural_analysis(
    cfg: _CFG,
    idom: dict[int, int | None],
    loops: list[_NaturalLoop],
) -> list[Statement]:
    """
    Recover structured control flow from the CFG using region-based structural analysis.
    Process loops innermost-first, then structure acyclic regions.
    """
    sorted_loops = _sort_loops_innermost_first(loops)
    collapsed: dict[int, list[Statement]] = {}
    loop_headers: set[int] = set()

    for loop in sorted_loops:
        loop_headers.add(loop.header)
        body_stmts = _structure_loop(cfg, loop, idom, collapsed)
        collapsed[loop.header] = body_stmts
        for body_node in loop.body:
            if body_node != loop.header and body_node not in collapsed:
                collapsed[body_node] = []

    return _structure_acyclic_region(cfg, cfg.entry, cfg.exit, idom, collapsed, loop_headers)


def _sort_loops_innermost_first(loops: list[_NaturalLoop]) -> list[_NaturalLoop]:
    """
    Sort loops so that inner (smaller body) loops are processed before outer ones.
    """
    return sorted(loops, key=lambda lp: len(lp.body))


def _structure_loop(
    cfg: _CFG,
    loop: _NaturalLoop,
    idom: dict[int, int | None],
    collapsed: dict[int, list[Statement]],
) -> list[Statement]:
    """
    Structure a single natural loop into a while/do-while statement.
    """
    header = loop.header
    header_node = cfg.nodes[header]

    if (
        header_node.condition is not None
        and len(header_node.successors) == 2
        and not header_node.payload
    ):
        true_succ, false_succ = header_node.successors
        if true_succ not in loop.body and true_succ in cfg.nodes:
            body_entry = false_succ
            condition = JsUnaryExpression(operator='!', operand=header_node.condition, prefix=True)
            body_prefix = header_node.false_prefix_payload
            exit_prefix = header_node.true_prefix_payload
        elif false_succ not in loop.body and false_succ in cfg.nodes:
            body_entry = true_succ
            condition = header_node.condition
            body_prefix = header_node.true_prefix_payload
            exit_prefix = header_node.false_prefix_payload
        else:
            return _structure_loop_infinite(cfg, loop, idom, collapsed)

        body_stmts = _structure_acyclic_region(
            cfg, body_entry, header, idom, collapsed, set(),
            loop_body=loop.body,
        )
        body_stmts = list(header_node.payload) + list(body_prefix) + body_stmts
        while_stmt = JsWhileStatement(
            test=condition,
            body=JsBlockStatement(body=body_stmts),
        )
        return [while_stmt] + list(exit_prefix)

    return _structure_loop_infinite(cfg, loop, idom, collapsed)


def _structure_loop_infinite(
    cfg: _CFG,
    loop: _NaturalLoop,
    idom: dict[int, int | None],
    collapsed: dict[int, list[Statement]],
) -> list[Statement]:
    """
    Structure a loop that doesn't have a simple while-condition as `while(true)` with breaks.
    """
    header = loop.header
    body_stmts = _structure_region_nodes(cfg, header, idom, collapsed, loop.body)
    while_stmt = JsWhileStatement(
        test=JsBooleanLiteral(value=True),
        body=JsBlockStatement(body=body_stmts),
    )
    return [while_stmt]


def _structure_acyclic_region(
    cfg: _CFG,
    entry: int,
    exit_disc: int,
    idom: dict[int, int | None],
    collapsed: dict[int, list[Statement]],
    loop_headers: set[int],
    loop_body: set[int] | None = None,
    _visited: set[int] | None = None,
) -> list[Statement]:
    """
    Structure an acyclic region from `entry` to `exit_disc` into a statement sequence.
    Handles if/else patterns using post-dominator-based join detection.
    """
    result: list[Statement] = []
    visited: set[int] = _visited if _visited is not None else set()
    worklist: deque[int] = deque([entry])

    while worklist:
        disc = worklist.popleft()
        if disc == exit_disc or disc == _VIRTUAL_EXIT:
            continue
        if disc in visited:
            continue
        if loop_body is not None and disc not in loop_body:
            continue
        visited.add(disc)

        if disc in collapsed:
            result.extend(collapsed[disc])
            node = cfg.nodes[disc]
            for s in node.successors:
                if s not in visited and s != exit_disc and s != _VIRTUAL_EXIT:
                    if loop_body is None or s in loop_body:
                        worklist.append(s)
            continue

        node = cfg.nodes.get(disc)
        if node is None:
            continue

        if node.condition is not None and len(node.successors) == 2:
            result.extend(node.payload)
            true_succ, false_succ = node.successors
            join = _find_acyclic_join(cfg, disc, exit_disc, loop_body)

            true_stmts: list[Statement] = list(node.true_prefix_payload)
            true_visited = set(visited)
            if true_succ != join and true_succ not in visited:
                true_stmts.extend(_structure_acyclic_region(
                    cfg, true_succ, join, idom, collapsed, loop_headers, loop_body, true_visited,
                ))
            false_stmts: list[Statement] = list(node.false_prefix_payload)
            false_visited = set(visited)
            if false_succ != join and false_succ not in visited:
                false_stmts.extend(_structure_acyclic_region(
                    cfg, false_succ, join, idom, collapsed, loop_headers, loop_body, false_visited,
                ))
            visited.update(true_visited)
            visited.update(false_visited)

            if_stmt = _build_js_if(node.condition, true_stmts, false_stmts)
            if if_stmt is not None:
                result.append(if_stmt)

            if join != _VIRTUAL_EXIT and join != exit_disc and join not in visited:
                worklist.appendleft(join)
        else:
            result.extend(node.payload)
            for s in node.successors:
                if s == exit_disc or s == _VIRTUAL_EXIT:
                    continue
                if s in visited:
                    continue
                if loop_body is not None and s not in loop_body:
                    result.append(JsBreakStatement())
                    continue
                worklist.append(s)

    return result


def _find_acyclic_join(
    cfg: _CFG,
    cond_disc: int,
    region_exit: int,
    loop_body: set[int] | None,
) -> int:
    """
    Find the join point of a conditional by computing its immediate post-dominator within
    the region. The ipdom is the first node where ALL paths from both successors converge.
    """
    region: set[int] = set()
    queue: deque[int] = deque([cond_disc])
    while queue:
        d = queue.popleft()
        if d in region or d == _VIRTUAL_EXIT:
            continue
        if d == region_exit:
            region.add(d)
            continue
        if loop_body is not None and d not in loop_body:
            continue
        region.add(d)
        node = cfg.nodes.get(d)
        if node is not None:
            for s in node.successors:
                if s not in region:
                    queue.append(s)

    if not region or cond_disc not in region:
        return region_exit

    region.add(region_exit)
    ipdom = _compute_ipdom(cfg, region_exit, region)
    join = ipdom.get(cond_disc)
    if join is None or (loop_body is not None and join not in loop_body and join != region_exit):
        return region_exit
    return join


def _structure_region_nodes(
    cfg: _CFG,
    header: int,
    idom: dict[int, int | None],
    collapsed: dict[int, list[Statement]],
    loop_body: set[int],
) -> list[Statement]:
    """
    Structure a set of CFG nodes that form a loop body, starting from the header.
    """
    result: list[Statement] = []
    visited: set[int] = set()
    worklist: deque[int] = deque([header])

    while worklist:
        disc = worklist.popleft()
        if disc in visited:
            continue
        if disc not in loop_body:
            result.append(JsBreakStatement())
            continue
        visited.add(disc)

        if disc in collapsed:
            result.extend(collapsed[disc])
            node = cfg.nodes[disc]
            for s in node.successors:
                if s not in visited and s in loop_body:
                    worklist.append(s)
            continue

        node = cfg.nodes.get(disc)
        if node is None:
            continue

        if node.condition is not None and len(node.successors) == 2:
            result.extend(node.payload)
            true_succ, false_succ = node.successors

            true_in_loop = true_succ in loop_body
            false_in_loop = false_succ in loop_body

            if true_succ == header:
                if false_succ not in loop_body:
                    neg = JsUnaryExpression(operator='!', operand=node.condition, prefix=True)
                    break_body = list(node.false_prefix_payload) + [JsBreakStatement()]
                    result.append(JsIfStatement(
                        test=neg,
                        consequent=JsBlockStatement(body=break_body),
                    ))
                    result.extend(node.true_prefix_payload)
                else:
                    continue_body = list(node.true_prefix_payload) + [JsContinueStatement()]
                    result.append(JsIfStatement(
                        test=node.condition,
                        consequent=JsBlockStatement(body=continue_body),
                    ))
                    result.extend(node.false_prefix_payload)
                    worklist.append(false_succ)
                continue
            elif false_succ == header:
                if true_succ not in loop_body:
                    break_body = list(node.true_prefix_payload) + [JsBreakStatement()]
                    result.append(JsIfStatement(
                        test=node.condition,
                        consequent=JsBlockStatement(body=break_body),
                    ))
                    result.extend(node.false_prefix_payload)
                else:
                    neg = JsUnaryExpression(operator='!', operand=node.condition, prefix=True)
                    continue_body = list(node.false_prefix_payload) + [JsContinueStatement()]
                    result.append(JsIfStatement(
                        test=neg,
                        consequent=JsBlockStatement(body=continue_body),
                    ))
                    result.extend(node.true_prefix_payload)
                    worklist.append(true_succ)
                continue

            if not true_in_loop and not false_in_loop:
                if node.true_prefix_payload or node.false_prefix_payload:
                    true_body = list(node.true_prefix_payload) + [JsBreakStatement()]
                    false_body = list(node.false_prefix_payload) + [JsBreakStatement()]
                    if_stmt = _build_js_if(node.condition, true_body, false_body)
                    if if_stmt is not None:
                        result.append(if_stmt)
                    else:
                        result.append(JsBreakStatement())
                else:
                    result.append(JsBreakStatement())
                continue
            if not true_in_loop:
                break_body = list(node.true_prefix_payload) + [JsBreakStatement()]
                result.append(JsIfStatement(
                    test=node.condition,
                    consequent=JsBlockStatement(body=break_body),
                ))
                result.extend(node.false_prefix_payload)
                worklist.append(false_succ)
                continue
            if not false_in_loop:
                neg = JsUnaryExpression(operator='!', operand=node.condition, prefix=True)
                break_body = list(node.false_prefix_payload) + [JsBreakStatement()]
                result.append(JsIfStatement(
                    test=neg,
                    consequent=JsBlockStatement(body=break_body),
                ))
                result.extend(node.true_prefix_payload)
                worklist.append(true_succ)
                continue

            join = _find_acyclic_join(cfg, disc, header, loop_body)
            true_stmts: list[Statement] = list(node.true_prefix_payload)
            true_visited = set(visited)
            if true_succ != join and true_succ not in visited:
                true_stmts.extend(_structure_acyclic_region(
                    cfg, true_succ, join, idom, collapsed, set(), loop_body, true_visited,
                ))
            false_stmts: list[Statement] = list(node.false_prefix_payload)
            false_visited = set(visited)
            if false_succ != join and false_succ not in visited:
                false_stmts.extend(_structure_acyclic_region(
                    cfg, false_succ, join, idom, collapsed, set(), loop_body, false_visited,
                ))
            visited.update(true_visited)
            visited.update(false_visited)
            if_stmt = _build_js_if(node.condition, true_stmts, false_stmts)
            if if_stmt is not None:
                result.append(if_stmt)
            if join != header and join in loop_body and join not in visited:
                worklist.appendleft(join)
        else:
            result.extend(node.payload)
            for s in node.successors:
                if s == header:
                    continue
                if s not in loop_body:
                    result.append(JsBreakStatement())
                    continue
                if s in visited:
                    continue
                worklist.append(s)

    return result


def _substitute_state_vars(stmts: list[Statement], env: _StateEnv) -> list[Statement]:
    """
    Clone statements and replace state variable identifiers with numeric literals. Stops at
    function boundaries to avoid replacing reused names in nested scopes.
    """
    result: list[Statement] = []
    for stmt in stmts:
        cloned = _clone_node(stmt)
        _substitute_in_scope(cloned, env)
        result.append(cloned)
    return result


def _substitute_in_scope(node: Node, env: _StateEnv) -> None:
    """
    Replace state variable identifiers with numeric literals, skipping into nested functions.
    """
    for child in node.children():
        if isinstance(child, (JsFunctionExpression, JsFunctionDeclaration)):
            continue
        if isinstance(child, JsIdentifier) and child.name in env:
            _replace_in_parent(child, make_numeric_literal(env[child.name]))
        else:
            _substitute_in_scope(child, env)


def _strip_scope_param_prefix(
    stmts: list[Statement],
    scope_param_name: str | None,
) -> list[Statement]:
    """
    Rewrite every `scope.X` member access to the bare identifier `X`, dissolving the scope-parameter
    prefix unconditionally. Where a name actually lives is decided afterwards by home-driven
    qualification, which is independent of the momentary `with`-redirect state, so the strip carries
    no namespace and never depends on the traversal's redirect target.
    """
    if scope_param_name is None:
        return stmts
    for stmt in stmts:
        _strip_scope_prefix_walk(stmt, scope_param_name)
    return stmts


def _strip_scope_prefix_walk(node: Node, scope_param_name: str) -> None:
    for child in node.children():
        if isinstance(child, JsMemberExpression) and isinstance(child.object, JsIdentifier):
            if child.object.name != scope_param_name:
                _strip_scope_prefix_walk(child, scope_param_name)
                continue
            if child.computed:
                if not isinstance(child.property, JsStringLiteral):
                    _strip_scope_prefix_walk(child, scope_param_name)
                    continue
                prop_name = child.property.value
            else:
                if not isinstance(child.property, JsIdentifier):
                    _strip_scope_prefix_walk(child, scope_param_name)
                    continue
                prop_name = child.property.name
            _replace_in_parent(child, JsIdentifier(name=prop_name))
            continue
        _strip_scope_prefix_walk(child, scope_param_name)


def _qualify_exempt(match: _GeneratorCFFMatch) -> set[str]:
    """
    The set of names that home-driven qualification must leave bare: the state variables, the
    JavaScript built-in globals, every namespace object, and the compiler-introduced scaffolding
    identifiers (generator, scope parameter, argument holder, return flag, redirect variable).
    """
    exempt: set[str] = set(match.state_var_names) | _JS_BUILTIN_GLOBALS | set(match.namespaces)
    for name in (
        match.generator_name,
        match.scope_param_name,
        match.arg_var_name,
        match.did_return_var,
        match.with_redirect_var,
    ):
        if name is not None:
            exempt.add(name)
    return exempt


def _qualify_condition(
    condition: Expression,
    state: _StateEnv,
    match: _GeneratorCFFMatch,
) -> Expression:
    """
    Clone, substitute, strip, and qualify a transition condition expression using the same
    pipeline as block payloads. Wraps in a synthetic statement so that root-node scope members
    and identifiers are processed correctly.
    """
    wrapper = JsExpressionStatement(expression=_clone_node(condition))
    _substitute_in_scope(wrapper, state)
    if match.scope_param_name:
        _strip_scope_prefix_walk(wrapper, match.scope_param_name)
    if match.qualifies_namespaces:
        _qualify_bare_walk(wrapper, match.namespace_homes, _qualify_exempt(match))
    return wrapper.expression  # type: ignore[return-value]


_JS_BUILTIN_GLOBALS: frozenset[str] = frozenset({
    'globalThis',
    'global',
    'self',
    'window',
    'undefined',
    'NaN',
    'Infinity',
    'eval',
    'isNaN',
    'isFinite',
    'parseInt',
    'parseFloat',
    'decodeURI',
    'decodeURIComponent',
    'encodeURI',
    'encodeURIComponent',
    'Object',
    'Function',
    'Boolean',
    'Symbol',
    'Number',
    'BigInt',
    'Math',
    'Date',
    'String',
    'RegExp',
    'Array',
    'Map',
    'Set',
    'WeakMap',
    'WeakSet',
    'ArrayBuffer',
    'SharedArrayBuffer',
    'DataView',
    'JSON',
    'Promise',
    'Reflect',
    'Proxy',
    'Error',
    'TypeError',
    'RangeError',
    'ReferenceError',
    'SyntaxError',
    'URIError',
    'EvalError',
    'console',
    'setTimeout',
    'setInterval',
    'clearTimeout',
    'clearInterval',
    'require',
    'module',
    'exports',
    'process',
    'Buffer',
    'URL',
    'URLSearchParams',
    'Intl',
    'Atomics',
    'WebAssembly',
})


def _qualify_with_identifiers(
    stmts: list[Statement],
    match: _GeneratorCFFMatch,
) -> list[Statement]:
    """
    Qualify each bare identifier that names a proven namespace-local by prepending its canonical
    home namespace: a bare `x` whose home is `H` becomes `H.x`. The home is fixed at match time and
    is independent of the momentary `with`-redirect, so a slot referenced qualified in one position
    and bare in another canonicalizes to the same `H.x` in both. Only applies under the
    with-redirect pattern with exactly one scope default.
    """
    if not match.qualifies_namespaces:
        return stmts
    exempt = _qualify_exempt(match)
    for stmt in stmts:
        _qualify_bare_walk(stmt, match.namespace_homes, exempt)
    _convert_function_declarations(stmts, match.namespace_homes, exempt)
    return stmts


def _convert_function_declarations(
    stmts: list[Statement],
    homes: dict[str, tuple[str, ...]],
    exempt: set[str],
    owner: Node | None = None,
) -> None:
    """
    Convert a function declaration whose name is a proven namespace-local into a namespace property
    assignment, so every reference to the function goes through its canonical home: a
    `function foo(...)` with home `H` becomes `H.foo = function(...)`. A name with no home is left
    as a free declaration. Recurses into block bodies but not function bodies.
    """
    for i, stmt in enumerate(stmts):
        if isinstance(stmt, JsFunctionDeclaration):
            if stmt.id is not None and stmt.id.name in homes and stmt.id.name not in exempt:
                name = stmt.id.name
                func_expr = JsFunctionExpression(
                    id=None,
                    params=stmt.params,
                    body=stmt.body,
                )
                target = _make_namespace_node([*homes[name], name])
                assignment = JsAssignmentExpression(operator='=', left=target, right=func_expr)
                stmts[i] = JsExpressionStatement(expression=assignment)
                if owner is not None:
                    stmts[i].parent = owner
            continue
        if isinstance(stmt, (JsFunctionExpression, JsBlockStatement)):
            continue
        for child in stmt.children():
            if isinstance(child, JsBlockStatement):
                _convert_function_declarations(child.body, homes, exempt, owner=child)


def _make_namespace_node(ns_path: list[str]) -> Expression:
    """
    Build an AST node for a namespace path: single identifier for length 1,
    nested member expressions for longer paths.
    """
    node: Expression = JsIdentifier(name=ns_path[0])
    for segment in ns_path[1:]:
        node = JsMemberExpression(
            object=node,
            property=JsIdentifier(name=segment),
            computed=False,
        )
    return node


def _qualify_bare_walk(node: Node, homes: dict[str, tuple[str, ...]], exempt: set[str]) -> None:
    for child in node.children():
        if isinstance(child, (JsFunctionExpression, JsFunctionDeclaration, JsArrowFunctionExpression)):
            inner_exempt = exempt | _collect_declared_names(child)
            _qualify_bare_walk(child, homes, inner_exempt)
            continue
        if isinstance(child, JsCatchClause):
            inner_exempt = exempt
            if child.param is not None:
                param_names: set[str] = set()
                _collect_binding_names(child.param, param_names)
                inner_exempt = exempt | param_names
            _qualify_bare_walk(child, homes, inner_exempt)
            continue
        if isinstance(child, JsIdentifier) and child.name in homes and child.name not in exempt:
            parent = child.parent
            if isinstance(parent, JsMemberExpression) and parent.property is child and not parent.computed:
                continue
            if isinstance(parent, JsProperty) and not parent.computed:
                if parent.shorthand and parent.value is child:
                    replacement = _make_namespace_node([*homes[child.name], child.name])
                    replacement.parent = parent
                    parent.value = replacement
                    parent.shorthand = False
                    continue
                if parent.key is child:
                    continue
            if isinstance(parent, (JsVariableDeclarator, JsRestElement)):
                exempt.add(child.name)
                continue
            if isinstance(parent, (JsLabeledStatement, JsContinueStatement, JsBreakStatement)):
                if getattr(parent, 'label', None) is child:
                    continue
            _replace_in_parent(child, _make_namespace_node([*homes[child.name], child.name]))
            continue
        _qualify_bare_walk(child, homes, exempt)


def _collect_declared_names(
    func: JsFunctionExpression | JsFunctionDeclaration | JsArrowFunctionExpression,
) -> set[str]:
    """
    Collect parameter names and var-declared names from a function for exemption. Only collects
    declarations at the function's own scope level — does not descend into nested functions. An
    arrow with an expression body (rather than a block) contributes only its parameter names.
    """
    names: set[str] = set()
    if isinstance(func, JsFunctionDeclaration) and func.id is not None:
        names.add(func.id.name)
    for p in (func.params or []):
        _collect_binding_names(p, names)
    body = func.body
    if isinstance(body, JsBlockStatement):
        queue: deque[Node] = deque(body.body)
        while queue:
            node = queue.popleft()
            if isinstance(node, (JsFunctionExpression, JsFunctionDeclaration, JsArrowFunctionExpression)):
                if isinstance(node, JsFunctionDeclaration) and node.id is not None:
                    names.add(node.id.name)
                continue
            if isinstance(node, JsVariableDeclaration):
                for decl in node.declarations:
                    if isinstance(decl, JsVariableDeclarator):
                        _collect_binding_names(decl.id, names)
            for child in node.children():
                queue.append(child)
    return names


def _collect_binding_names(pattern: Expression | None, out: set[str]) -> None:
    """
    Recursively extract bound identifier names from a binding pattern (simple identifier,
    array pattern, object pattern, rest element, or assignment pattern with default).
    """
    if pattern is None:
        return
    if isinstance(pattern, JsIdentifier):
        out.add(pattern.name)
    elif isinstance(pattern, JsRestElement):
        _collect_binding_names(pattern.argument, out)
    elif isinstance(pattern, JsAssignmentPattern):
        _collect_binding_names(pattern.left, out)
    elif isinstance(pattern, JsArrayPattern):
        for el in pattern.elements:
            _collect_binding_names(el, out)
    elif isinstance(pattern, JsObjectPattern):
        for prop in pattern.properties:
            if isinstance(prop, JsRestElement):
                _collect_binding_names(prop.argument, out)
            elif isinstance(prop, JsProperty) and prop.value is not None:
                _collect_binding_names(prop.value, out)


def _is_did_return_assignment(expr: Expression, did_return_var: str | None) -> bool:
    """
    Check whether an expression is `didReturnVar = true`.
    """
    if did_return_var is None:
        return False
    if not isinstance(expr, JsAssignmentExpression):
        return False
    if not isinstance(expr.left, JsIdentifier):
        return False
    return expr.left.name == did_return_var and expr.operator == '='


def _recover_returns(stmts: list[Statement], did_return_var: str | None) -> list[Statement]:
    """
    Convert sequence expressions of the form

        (didReturn = true, value)

    into JsReturnStatement nodes. Also handles explicit return with the same pattern. Recurses
    into nested structures (if/else branches, while bodies) so that return patterns at any depth
    are recovered.
    """
    if did_return_var is None:
        return stmts
    result: list[Statement] = []
    for stmt in stmts:
        if isinstance(stmt, JsReturnStatement) and stmt.argument is not None:
            arg = stmt.argument
            if isinstance(arg, JsSequenceExpression) and len(arg.expressions) >= 2:
                if _is_did_return_assignment(arg.expressions[0], did_return_var):
                    ret_val = (
                        arg.expressions[1] if len(arg.expressions) == 2
                        else JsSequenceExpression(expressions=arg.expressions[1:])
                    )
                    result.append(JsReturnStatement(argument=ret_val))
                    continue
            result.append(stmt)
            continue
        if isinstance(stmt, JsExpressionStatement) and isinstance(stmt.expression, JsSequenceExpression):
            seq = stmt.expression
            if len(seq.expressions) >= 2 and _is_did_return_assignment(seq.expressions[0], did_return_var):
                ret_val = (
                    seq.expressions[1] if len(seq.expressions) == 2
                    else JsSequenceExpression(expressions=seq.expressions[1:])
                )
                result.append(JsReturnStatement(argument=ret_val))
                continue
        if isinstance(stmt, JsIfStatement):
            if stmt.consequent is not None and isinstance(stmt.consequent, JsBlockStatement):
                stmt.consequent.body = _recover_returns(stmt.consequent.body, did_return_var)
            if stmt.alternate is not None and isinstance(stmt.alternate, JsBlockStatement):
                stmt.alternate.body = _recover_returns(stmt.alternate.body, did_return_var)
            elif isinstance(stmt.alternate, JsIfStatement):
                recovered = _recover_returns([stmt.alternate], did_return_var)
                if recovered:
                    stmt.alternate = recovered[0]
        elif isinstance(stmt, JsWhileStatement):
            if stmt.body is not None and isinstance(stmt.body, JsBlockStatement):
                stmt.body.body = _recover_returns(stmt.body.body, did_return_var)
        result.append(stmt)
    return result


def _is_direct_scope_member(node: Node, scope_param_name: str) -> bool:
    """
    Check if an expression is a depth-1 member access on the scope parameter, i.e. `scope.X` or
    `scope["X"]` but NOT `scope.X.Y`. Only direct slots are CFF routing state; deeper chains are
    semantic writes.
    """
    if not isinstance(node, JsMemberExpression):
        return False
    if not isinstance(node.object, JsIdentifier) or node.object.name != scope_param_name:
        return False
    if node.computed:
        return isinstance(node.property, JsStringLiteral)
    return True


def _extract_arg_param_names(
    stmt: Statement,
    arg_var_name: str | None,
) -> list[str] | None:
    """
    If *stmt* is the argument-destructuring pattern:

        [elem1, elem2, ...] = argVar

    extract parameter names from the LHS elements. Each element is expected to be a
    member-expression chain; the deepest property name is returned. Returns `None` if the
    statement is not the arg-destructuring pattern.
    """
    if arg_var_name is None:
        return None
    if not isinstance(stmt, JsExpressionStatement):
        return None
    expr = stmt.expression
    if not isinstance(expr, JsAssignmentExpression):
        return None
    if not isinstance(expr.left, (JsArrayExpression, JsArrayPattern)):
        return None
    if not isinstance(expr.right, JsIdentifier) or expr.right.name != arg_var_name:
        return None
    names: list[str] = []
    for elem in expr.left.elements:
        if elem is None:
            return None
        name = _deepest_property_name(elem)
        if name is None:
            return None
        names.append(name)
    return names


def _deepest_property_name(node: Node) -> str | None:
    """
    Walk a member-expression chain and return the deepest (rightmost) property name.
    """
    if isinstance(node, JsIdentifier):
        return node.name
    if isinstance(node, JsMemberExpression):
        if isinstance(node.property, JsIdentifier):
            return node.property.name
        if isinstance(node.property, JsStringLiteral):
            return node.property.value
    return None


def _is_bare_redirect_assignment(expr: Expression, redirect_var: str) -> bool:
    """
    Whether *expr* is a stripped redirect-routing write `redirect_var = <namespace>` on the bare
    redirect identifier. Once the scope prefix is dissolved unconditionally, `scope.redirect_var =
    scope.X` survives as this bare assignment of one identifier to another; it is pure routing
    bookkeeping with no consumer in the recovered code. The right-hand side must be a bare
    identifier — the only shape a stripped `scope.X` target can take — so that a coincidental
    side-effecting write to a same-named variable is never discarded.
    """
    return (
        isinstance(expr, JsAssignmentExpression)
        and expr.operator == '='
        and isinstance(expr.left, JsIdentifier)
        and expr.left.name == redirect_var
        and isinstance(expr.right, JsIdentifier)
    )


def _filter_redirect_var_assignments(
    stmts: list[Statement],
    match: _GeneratorCFFMatch,
) -> list[Statement]:
    if not match.qualifies_namespaces:
        return stmts
    redirect_var = match.with_redirect_var
    assert redirect_var is not None
    result: list[Statement] = []
    for stmt in stmts:
        if not isinstance(stmt, JsExpressionStatement) or stmt.expression is None:
            result.append(stmt)
            continue
        expr = stmt.expression
        if isinstance(expr, JsSequenceExpression):
            remaining = [
                e for e in expr.expressions
                if not _is_bare_redirect_assignment(e, redirect_var)
            ]
            if len(remaining) == len(expr.expressions):
                result.append(stmt)
            elif not remaining:
                continue
            elif len(remaining) == 1:
                result.append(JsExpressionStatement(expression=remaining[0]))
            else:
                result.append(JsExpressionStatement(
                    expression=JsSequenceExpression(expressions=remaining),
                ))
            continue
        if _is_bare_redirect_assignment(expr, redirect_var):
            continue
        result.append(stmt)
    return result


def _track_scope_routing(payload: list[Statement], state: _StateEnv) -> None:
    """
    Scan payload for assignments to scope member expressions with evaluable RHS values and record
    them in the state environment. This captures routing variables stored on scope objects.
    """
    for stmt in payload:
        if not isinstance(stmt, JsExpressionStatement):
            continue
        expr = stmt.expression
        if isinstance(expr, JsSequenceExpression):
            exprs = expr.expressions
        else:
            exprs = [expr]
        for e in exprs:
            if not isinstance(e, JsAssignmentExpression):
                continue
            if not isinstance(e.left, JsMemberExpression):
                continue
            if e.operator != '=':
                continue
            key = member_key(e.left)
            if key is None or e.right is None:
                continue
            val = _eval_expr(e.right, state)
            if val is not None:
                state[key] = val


def _execute_machine(
    machine: _StateMachine,
    match: _GeneratorCFFMatch,
    inherited_state: _StateEnv | None = None,
) -> tuple[list[Statement], _StateEnv] | None:
    """
    Recover structured code from the state machine using CFG-based structural analysis.
    Builds a control flow graph, identifies loops via dominator analysis, and emits
    structured control flow (while, if/else, break).
    """
    var_names = match.state_var_names
    state = _apply_initial_state(var_names, match.initial_state)
    if inherited_state:
        for k, v in inherited_state.items():
            if k not in var_names:
                state[k] = v

    cfg_result = _build_cfg(machine, state, var_names, match.end_state, match)
    if cfg_result is None:
        return None

    cfg, final_state = cfg_result
    idom = _compute_idom(cfg)
    loops = _find_loops(cfg, idom)
    stmts = _structural_analysis(cfg, idom, loops)
    recovered = _recover_returns(stmts, match.did_return_var)
    return (recovered, final_state)


def _build_js_if(
    condition: Expression,
    true_body: list[Statement],
    false_body: list[Statement],
) -> JsIfStatement | None:
    """
    Build a JsIfStatement, omitting empty branches.
    """
    if not true_body and not false_body:
        return None
    if not true_body:
        neg = JsUnaryExpression(operator='!', operand=condition, prefix=True)
        return JsIfStatement(
            test=neg,
            consequent=JsBlockStatement(body=false_body),
        )
    if not false_body:
        return JsIfStatement(
            test=condition,
            consequent=JsBlockStatement(body=true_body),
        )
    return JsIfStatement(
        test=condition,
        consequent=JsBlockStatement(body=true_body),
        alternate=JsBlockStatement(body=false_body),
    )


def _resolve_shared_wrappers(
    stmts: list[Statement],
    machine: _StateMachine,
    match: _GeneratorCFFMatch,
    outer_state: _StateEnv,
) -> list[Statement]:
    """
    Walk recovered statements looking for function expressions that are wrappers around the same
    shared generator. For each wrapper found, execute the state machine from its entry point and
    replace the wrapper with a proper function containing the recovered body. The *outer_state*
    carries scope routing values from the primary execution so that predicate-gated cases in
    wrapper paths can resolve. Iterates until no more wrappers are resolved (handles nesting).
    """
    gen_name = match.generator_name
    num_vars = len(match.state_var_names)
    attempted: set[int] = set()

    while True:
        resolved_any = False
        for node in list(_walk_all(stmts)):
            if not isinstance(node, JsFunctionExpression):
                continue
            node_id = id(node)
            if node_id in attempted:
                continue
            wrapper_info = _detect_wrapper_function(node, gen_name, num_vars)
            if wrapper_info is None:
                continue
            synthetic = _GeneratorCFFMatch(
                generator_name=gen_name,
                state_var_names=match.state_var_names,
                initial_state=wrapper_info.initial_state,
                end_state=match.end_state,
                switch_stmt=match.switch_stmt,
                switch_label=match.switch_label,
                scope_param_name=match.scope_param_name,
                arg_var_name=match.arg_var_name,
                did_return_var=match.did_return_var,
                result_var=None,
                gen_decl_index=0,
                scaffolding_end=0,
                with_redirect_var=match.with_redirect_var,
                scope_default_props=match.scope_default_props,
                namespaces=match.namespaces,
                namespace_homes=match.namespace_homes,
            )
            result = _execute_machine(machine, synthetic, inherited_state=outer_state)
            if result is None:
                attempted.add(node_id)
                continue
            recovered, _ = result
            target = _wrapper_arg_param_name(match, wrapper_info, recovered)
            if match.arg_var_name and target and match.arg_var_name != target:
                recovered = _rebind_free_arg_var(recovered, match.arg_var_name, target)
            node.body = JsBlockStatement(body=recovered)
            node.body.parent = node
            for s in recovered:
                s.parent = node.body
            if synthetic.arg_params:
                node.params = [JsIdentifier(name=n) for n in synthetic.arg_params]
            elif target is not None and target != wrapper_info.rest_param_name:
                node.params = _rebind_wrapper_param(node.params, target)
            resolved_any = True
        if not resolved_any:
            break

    return stmts


def _walk_all(stmts: list[Statement]):
    """
    Yield all nodes reachable from a list of statements.
    """
    for stmt in stmts:
        yield from stmt.walk()


def _fresh_arg_name(base: str, taken: set[str]) -> str:
    """
    Derive an identifier based on *base* that does not appear in *taken*.
    """
    candidate = base
    suffix = 0
    while candidate in taken:
        suffix += 1
        candidate = F'{base}_{suffix}'
    return candidate


def _rebind_wrapper_param(params: list[Expression], target: str) -> list[Expression]:
    """
    Rename the binding of a wrapper's last parameter to *target*, preserving any leading parameters
    and whether that last parameter is a rest element or a plain identifier. A rest wrapper
    `(...rest)` becomes `(...target)` and a plain wrapper `(p)` becomes `(target)`, so the recovered
    body — whose argument-variable references were rebound onto *target* — keeps the wrapper's
    original arity and its rest-versus-scalar argument mapping. A parameterless wrapper gains a
    single rest parameter.
    """
    if not params:
        return [JsRestElement(argument=JsIdentifier(name=target))]
    result = list(params)
    last = result[-1]
    if isinstance(last, JsRestElement):
        result[-1] = JsRestElement(argument=JsIdentifier(name=target))
    else:
        result[-1] = JsIdentifier(name=target)
    return result


def _wrapper_arg_param_name(
    match: _GeneratorCFFMatch,
    wrapper_info: _WrapperFunctionInfo,
    recovered: list[Statement],
) -> str | None:
    """
    Choose the identifier a wrapper's recovered body should use for the shared generator's argument
    variable, or `None` when the body never references it in a value position. The wrapper's own
    rest-parameter name is preferred, but only when it neither collides with a state-machine
    variable nor occurs anywhere in the recovered body: any occurrence there — a nested binding of
    the name or a free reference to an outer one — would be captured once the argument variable is
    rebound onto it. When the rest-param is unusable (or absent), a fresh identifier not present in
    the recovered body is minted instead.
    """
    if match.arg_var_name is None:
        return None
    taken: set[str] = set()
    referenced: set[str] = set()
    for node in _walk_all(recovered):
        if isinstance(node, JsIdentifier):
            taken.add(node.name)
            if is_reference(node):
                referenced.add(node.name)
    if match.arg_var_name not in referenced:
        return None
    rest = wrapper_info.rest_param_name
    if rest is not None and rest not in match.state_var_names and rest not in taken:
        return rest
    return _fresh_arg_name(match.arg_var_name, taken)


def _rebind_free_arg_var(
    stmts: list[Statement],
    arg_var_name: str,
    param_name: str,
) -> list[Statement]:
    """
    Rename free references to the shared generator's argument variable to a wrapper's parameter
    name. A nested function whose own declarations (`_collect_declared_names`) bind either name owns
    that identifier and is left untouched; a function binding neither is descended into, so a
    genuine closure over the wrapper arguments is still rebound. Member-property and object-key
    positions are skipped because a name there is not a variable reference; an object shorthand
    `{arg}` is expanded to `{arg: param}` so the value read is rebound without renaming the key.
    """
    for stmt in stmts:
        _rebind_arg_var_in_scope(stmt, arg_var_name, param_name)
    return stmts


def _rebind_arg_var_in_scope(node: Node, arg_var_name: str, param_name: str) -> None:
    for child in node.children():
        if isinstance(child, (JsFunctionExpression, JsFunctionDeclaration)):
            declared = _collect_declared_names(child)
            if arg_var_name not in declared and param_name not in declared:
                _rebind_arg_var_in_scope(child, arg_var_name, param_name)
            continue
        if isinstance(child, JsIdentifier) and child.name == arg_var_name:
            parent = child.parent
            if isinstance(parent, JsMemberExpression) and parent.property is child and not parent.computed:
                continue
            if isinstance(parent, JsProperty) and not parent.computed:
                if parent.shorthand and parent.value is child:
                    replacement = JsIdentifier(name=param_name)
                    replacement.parent = parent
                    parent.value = replacement
                    parent.shorthand = False
                    continue
                if parent.key is child:
                    continue
            child.name = param_name
            continue
        _rebind_arg_var_in_scope(child, arg_var_name, param_name)


def _structural_namespaces(match: _GeneratorCFFMatch) -> list[str]:
    """
    The namespaces that must be materialized as `var X = {}`: the scope-parameter defaults and the
    object-literal scope-argument keys, in that order and deduplicated. Namespaces that exist only
    because an in-body `scope.X = {}` write created them are excluded — the recovered body declares
    and initializes those itself.
    """
    names: list[str] = list(match.scope_default_props)
    seen = set(names)
    for name in match.scope_arg_namespaces:
        if name not in seen:
            seen.add(name)
            names.append(name)
    return names


def _emit_scope_namespace_declarations(
    match: _GeneratorCFFMatch,
    recovered: list[Statement],
) -> list[Statement]:
    """
    Emit `var X = {}` for each structural namespace (scope default or object-literal scope-argument
    key), but only for namespaces still referenced in the recovered body. Once free names are left
    bare, a namespace whose every member turned out free has no surviving reference, so its
    declaration would be dead; a surviving `X.member` keeps it.
    """
    referenced = {node.name for node in _walk_all(recovered) if isinstance(node, JsIdentifier)}
    declarations: list[Statement] = []
    for name in _structural_namespaces(match):
        if name not in referenced:
            continue
        init = match.scope_default_inits.get(name)
        if init is None:
            init = match.scope_arg_namespaces.get(name)
        if init is None:
            init = JsObjectExpression(properties=[])
        decl = JsVariableDeclaration(
            declarations=[JsVariableDeclarator(
                id=JsIdentifier(name=name),
                init=_clone_node(init),
            )],
            kind=JsVarKind.VAR,
        )
        declarations.append(decl)
    return declarations


def _emit_arg_param_declarations(match: _GeneratorCFFMatch) -> list[Statement]:
    declarations: list[JsVariableDeclarator] = []
    for name in match.arg_params:
        declarations.append(JsVariableDeclarator(id=JsIdentifier(name=name), init=None))
    if not declarations:
        return []
    return [JsVariableDeclaration(declarations=declarations, kind=JsVarKind.VAR)]


def _collect_scope_props(
    stmts: list[Statement],
    scope_param_name: str | None,
    out: set[str],
) -> None:
    """
    Record the property names of depth-1 scope-member accesses (`scope.X` / `scope["X"]`) in
    *stmts*. These identify which bare identifiers in the recovered code originated as variables
    stored on the scope object, so the recovery can declare the live ones and drop write-only
    routing slots.
    """
    if scope_param_name is None:
        return
    for stmt in stmts:
        for node in stmt.walk():
            if _is_direct_scope_member(node, scope_param_name):
                name = _deepest_property_name(node)
                if name is not None:
                    out.add(name)


def _collect_read_names(node: Node | None, out: set[str]) -> None:
    """
    Collect names of identifiers that are read (appear in a value position) within *node*.
    Assignment targets, declaration ids, and non-computed member property names do not count as
    reads.
    """
    if node is None:
        return
    if isinstance(node, JsAssignmentExpression):
        if isinstance(node.left, JsIdentifier):
            if node.operator != '=':
                out.add(node.left.name)
        else:
            _collect_read_names(node.left, out)
        if node.right is not None:
            _collect_read_names(node.right, out)
        return
    if isinstance(node, JsVariableDeclarator):
        if node.init is not None:
            _collect_read_names(node.init, out)
        return
    if isinstance(node, JsMemberExpression):
        _collect_read_names(node.object, out)
        if node.computed:
            _collect_read_names(node.property, out)
        return
    if isinstance(node, JsProperty):
        if node.computed:
            _collect_read_names(node.key, out)
        _collect_read_names(node.value, out)
        return
    if isinstance(node, JsIdentifier):
        out.add(node.name)
        return
    for child in node.children():
        _collect_read_names(child, out)


def _is_pure_rhs(node: Node) -> bool:
    """
    Conservative purity check for dead-store removal: the expression must contain no calls or nested
    assignments, so dropping the statement cannot discard an observable side effect.
    """
    for n in node.walk():
        if isinstance(n, (JsCallExpression, JsAssignmentExpression)):
            return False
    return True


def _remove_dead_scope_writes(stmts: list[Statement], dead: set[str]) -> list[Statement]:
    """
    Remove pure `name = value` writes (and such sub-expressions of sequences) where *name* is a
    write-only scope slot, i.e. routing bookkeeping that is never read. Writes with side-effecting
    right-hand sides are preserved.
    """
    if not dead:
        return stmts

    def is_dead_write(e: Expression) -> bool:
        return (
            isinstance(e, JsAssignmentExpression)
            and e.operator == '='
            and isinstance(e.left, JsIdentifier)
            and e.left.name in dead
            and e.right is not None
            and _is_pure_rhs(e.right)
        )

    result: list[Statement] = []
    for stmt in stmts:
        if isinstance(stmt, JsExpressionStatement) and stmt.expression is not None:
            expr = stmt.expression
            if isinstance(expr, JsSequenceExpression):
                remaining = [e for e in expr.expressions if not is_dead_write(e)]
                if not remaining:
                    continue
                if len(remaining) == 1:
                    result.append(JsExpressionStatement(expression=remaining[0]))
                else:
                    result.append(JsExpressionStatement(
                        expression=JsSequenceExpression(expressions=remaining),
                    ))
                continue
            if is_dead_write(expr):
                continue
        result.append(stmt)
    return result


def _declared_names_in_stmts(stmts: list[Statement]) -> set[str]:
    """
    Collect var-declared binding names appearing anywhere in *stmts*.
    """
    names: set[str] = set()
    for stmt in stmts:
        for node in stmt.walk():
            if isinstance(node, JsVariableDeclaration):
                for decl in node.declarations:
                    if isinstance(decl, JsVariableDeclarator):
                        _collect_binding_names(decl.id, names)
    return names


def _declare_recovered_scope_vars(
    recovered: list[Statement],
    match: _GeneratorCFFMatch,
) -> list[Statement]:
    """
    Hoisted scope variables survive recovery as bare identifiers. Declare the ones that are read as
    locals of the recovered function, and drop the writes of slots that are pure routing bookkeeping
    (written but never read). Slots already declared, the structural namespaces, and resolved
    argument parameters are left to their dedicated emitters; an in-body namespace (created by a
    `scope.X = {}` write) is not structural, so it is declared here.
    """
    props = match.scope_prop_names
    if not props:
        return recovered
    reads: set[str] = set()
    for stmt in recovered:
        _collect_read_names(stmt, reads)
    dead = {p for p in props if p not in reads}
    recovered = _remove_dead_scope_writes(recovered, dead)
    present: set[str] = set()
    for stmt in recovered:
        for node in stmt.walk():
            if isinstance(node, JsIdentifier):
                present.add(node.name)
    exclude = _declared_names_in_stmts(recovered)
    exclude |= set(match.arg_params)
    exclude |= set(_structural_namespaces(match))
    to_declare = sorted(p for p in props if p in present and p not in exclude)
    if not to_declare:
        return recovered
    decl = JsVariableDeclaration(
        declarations=[JsVariableDeclarator(id=JsIdentifier(name=n), init=None) for n in to_declare],
        kind=JsVarKind.VAR,
    )
    return [decl] + recovered


class JsGeneratorCFFUnflattening(BodyProcessingTransformer):
    """
    Recover original code from generator-based state-machine CFF dispatchers. Handles the pattern
    where a function body is replaced with a generator function containing a while/switch state
    machine driven by multiple state variables.
    """

    def _process_body(self, parent: Node, body: list[Statement]) -> None:
        is_script = isinstance(parent, JsScript)
        i = 0
        while i < len(body):
            match = _match_generator_cff(body, i)
            if match is None:
                i += 1
                continue
            machine = _extract_state_blocks(match)
            if machine is None:
                i += 1
                continue
            result = _execute_machine(machine, match)
            if result is None:
                i += 1
                continue
            recovered, outer_state = result
            if match.arg_var_name is not None:
                recovered = _resolve_shared_wrappers(recovered, machine, match, outer_state)
            recovered = _declare_recovered_scope_vars(recovered, match)
            if match.scope_default_props:
                recovered = _emit_scope_namespace_declarations(match, recovered) + recovered
            if match.arg_params:
                recovered = _emit_arg_param_declarations(match) + recovered
            if is_script:
                recovered = self._sanitize_for_script_scope(recovered)
                if recovered is None:
                    i += 1
                    continue
            for s in recovered:
                s.parent = parent
            start = match.gen_decl_index
            end = match.scaffolding_end
            replacement = body[:start] + recovered + body[end + 1:]
            self._replace_body(parent, body, replacement)
            i = start + len(recovered)

    @staticmethod
    def _sanitize_for_script_scope(stmts: list[Statement]) -> list[Statement] | None:
        for stmt in stmts[:-1] if stmts else ():
            if isinstance(stmt, JsReturnStatement):
                return None
        if stmts and isinstance(stmts[-1], JsReturnStatement):
            last = stmts[-1]
            if last.argument is not None:
                stmts = stmts[:-1] + [JsExpressionStatement(expression=last.argument)]
            else:
                stmts = stmts[:-1]
        return stmts

Classes

class JsGeneratorCFFUnflattening

Recover original code from generator-based state-machine CFF dispatchers. Handles the pattern where a function body is replaced with a generator function containing a while/switch state machine driven by multiple state variables.

Expand source code Browse git
class JsGeneratorCFFUnflattening(BodyProcessingTransformer):
    """
    Recover original code from generator-based state-machine CFF dispatchers. Handles the pattern
    where a function body is replaced with a generator function containing a while/switch state
    machine driven by multiple state variables.
    """

    def _process_body(self, parent: Node, body: list[Statement]) -> None:
        is_script = isinstance(parent, JsScript)
        i = 0
        while i < len(body):
            match = _match_generator_cff(body, i)
            if match is None:
                i += 1
                continue
            machine = _extract_state_blocks(match)
            if machine is None:
                i += 1
                continue
            result = _execute_machine(machine, match)
            if result is None:
                i += 1
                continue
            recovered, outer_state = result
            if match.arg_var_name is not None:
                recovered = _resolve_shared_wrappers(recovered, machine, match, outer_state)
            recovered = _declare_recovered_scope_vars(recovered, match)
            if match.scope_default_props:
                recovered = _emit_scope_namespace_declarations(match, recovered) + recovered
            if match.arg_params:
                recovered = _emit_arg_param_declarations(match) + recovered
            if is_script:
                recovered = self._sanitize_for_script_scope(recovered)
                if recovered is None:
                    i += 1
                    continue
            for s in recovered:
                s.parent = parent
            start = match.gen_decl_index
            end = match.scaffolding_end
            replacement = body[:start] + recovered + body[end + 1:]
            self._replace_body(parent, body, replacement)
            i = start + len(recovered)

    @staticmethod
    def _sanitize_for_script_scope(stmts: list[Statement]) -> list[Statement] | None:
        for stmt in stmts[:-1] if stmts else ():
            if isinstance(stmt, JsReturnStatement):
                return None
        if stmts and isinstance(stmts[-1], JsReturnStatement):
            last = stmts[-1]
            if last.argument is not None:
                stmts = stmts[:-1] + [JsExpressionStatement(expression=last.argument)]
            else:
                stmts = stmts[:-1]
        return stmts

Ancestors

Inherited members