Module refinery.lib.scripts.js.deobfuscation.simplify

JavaScript syntax normalization transforms.

Expand source code Browse git
"""
JavaScript syntax normalization transforms.
"""
from __future__ import annotations

from typing import Callable

from refinery.lib.scripts import Node, Transformer
from refinery.lib.scripts.js.analysis.cache import ModelCache, model_cache
from refinery.lib.scripts.js.analysis.dominance import DominanceModel
from refinery.lib.scripts.js.analysis.effects import GLOBAL_OBJECT, EffectModel
from refinery.lib.scripts.js.analysis.reaching import ReachingModel
from refinery.lib.scripts.js.analysis.model import (
    FUNCTION_NODES,
    GUARANTEED_GLOBALS,
    Binding,
    BindingKind,
    SemanticModel,
    is_invocation_target,
)
from refinery.lib.scripts.js.deobfuscation.helpers import (
    OBJECT_PROTOTYPE_MEMBERS,
    RELATIONAL_OPS,
    _to_int32,
    access_key,
    escape_js_string,
    eval_binary_op,
    extract_identifier_params,
    extract_literal_value,
    is_closed_expression,
    is_literal,
    is_nullish,
    is_safe_iife_inline,
    is_simple_expression,
    is_statically_evaluable,
    is_truthy,
    is_valid_identifier,
    is_valid_property_key,
    js_parse_int,
    make_numeric_literal,
    make_string_literal,
    numeric_value,
    string_value,
    substitute_params,
    value_to_node,
)
from refinery.lib.scripts.js.deobfuscation.interpreter import BUILTIN_REGISTRY, STATIC_OBJECTS
from refinery.lib.scripts.js.model import (
    JsArrayExpression,
    JsArrowFunctionExpression,
    JsAssignmentExpression,
    JsBinaryExpression,
    JsBlockStatement,
    JsBooleanLiteral,
    JsCallExpression,
    JsClassDeclaration,
    JsClassExpression,
    JsConditionalExpression,
    JsFunctionExpression,
    JsIdentifier,
    JsLogicalExpression,
    JsMemberExpression,
    JsNullLiteral,
    JsNumericLiteral,
    JsObjectExpression,
    JsParenthesizedExpression,
    JsProperty,
    JsReturnStatement,
    JsScript,
    JsSequenceExpression,
    JsStringLiteral,
    JsUnaryExpression,
    strip_parens,
)
from refinery.lib.scripts.js.precedence import parens_required

_OBJECT_PROTO_PROPERTIES = OBJECT_PROTOTYPE_MEMBERS

_FUNCTION_PROPERTIES = _OBJECT_PROTO_PROPERTIES | frozenset({
    'apply',
    'arguments',
    'bind',
    'call',
    'caller',
    'length',
    'name',
    'prototype',
})

_EMPTY_OBJECT_PROPERTIES = _OBJECT_PROTO_PROPERTIES


def _callee_form_sensitive(node: Node) -> bool:
    """
    Whether invoking *node* directly as a call callee, rather than behind the comma sequence that
    currently wraps it, would change the call's meaning. A member access binds `this` to its object and
    a bare `eval` performs a *direct* eval evaluated in the caller's own scope; any other callee — a
    plain identifier or value — invokes with no receiver and no direct-eval effect, exactly as the
    wrapping indirect sequence does, so collapsing the sequence down to it preserves the call.
    """
    inner = strip_parens(node)
    if isinstance(inner, JsMemberExpression):
        return True
    return isinstance(inner, JsIdentifier) and inner.name == 'eval'


_UNCONVERTIBLE = object()


def _node_to_value(node: Node) -> object:
    """
    Convert an AST expression to its Python equivalent for use with BUILTIN_REGISTRY dispatch.
    Returns the module-level sentinel `_UNCONVERTIBLE` when the node cannot be statically resolved.
    """
    ok, value = extract_literal_value(node)
    return value if ok else _UNCONVERTIBLE


class JsSimplifications(Transformer):

    def __init__(self):
        super().__init__()
        self._cache: ModelCache | None = None

    @property
    def model(self) -> SemanticModel:
        assert self._cache is not None
        return self._cache.model

    @property
    def effects(self) -> EffectModel:
        assert self._cache is not None
        return self._cache.effects

    @property
    def dominance(self) -> DominanceModel:
        assert self._cache is not None
        return self._cache.dominance

    @property
    def reaching(self) -> ReachingModel:
        assert self._cache is not None
        return self._cache.reaching

    def _global_object_alias_base(self, member: JsMemberExpression) -> bool:
        """
        Whether *member*'s base is a local single-assigned to the global object whose value reaches this
        read unchanged — the `var g = globalThis || {}; g.String` idiom. The value must be established
        before the read (`ReachingModel.value_preserved`), so collapsing `g.String` to `String` cannot
        turn a not-yet-assigned `undefined.String` into a different result. The bare syntactic-alias case
        (`globalThis.String`) is handled by `global_alias_member_name`; this covers only the local alias.
        """
        base = member.object
        if not isinstance(base, JsIdentifier) or self.model.global_alias_member_name(member) is not None:
            return False
        binding = self.model.resolve(base)
        if binding is None:
            return False
        value = self.model.singular_value(binding)
        if value is None or self.effects.intrinsic_of(value) is not GLOBAL_OBJECT:
            return False
        return self.reaching.value_preserved(binding, value, base)

    def visit_JsScript(self, node: JsScript):
        """
        Attach the shared model cache for the whole script, then rewrite. The model, effect, and
        dominance models are all read from that version-aware cache, so a mid-pass mutation rebuilds them
        together and the three stay consistent. Simplification only ever removes bindings within a pass,
        never adds one, so a name the model reports as locally bound stays bound; reading shadowing from
        it can therefore only over-preserve a global-alias access, never collapse one a local now captures.
        """
        self._cache = model_cache(self, node)
        self.generic_visit(node)
        return None

    def _resolves_to_local(self, member: JsMemberExpression, name: str) -> bool:
        """
        Whether *name*, written as a bare identifier where *member* sits, would bind to a local
        declaration rather than the global the `<global-alias>.name` access denotes. An implicit
        global is not a local: the bare name and the property access name the same global, so
        collapsing them stays sound. An unmapped or dynamically-scoped position is treated as bound,
        leaving the access untouched where the model cannot prove the name is free.
        """
        scope = self.model.scope_of(member)
        if scope is None:
            return True
        binding = self.model.lookup(name, scope)
        return binding is not None and binding.kind is not BindingKind.IMPLICIT_GLOBAL

    def _alias_property_defined(self, member: JsMemberExpression, name: str) -> bool:
        """
        Whether a bare read of *name* where *member* sits is guaranteed to resolve, so collapsing
        `<global-alias>.name` to `name` cannot turn the member read's `undefined` into a
        `ReferenceError`. A free name must be one the specification mandates on the global object
        (`GUARANTEED_GLOBALS`); a name the program itself defines as a global (an implicit global) exists
        only after its establishing write, so it is admitted only when a write is proven to run before
        this read — interprocedurally, so a top-level write still covers a read inside a function invoked
        after it, but strictly, so a same-statement or earlier read is declined.
        """
        binding = self.model.lookup(name, self.model.scope_of(member))
        if binding is None:
            return name in GUARANTEED_GLOBALS
        return any(self.dominance.runs_before(w, member) for w in binding.writes)

    def _resolve_in(self, node: JsBinaryExpression, key: str) -> bool | None:
        """
        Statically resolve `key in name` by asking the model what value *name* holds. A sole function,
        an empty class, or an empty object literal has a bounded property set — the built-in members of
        its type plus the own properties the binding is assigned — so membership is decidable; any other
        value, or one whose own-property set cannot be bounded, yields `None`. The binding is resolved
        through the model, so the answer is shadowing-correct across scopes and recognizes the
        bare-assignment form namespace flattening leaves, not only declarations. The value reads
        `undefined` until whatever establishes it — a declarator initializer, a class declaration, or a
        lone assignment — has run, so a `key in name` whose establishing node does not run before the read
        is left unresolved rather than fold away the `TypeError` a premature read would throw.
        """
        right = node.right
        if not isinstance(right, JsIdentifier):
            return None
        binding = self.model.resolve(right)
        if binding is None:
            return None
        value = self.model.singular_value(binding)
        if value is None:
            return None
        if not self.dominance.binding_established_before(binding, right):
            return None
        if isinstance(value, FUNCTION_NODES):
            members = _FUNCTION_PROPERTIES
        elif isinstance(value, (JsClassDeclaration, JsClassExpression)):
            if value.super_class is not None:
                return None
            if value.body is not None and value.body.body:
                return None
            members = _FUNCTION_PROPERTIES
        elif isinstance(value, JsObjectExpression) and not value.properties:
            members = _EMPTY_OBJECT_PROPERTIES
        else:
            return None
        if key in members:
            return True
        state = self._own_property_stores(binding, right)
        if state is None:
            return None
        present, any_store = state
        if key in present:
            return True
        if any_store:
            return None
        return False

    def _own_property_stores(self, binding: Binding, read: Node) -> tuple[set[str], bool] | None:
        """
        The own-property state of *binding*'s value at *read*: a pair `(present, any_store)`. *present* is
        the property names a store (`name.k = ...`, `name['k'] = ...`) provably installs before *read*
        runs and that no `delete name.k` can remove; a store that runs after *read* cannot make the key
        present at it, and a key that may be deleted — or that shares the binding with a `delete` of an
        unbounded computed key — is withheld. *any_store* records whether the value receives any
        own-property store at all, so the caller only concludes absence for a value that receives none.
        References are resolved through the model so the set is shadowing-correct. `None` when a store or
        delete targets a computed key that is not a string literal, since the own-property set is then
        unbounded and no `in` membership can be decided.
        """
        stores: list[tuple[str, JsMemberExpression]] = []
        deleted: set[str] = set()
        unbounded_delete = False
        any_store = False
        for ref in self.model.references(binding):
            member = ref.parent
            if not isinstance(member, JsMemberExpression) or member.object is not ref:
                continue
            prop = member.property
            if member.computed:
                name = prop.value if isinstance(prop, JsStringLiteral) else None
            elif isinstance(prop, JsIdentifier):
                name = prop.name
            else:
                name = None
            parent = member.parent
            if isinstance(parent, JsAssignmentExpression) and strip_parens(parent.left) is member:
                if name is None:
                    return None
                any_store = True
                stores.append((name, member))
            elif (
                isinstance(parent, JsUnaryExpression)
                and parent.operator == 'delete'
                and strip_parens(parent.operand) is member
            ):
                if name is None:
                    unbounded_delete = True
                else:
                    deleted.add(name)
        present: set[str] = set()
        for name, member in stores:
            if name in deleted or unbounded_delete:
                continue
            if self.dominance.runs_before(member, read):
                present.add(name)
        return present, any_store

    def visit_JsBinaryExpression(self, node: JsBinaryExpression):
        self.generic_visit(node)
        if node.left is None or node.right is None:
            return None
        op = node.operator
        left_str = string_value(node.left)
        right_str = string_value(node.right)
        if op == '+' and left_str is not None and right_str is not None:
            return make_string_literal(left_str + right_str)
        left_num = numeric_value(node.left)
        right_num = numeric_value(node.right)
        if left_num is not None and right_num is not None:
            result = eval_binary_op(op, left_num, right_num)
            if result is None:
                pass
            elif isinstance(result, bool):
                return JsBooleanLiteral(value=result)
            elif isinstance(result, (int, float)):
                if result != result or result == float('inf') or result == float('-inf'):
                    return None
                return make_numeric_literal(result)
        if op in ('===', '!==', '==', '!='):
            equal: bool | None = None
            if left_str is not None and right_str is not None:
                equal = left_str == right_str
            elif (
                isinstance(node.left, JsBooleanLiteral)
                and isinstance(node.right, JsBooleanLiteral)
            ):
                equal = node.left.value == node.right.value
            elif isinstance(node.left, JsNullLiteral) and isinstance(node.right, JsNullLiteral):
                equal = True
            if equal is not None:
                return JsBooleanLiteral(value=equal if op in ('===', '==') else not equal)
        if op in RELATIONAL_OPS:
            if left_str is not None and right_str is not None:
                return JsBooleanLiteral(value=RELATIONAL_OPS[op](left_str, right_str))
        if op == 'in' and isinstance(node.left, JsStringLiteral):
            result = self._resolve_in(node, node.left.value)
            if result is not None:
                return JsBooleanLiteral(value=result)
        return None

    def visit_JsCallExpression(self, node: JsCallExpression):
        self.generic_visit(node)
        callee = node.callee
        if isinstance(callee, JsIdentifier) and callee.name == 'parseInt':
            return self._fold_parseint(node)
        fn = callee
        if isinstance(fn, JsParenthesizedExpression):
            fn = fn.expression
        if isinstance(fn, JsFunctionExpression):
            return self._try_inline_iife(
                node,
                fn,
                lambda call: self.effects.is_pure_call(call),
                self.model.read_has_dynamic_effect,
                lambda call: self.effects.call_clearable(
                    call, lambda f: self.dominance.established_before(f, call)),
                self,
            )
        return (
            self._try_fold_static_method(node)
            or self._try_fold_free_function(node)
            or self._try_fold_instance_method(node)
            or self._try_fold_split(node)
            or self._try_fold_join(node)
        )

    @staticmethod
    def _fold_parseint(node: JsCallExpression) -> JsNumericLiteral | None:
        if len(node.arguments) < 1:
            return None
        radix = 10
        if len(node.arguments) >= 2:
            radix_value = numeric_value(node.arguments[1])
            if radix_value is None:
                return None
            radix = int(radix_value)
        sv = string_value(node.arguments[0])
        if sv is not None:
            result = js_parse_int(sv, radix)
            if result is not None:
                return make_numeric_literal(result)
        return None

    @staticmethod
    def _try_inline_iife(
        node: JsCallExpression,
        fn: JsFunctionExpression,
        call_pure: Callable[..., bool],
        read_effect: Callable[[Node], bool],
        call_established: Callable[..., bool],
        transformer: Transformer,
    ) -> Node | None:
        if fn.body is None or not isinstance(fn.body, JsBlockStatement):
            return None
        body = fn.body.body
        if len(body) != 1:
            return None
        stmt = body[0]
        if not isinstance(stmt, JsReturnStatement) or stmt.argument is None:
            return None
        param_names = extract_identifier_params(fn.params)
        if param_names is None or len(node.arguments) != len(param_names):
            return None
        expr = stmt.argument
        if not is_closed_expression(expr, set(param_names)):
            return None
        if not is_safe_iife_inline(
            expr, param_names, node.arguments, call_pure, read_effect, call_established,
        ):
            return None
        return substitute_params(expr, fn.params, node.arguments, transformer=transformer)

    @staticmethod
    def _try_fold_static_method(node: JsCallExpression) -> Node | None:
        callee = node.callee
        if not isinstance(callee, JsMemberExpression):
            return None
        if not isinstance(callee.object, JsIdentifier):
            return None
        static_name = callee.object.name
        if static_name not in STATIC_OBJECTS:
            return None
        if static_name == 'Buffer':
            return None
        method_name = access_key(callee)
        if method_name is None:
            return None
        builtin = BUILTIN_REGISTRY.get((static_name, method_name))
        if builtin is None:
            return None
        args = [_node_to_value(a) for a in node.arguments]
        if any(a is _UNCONVERTIBLE for a in args):
            return None
        try:
            result = builtin(args)
        except Exception:
            return None
        return value_to_node(result)

    @staticmethod
    def _try_fold_free_function(node: JsCallExpression) -> Node | None:
        callee = node.callee
        if not isinstance(callee, JsIdentifier):
            return None
        builtin = BUILTIN_REGISTRY.get((None, callee.name))
        if builtin is None:
            return None
        args = [_node_to_value(a) for a in node.arguments]
        if any(a is _UNCONVERTIBLE for a in args):
            return None
        try:
            result = builtin(args)
        except Exception:
            return None
        return value_to_node(result)

    @staticmethod
    def _try_fold_instance_method(node: JsCallExpression) -> Node | None:
        callee = node.callee
        if not isinstance(callee, JsMemberExpression):
            return None
        method_name = access_key(callee)
        if method_name is None or method_name in ('split', 'join', 'length'):
            return None
        if callee.object is None:
            return None
        receiver = _node_to_value(callee.object)
        if receiver is _UNCONVERTIBLE:
            return None
        if isinstance(receiver, str):
            builtin = BUILTIN_REGISTRY.get((str, method_name))
        elif isinstance(receiver, list):
            builtin = BUILTIN_REGISTRY.get((list, method_name))
        else:
            return None
        if builtin is None:
            return None
        args = [_node_to_value(a) for a in node.arguments]
        if any(a is _UNCONVERTIBLE for a in args):
            return None
        try:
            result = builtin(receiver, args)
        except Exception:
            return None
        return value_to_node(result)

    @staticmethod
    def _try_fold_split(node: JsCallExpression) -> JsArrayExpression | None:
        if len(node.arguments) != 1:
            return None
        callee = node.callee
        if not isinstance(callee, JsMemberExpression):
            return None
        obj_str = string_value(callee.object)
        if obj_str is None:
            return None
        method_name = access_key(callee)
        if method_name != 'split':
            return None
        sep = string_value(node.arguments[0])
        if sep is None:
            return None
        if sep:
            parts = obj_str.split(sep)
        else:
            parts = []
            for ch in obj_str:
                cp = ord(ch)
                if cp > 0xFFFF:
                    hi = 0xD800 + ((cp - 0x10000) >> 10)
                    lo = 0xDC00 + ((cp - 0x10000) & 0x3FF)
                    parts.append(chr(hi))
                    parts.append(chr(lo))
                else:
                    parts.append(ch)
        return JsArrayExpression(
            elements=[make_string_literal(p) for p in parts],
        )

    @staticmethod
    def _try_fold_join(node: JsCallExpression) -> JsStringLiteral | None:
        if len(node.arguments) > 1:
            return None
        callee = node.callee
        if not isinstance(callee, JsMemberExpression):
            return None
        method_name = access_key(callee)
        if method_name != 'join':
            return None
        obj = callee.object
        if not isinstance(obj, JsArrayExpression):
            return None
        parts: list[str] = []
        for e in obj.elements:
            if not isinstance(e, JsStringLiteral):
                return None
            parts.append(e.value)
        if node.arguments:
            sep = string_value(node.arguments[0])
            if sep is None:
                return None
        else:
            sep = ','
        return make_string_literal(sep.join(parts))

    def visit_JsConditionalExpression(self, node: JsConditionalExpression):
        self.generic_visit(node)
        if node.test is None or not is_statically_evaluable(node.test):
            return None
        truthy = is_truthy(node.test)
        if truthy is None:
            return None
        return node.consequent if truthy else node.alternate

    def visit_JsSequenceExpression(self, node: JsSequenceExpression):
        self.generic_visit(node)
        if not node.expressions:
            return None
        filtered = [
            e for i, e in enumerate(node.expressions)
            if i == len(node.expressions) - 1
            or not is_simple_expression(e)
            or self.model.read_has_dynamic_effect(e)
        ]
        if len(filtered) == len(node.expressions):
            return None
        if len(filtered) == 1:
            if is_invocation_target(node) and _callee_form_sensitive(filtered[0]):
                return None
            return filtered[0]
        node.expressions = filtered
        self.mark_changed()
        return None

    def visit_JsParenthesizedExpression(self, node: JsParenthesizedExpression):
        self.generic_visit(node)
        inner = node.expression
        if inner is None:
            return None
        if isinstance(inner, (
            JsSequenceExpression,
            JsFunctionExpression,
            JsArrowFunctionExpression,
            JsObjectExpression,
            JsClassExpression,
        )):
            return None
        if parens_required(inner, node.parent, node):
            return None
        return inner

    def visit_JsMemberExpression(self, node: JsMemberExpression):
        self.generic_visit(node)
        if (
            not node.computed
            and isinstance(node.object, JsIdentifier)
            and isinstance(node.property, JsIdentifier)
            and (self.model.global_alias_member_name(node) is not None or self._global_object_alias_base(node))
            and not self._resolves_to_local(node, node.property.name)
            and self._alias_property_defined(node, node.property.name)
        ):
            p = node.parent
            if isinstance(p, JsAssignmentExpression) and p.left is node:
                return None
            if is_invocation_target(node):
                return None
            return node.property
        if node.computed and node.object is not None and node.property is not None:
            if (
                isinstance(node.object, JsArrayExpression)
                and isinstance(node.property, JsNumericLiteral)
            ):
                idx = node.property.value
                elements = node.object.elements
                if (
                    isinstance(idx, int)
                    and 0 <= idx < len(elements)
                    and all(e is not None and is_literal(e) for e in elements)
                ):
                    return elements[idx]
            prop_str = string_value(node.property)
            if prop_str is not None and is_valid_identifier(prop_str):
                node.computed = False
                node.property = JsIdentifier(name=prop_str)
                node._adopt(node.property)
                self.mark_changed()
                return None
        return None

    def visit_JsProperty(self, node: JsProperty):
        self.generic_visit(node)
        if node.computed and node.key is not None:
            key_str = string_value(node.key)
            if (
                key_str is not None
                and is_valid_property_key(key_str)
                and (node.method or key_str != '__proto__')
            ):
                node.computed = False
                node.key = JsIdentifier(name=key_str)
                node._adopt(node.key)
                self.mark_changed()
        return None

    def visit_JsUnaryExpression(self, node: JsUnaryExpression):
        self.generic_visit(node)
        if node.operand is None:
            return None
        op = node.operator
        if op == '!' and is_statically_evaluable(node.operand):
            truthy = is_truthy(node.operand)
            if truthy is not None:
                return JsBooleanLiteral(value=not truthy)
        if op == '-' and isinstance(node.operand, JsNumericLiteral):
            value = node.operand.value
            if value == 0 and isinstance(value, int):
                value = -0.0
            else:
                value = -value
            return make_numeric_literal(value)
        if op == '+' and isinstance(node.operand, JsNumericLiteral):
            return node.operand
        if op == '~' and isinstance(node.operand, JsNumericLiteral):
            value = node.operand.value
            if value == value and value not in (float('inf'), float('-inf')):
                return make_numeric_literal(_to_int32(~int(value)))
        if op == 'typeof' and is_literal(node.operand):
            if isinstance(node.operand, JsNumericLiteral):
                return make_string_literal('number')
            if isinstance(node.operand, JsStringLiteral):
                return make_string_literal('string')
            if isinstance(node.operand, JsBooleanLiteral):
                return make_string_literal('boolean')
        return None

    def visit_JsStringLiteral(self, node: JsStringLiteral):
        quote = node.raw[0] if node.raw else '\''
        rebuilt = quote + escape_js_string(node.value, quote) + quote
        if rebuilt != node.raw:
            node.raw = rebuilt
            self.mark_changed()
        return None

    def visit_JsLogicalExpression(self, node: JsLogicalExpression):
        self.generic_visit(node)
        if node.left is None or node.right is None:
            return None
        if node.operator == '||' and self.effects.intrinsic_of(node.left) is not None:
            return node.left
        if not is_statically_evaluable(node.left):
            return None
        op = node.operator
        if op == '??':
            if is_nullish(node.left):
                return node.right
            return node.left
        truthy = is_truthy(node.left)
        if truthy is None:
            return None
        if op == '&&':
            return node.right if truthy else node.left
        if op == '||':
            return node.left if truthy else node.right
        return None

Classes

class JsSimplifications

In-place tree rewriter. Each visit method may return a replacement node or None to keep the original. Tracks whether any transformation was applied via the changed flag.

When a models cache is attached by the pipeline, setting changed truthy invalidates it, so a transform that mutates the tree never leaves a stale model behind for the next consumer.

Expand source code Browse git
class JsSimplifications(Transformer):

    def __init__(self):
        super().__init__()
        self._cache: ModelCache | None = None

    @property
    def model(self) -> SemanticModel:
        assert self._cache is not None
        return self._cache.model

    @property
    def effects(self) -> EffectModel:
        assert self._cache is not None
        return self._cache.effects

    @property
    def dominance(self) -> DominanceModel:
        assert self._cache is not None
        return self._cache.dominance

    @property
    def reaching(self) -> ReachingModel:
        assert self._cache is not None
        return self._cache.reaching

    def _global_object_alias_base(self, member: JsMemberExpression) -> bool:
        """
        Whether *member*'s base is a local single-assigned to the global object whose value reaches this
        read unchanged — the `var g = globalThis || {}; g.String` idiom. The value must be established
        before the read (`ReachingModel.value_preserved`), so collapsing `g.String` to `String` cannot
        turn a not-yet-assigned `undefined.String` into a different result. The bare syntactic-alias case
        (`globalThis.String`) is handled by `global_alias_member_name`; this covers only the local alias.
        """
        base = member.object
        if not isinstance(base, JsIdentifier) or self.model.global_alias_member_name(member) is not None:
            return False
        binding = self.model.resolve(base)
        if binding is None:
            return False
        value = self.model.singular_value(binding)
        if value is None or self.effects.intrinsic_of(value) is not GLOBAL_OBJECT:
            return False
        return self.reaching.value_preserved(binding, value, base)

    def visit_JsScript(self, node: JsScript):
        """
        Attach the shared model cache for the whole script, then rewrite. The model, effect, and
        dominance models are all read from that version-aware cache, so a mid-pass mutation rebuilds them
        together and the three stay consistent. Simplification only ever removes bindings within a pass,
        never adds one, so a name the model reports as locally bound stays bound; reading shadowing from
        it can therefore only over-preserve a global-alias access, never collapse one a local now captures.
        """
        self._cache = model_cache(self, node)
        self.generic_visit(node)
        return None

    def _resolves_to_local(self, member: JsMemberExpression, name: str) -> bool:
        """
        Whether *name*, written as a bare identifier where *member* sits, would bind to a local
        declaration rather than the global the `<global-alias>.name` access denotes. An implicit
        global is not a local: the bare name and the property access name the same global, so
        collapsing them stays sound. An unmapped or dynamically-scoped position is treated as bound,
        leaving the access untouched where the model cannot prove the name is free.
        """
        scope = self.model.scope_of(member)
        if scope is None:
            return True
        binding = self.model.lookup(name, scope)
        return binding is not None and binding.kind is not BindingKind.IMPLICIT_GLOBAL

    def _alias_property_defined(self, member: JsMemberExpression, name: str) -> bool:
        """
        Whether a bare read of *name* where *member* sits is guaranteed to resolve, so collapsing
        `<global-alias>.name` to `name` cannot turn the member read's `undefined` into a
        `ReferenceError`. A free name must be one the specification mandates on the global object
        (`GUARANTEED_GLOBALS`); a name the program itself defines as a global (an implicit global) exists
        only after its establishing write, so it is admitted only when a write is proven to run before
        this read — interprocedurally, so a top-level write still covers a read inside a function invoked
        after it, but strictly, so a same-statement or earlier read is declined.
        """
        binding = self.model.lookup(name, self.model.scope_of(member))
        if binding is None:
            return name in GUARANTEED_GLOBALS
        return any(self.dominance.runs_before(w, member) for w in binding.writes)

    def _resolve_in(self, node: JsBinaryExpression, key: str) -> bool | None:
        """
        Statically resolve `key in name` by asking the model what value *name* holds. A sole function,
        an empty class, or an empty object literal has a bounded property set — the built-in members of
        its type plus the own properties the binding is assigned — so membership is decidable; any other
        value, or one whose own-property set cannot be bounded, yields `None`. The binding is resolved
        through the model, so the answer is shadowing-correct across scopes and recognizes the
        bare-assignment form namespace flattening leaves, not only declarations. The value reads
        `undefined` until whatever establishes it — a declarator initializer, a class declaration, or a
        lone assignment — has run, so a `key in name` whose establishing node does not run before the read
        is left unresolved rather than fold away the `TypeError` a premature read would throw.
        """
        right = node.right
        if not isinstance(right, JsIdentifier):
            return None
        binding = self.model.resolve(right)
        if binding is None:
            return None
        value = self.model.singular_value(binding)
        if value is None:
            return None
        if not self.dominance.binding_established_before(binding, right):
            return None
        if isinstance(value, FUNCTION_NODES):
            members = _FUNCTION_PROPERTIES
        elif isinstance(value, (JsClassDeclaration, JsClassExpression)):
            if value.super_class is not None:
                return None
            if value.body is not None and value.body.body:
                return None
            members = _FUNCTION_PROPERTIES
        elif isinstance(value, JsObjectExpression) and not value.properties:
            members = _EMPTY_OBJECT_PROPERTIES
        else:
            return None
        if key in members:
            return True
        state = self._own_property_stores(binding, right)
        if state is None:
            return None
        present, any_store = state
        if key in present:
            return True
        if any_store:
            return None
        return False

    def _own_property_stores(self, binding: Binding, read: Node) -> tuple[set[str], bool] | None:
        """
        The own-property state of *binding*'s value at *read*: a pair `(present, any_store)`. *present* is
        the property names a store (`name.k = ...`, `name['k'] = ...`) provably installs before *read*
        runs and that no `delete name.k` can remove; a store that runs after *read* cannot make the key
        present at it, and a key that may be deleted — or that shares the binding with a `delete` of an
        unbounded computed key — is withheld. *any_store* records whether the value receives any
        own-property store at all, so the caller only concludes absence for a value that receives none.
        References are resolved through the model so the set is shadowing-correct. `None` when a store or
        delete targets a computed key that is not a string literal, since the own-property set is then
        unbounded and no `in` membership can be decided.
        """
        stores: list[tuple[str, JsMemberExpression]] = []
        deleted: set[str] = set()
        unbounded_delete = False
        any_store = False
        for ref in self.model.references(binding):
            member = ref.parent
            if not isinstance(member, JsMemberExpression) or member.object is not ref:
                continue
            prop = member.property
            if member.computed:
                name = prop.value if isinstance(prop, JsStringLiteral) else None
            elif isinstance(prop, JsIdentifier):
                name = prop.name
            else:
                name = None
            parent = member.parent
            if isinstance(parent, JsAssignmentExpression) and strip_parens(parent.left) is member:
                if name is None:
                    return None
                any_store = True
                stores.append((name, member))
            elif (
                isinstance(parent, JsUnaryExpression)
                and parent.operator == 'delete'
                and strip_parens(parent.operand) is member
            ):
                if name is None:
                    unbounded_delete = True
                else:
                    deleted.add(name)
        present: set[str] = set()
        for name, member in stores:
            if name in deleted or unbounded_delete:
                continue
            if self.dominance.runs_before(member, read):
                present.add(name)
        return present, any_store

    def visit_JsBinaryExpression(self, node: JsBinaryExpression):
        self.generic_visit(node)
        if node.left is None or node.right is None:
            return None
        op = node.operator
        left_str = string_value(node.left)
        right_str = string_value(node.right)
        if op == '+' and left_str is not None and right_str is not None:
            return make_string_literal(left_str + right_str)
        left_num = numeric_value(node.left)
        right_num = numeric_value(node.right)
        if left_num is not None and right_num is not None:
            result = eval_binary_op(op, left_num, right_num)
            if result is None:
                pass
            elif isinstance(result, bool):
                return JsBooleanLiteral(value=result)
            elif isinstance(result, (int, float)):
                if result != result or result == float('inf') or result == float('-inf'):
                    return None
                return make_numeric_literal(result)
        if op in ('===', '!==', '==', '!='):
            equal: bool | None = None
            if left_str is not None and right_str is not None:
                equal = left_str == right_str
            elif (
                isinstance(node.left, JsBooleanLiteral)
                and isinstance(node.right, JsBooleanLiteral)
            ):
                equal = node.left.value == node.right.value
            elif isinstance(node.left, JsNullLiteral) and isinstance(node.right, JsNullLiteral):
                equal = True
            if equal is not None:
                return JsBooleanLiteral(value=equal if op in ('===', '==') else not equal)
        if op in RELATIONAL_OPS:
            if left_str is not None and right_str is not None:
                return JsBooleanLiteral(value=RELATIONAL_OPS[op](left_str, right_str))
        if op == 'in' and isinstance(node.left, JsStringLiteral):
            result = self._resolve_in(node, node.left.value)
            if result is not None:
                return JsBooleanLiteral(value=result)
        return None

    def visit_JsCallExpression(self, node: JsCallExpression):
        self.generic_visit(node)
        callee = node.callee
        if isinstance(callee, JsIdentifier) and callee.name == 'parseInt':
            return self._fold_parseint(node)
        fn = callee
        if isinstance(fn, JsParenthesizedExpression):
            fn = fn.expression
        if isinstance(fn, JsFunctionExpression):
            return self._try_inline_iife(
                node,
                fn,
                lambda call: self.effects.is_pure_call(call),
                self.model.read_has_dynamic_effect,
                lambda call: self.effects.call_clearable(
                    call, lambda f: self.dominance.established_before(f, call)),
                self,
            )
        return (
            self._try_fold_static_method(node)
            or self._try_fold_free_function(node)
            or self._try_fold_instance_method(node)
            or self._try_fold_split(node)
            or self._try_fold_join(node)
        )

    @staticmethod
    def _fold_parseint(node: JsCallExpression) -> JsNumericLiteral | None:
        if len(node.arguments) < 1:
            return None
        radix = 10
        if len(node.arguments) >= 2:
            radix_value = numeric_value(node.arguments[1])
            if radix_value is None:
                return None
            radix = int(radix_value)
        sv = string_value(node.arguments[0])
        if sv is not None:
            result = js_parse_int(sv, radix)
            if result is not None:
                return make_numeric_literal(result)
        return None

    @staticmethod
    def _try_inline_iife(
        node: JsCallExpression,
        fn: JsFunctionExpression,
        call_pure: Callable[..., bool],
        read_effect: Callable[[Node], bool],
        call_established: Callable[..., bool],
        transformer: Transformer,
    ) -> Node | None:
        if fn.body is None or not isinstance(fn.body, JsBlockStatement):
            return None
        body = fn.body.body
        if len(body) != 1:
            return None
        stmt = body[0]
        if not isinstance(stmt, JsReturnStatement) or stmt.argument is None:
            return None
        param_names = extract_identifier_params(fn.params)
        if param_names is None or len(node.arguments) != len(param_names):
            return None
        expr = stmt.argument
        if not is_closed_expression(expr, set(param_names)):
            return None
        if not is_safe_iife_inline(
            expr, param_names, node.arguments, call_pure, read_effect, call_established,
        ):
            return None
        return substitute_params(expr, fn.params, node.arguments, transformer=transformer)

    @staticmethod
    def _try_fold_static_method(node: JsCallExpression) -> Node | None:
        callee = node.callee
        if not isinstance(callee, JsMemberExpression):
            return None
        if not isinstance(callee.object, JsIdentifier):
            return None
        static_name = callee.object.name
        if static_name not in STATIC_OBJECTS:
            return None
        if static_name == 'Buffer':
            return None
        method_name = access_key(callee)
        if method_name is None:
            return None
        builtin = BUILTIN_REGISTRY.get((static_name, method_name))
        if builtin is None:
            return None
        args = [_node_to_value(a) for a in node.arguments]
        if any(a is _UNCONVERTIBLE for a in args):
            return None
        try:
            result = builtin(args)
        except Exception:
            return None
        return value_to_node(result)

    @staticmethod
    def _try_fold_free_function(node: JsCallExpression) -> Node | None:
        callee = node.callee
        if not isinstance(callee, JsIdentifier):
            return None
        builtin = BUILTIN_REGISTRY.get((None, callee.name))
        if builtin is None:
            return None
        args = [_node_to_value(a) for a in node.arguments]
        if any(a is _UNCONVERTIBLE for a in args):
            return None
        try:
            result = builtin(args)
        except Exception:
            return None
        return value_to_node(result)

    @staticmethod
    def _try_fold_instance_method(node: JsCallExpression) -> Node | None:
        callee = node.callee
        if not isinstance(callee, JsMemberExpression):
            return None
        method_name = access_key(callee)
        if method_name is None or method_name in ('split', 'join', 'length'):
            return None
        if callee.object is None:
            return None
        receiver = _node_to_value(callee.object)
        if receiver is _UNCONVERTIBLE:
            return None
        if isinstance(receiver, str):
            builtin = BUILTIN_REGISTRY.get((str, method_name))
        elif isinstance(receiver, list):
            builtin = BUILTIN_REGISTRY.get((list, method_name))
        else:
            return None
        if builtin is None:
            return None
        args = [_node_to_value(a) for a in node.arguments]
        if any(a is _UNCONVERTIBLE for a in args):
            return None
        try:
            result = builtin(receiver, args)
        except Exception:
            return None
        return value_to_node(result)

    @staticmethod
    def _try_fold_split(node: JsCallExpression) -> JsArrayExpression | None:
        if len(node.arguments) != 1:
            return None
        callee = node.callee
        if not isinstance(callee, JsMemberExpression):
            return None
        obj_str = string_value(callee.object)
        if obj_str is None:
            return None
        method_name = access_key(callee)
        if method_name != 'split':
            return None
        sep = string_value(node.arguments[0])
        if sep is None:
            return None
        if sep:
            parts = obj_str.split(sep)
        else:
            parts = []
            for ch in obj_str:
                cp = ord(ch)
                if cp > 0xFFFF:
                    hi = 0xD800 + ((cp - 0x10000) >> 10)
                    lo = 0xDC00 + ((cp - 0x10000) & 0x3FF)
                    parts.append(chr(hi))
                    parts.append(chr(lo))
                else:
                    parts.append(ch)
        return JsArrayExpression(
            elements=[make_string_literal(p) for p in parts],
        )

    @staticmethod
    def _try_fold_join(node: JsCallExpression) -> JsStringLiteral | None:
        if len(node.arguments) > 1:
            return None
        callee = node.callee
        if not isinstance(callee, JsMemberExpression):
            return None
        method_name = access_key(callee)
        if method_name != 'join':
            return None
        obj = callee.object
        if not isinstance(obj, JsArrayExpression):
            return None
        parts: list[str] = []
        for e in obj.elements:
            if not isinstance(e, JsStringLiteral):
                return None
            parts.append(e.value)
        if node.arguments:
            sep = string_value(node.arguments[0])
            if sep is None:
                return None
        else:
            sep = ','
        return make_string_literal(sep.join(parts))

    def visit_JsConditionalExpression(self, node: JsConditionalExpression):
        self.generic_visit(node)
        if node.test is None or not is_statically_evaluable(node.test):
            return None
        truthy = is_truthy(node.test)
        if truthy is None:
            return None
        return node.consequent if truthy else node.alternate

    def visit_JsSequenceExpression(self, node: JsSequenceExpression):
        self.generic_visit(node)
        if not node.expressions:
            return None
        filtered = [
            e for i, e in enumerate(node.expressions)
            if i == len(node.expressions) - 1
            or not is_simple_expression(e)
            or self.model.read_has_dynamic_effect(e)
        ]
        if len(filtered) == len(node.expressions):
            return None
        if len(filtered) == 1:
            if is_invocation_target(node) and _callee_form_sensitive(filtered[0]):
                return None
            return filtered[0]
        node.expressions = filtered
        self.mark_changed()
        return None

    def visit_JsParenthesizedExpression(self, node: JsParenthesizedExpression):
        self.generic_visit(node)
        inner = node.expression
        if inner is None:
            return None
        if isinstance(inner, (
            JsSequenceExpression,
            JsFunctionExpression,
            JsArrowFunctionExpression,
            JsObjectExpression,
            JsClassExpression,
        )):
            return None
        if parens_required(inner, node.parent, node):
            return None
        return inner

    def visit_JsMemberExpression(self, node: JsMemberExpression):
        self.generic_visit(node)
        if (
            not node.computed
            and isinstance(node.object, JsIdentifier)
            and isinstance(node.property, JsIdentifier)
            and (self.model.global_alias_member_name(node) is not None or self._global_object_alias_base(node))
            and not self._resolves_to_local(node, node.property.name)
            and self._alias_property_defined(node, node.property.name)
        ):
            p = node.parent
            if isinstance(p, JsAssignmentExpression) and p.left is node:
                return None
            if is_invocation_target(node):
                return None
            return node.property
        if node.computed and node.object is not None and node.property is not None:
            if (
                isinstance(node.object, JsArrayExpression)
                and isinstance(node.property, JsNumericLiteral)
            ):
                idx = node.property.value
                elements = node.object.elements
                if (
                    isinstance(idx, int)
                    and 0 <= idx < len(elements)
                    and all(e is not None and is_literal(e) for e in elements)
                ):
                    return elements[idx]
            prop_str = string_value(node.property)
            if prop_str is not None and is_valid_identifier(prop_str):
                node.computed = False
                node.property = JsIdentifier(name=prop_str)
                node._adopt(node.property)
                self.mark_changed()
                return None
        return None

    def visit_JsProperty(self, node: JsProperty):
        self.generic_visit(node)
        if node.computed and node.key is not None:
            key_str = string_value(node.key)
            if (
                key_str is not None
                and is_valid_property_key(key_str)
                and (node.method or key_str != '__proto__')
            ):
                node.computed = False
                node.key = JsIdentifier(name=key_str)
                node._adopt(node.key)
                self.mark_changed()
        return None

    def visit_JsUnaryExpression(self, node: JsUnaryExpression):
        self.generic_visit(node)
        if node.operand is None:
            return None
        op = node.operator
        if op == '!' and is_statically_evaluable(node.operand):
            truthy = is_truthy(node.operand)
            if truthy is not None:
                return JsBooleanLiteral(value=not truthy)
        if op == '-' and isinstance(node.operand, JsNumericLiteral):
            value = node.operand.value
            if value == 0 and isinstance(value, int):
                value = -0.0
            else:
                value = -value
            return make_numeric_literal(value)
        if op == '+' and isinstance(node.operand, JsNumericLiteral):
            return node.operand
        if op == '~' and isinstance(node.operand, JsNumericLiteral):
            value = node.operand.value
            if value == value and value not in (float('inf'), float('-inf')):
                return make_numeric_literal(_to_int32(~int(value)))
        if op == 'typeof' and is_literal(node.operand):
            if isinstance(node.operand, JsNumericLiteral):
                return make_string_literal('number')
            if isinstance(node.operand, JsStringLiteral):
                return make_string_literal('string')
            if isinstance(node.operand, JsBooleanLiteral):
                return make_string_literal('boolean')
        return None

    def visit_JsStringLiteral(self, node: JsStringLiteral):
        quote = node.raw[0] if node.raw else '\''
        rebuilt = quote + escape_js_string(node.value, quote) + quote
        if rebuilt != node.raw:
            node.raw = rebuilt
            self.mark_changed()
        return None

    def visit_JsLogicalExpression(self, node: JsLogicalExpression):
        self.generic_visit(node)
        if node.left is None or node.right is None:
            return None
        if node.operator == '||' and self.effects.intrinsic_of(node.left) is not None:
            return node.left
        if not is_statically_evaluable(node.left):
            return None
        op = node.operator
        if op == '??':
            if is_nullish(node.left):
                return node.right
            return node.left
        truthy = is_truthy(node.left)
        if truthy is None:
            return None
        if op == '&&':
            return node.right if truthy else node.left
        if op == '||':
            return node.left if truthy else node.right
        return None

Ancestors

Instance variables

var model
Expand source code Browse git
@property
def model(self) -> SemanticModel:
    assert self._cache is not None
    return self._cache.model
var effects
Expand source code Browse git
@property
def effects(self) -> EffectModel:
    assert self._cache is not None
    return self._cache.effects
var dominance
Expand source code Browse git
@property
def dominance(self) -> DominanceModel:
    assert self._cache is not None
    return self._cache.dominance
var reaching
Expand source code Browse git
@property
def reaching(self) -> ReachingModel:
    assert self._cache is not None
    return self._cache.reaching

Methods

def visit_JsScript(self, node)

Attach the shared model cache for the whole script, then rewrite. The model, effect, and dominance models are all read from that version-aware cache, so a mid-pass mutation rebuilds them together and the three stay consistent. Simplification only ever removes bindings within a pass, never adds one, so a name the model reports as locally bound stays bound; reading shadowing from it can therefore only over-preserve a global-alias access, never collapse one a local now captures.

Expand source code Browse git
def visit_JsScript(self, node: JsScript):
    """
    Attach the shared model cache for the whole script, then rewrite. The model, effect, and
    dominance models are all read from that version-aware cache, so a mid-pass mutation rebuilds them
    together and the three stay consistent. Simplification only ever removes bindings within a pass,
    never adds one, so a name the model reports as locally bound stays bound; reading shadowing from
    it can therefore only over-preserve a global-alias access, never collapse one a local now captures.
    """
    self._cache = model_cache(self, node)
    self.generic_visit(node)
    return None
def visit_JsBinaryExpression(self, node)
Expand source code Browse git
def visit_JsBinaryExpression(self, node: JsBinaryExpression):
    self.generic_visit(node)
    if node.left is None or node.right is None:
        return None
    op = node.operator
    left_str = string_value(node.left)
    right_str = string_value(node.right)
    if op == '+' and left_str is not None and right_str is not None:
        return make_string_literal(left_str + right_str)
    left_num = numeric_value(node.left)
    right_num = numeric_value(node.right)
    if left_num is not None and right_num is not None:
        result = eval_binary_op(op, left_num, right_num)
        if result is None:
            pass
        elif isinstance(result, bool):
            return JsBooleanLiteral(value=result)
        elif isinstance(result, (int, float)):
            if result != result or result == float('inf') or result == float('-inf'):
                return None
            return make_numeric_literal(result)
    if op in ('===', '!==', '==', '!='):
        equal: bool | None = None
        if left_str is not None and right_str is not None:
            equal = left_str == right_str
        elif (
            isinstance(node.left, JsBooleanLiteral)
            and isinstance(node.right, JsBooleanLiteral)
        ):
            equal = node.left.value == node.right.value
        elif isinstance(node.left, JsNullLiteral) and isinstance(node.right, JsNullLiteral):
            equal = True
        if equal is not None:
            return JsBooleanLiteral(value=equal if op in ('===', '==') else not equal)
    if op in RELATIONAL_OPS:
        if left_str is not None and right_str is not None:
            return JsBooleanLiteral(value=RELATIONAL_OPS[op](left_str, right_str))
    if op == 'in' and isinstance(node.left, JsStringLiteral):
        result = self._resolve_in(node, node.left.value)
        if result is not None:
            return JsBooleanLiteral(value=result)
    return None
def visit_JsCallExpression(self, node)
Expand source code Browse git
def visit_JsCallExpression(self, node: JsCallExpression):
    self.generic_visit(node)
    callee = node.callee
    if isinstance(callee, JsIdentifier) and callee.name == 'parseInt':
        return self._fold_parseint(node)
    fn = callee
    if isinstance(fn, JsParenthesizedExpression):
        fn = fn.expression
    if isinstance(fn, JsFunctionExpression):
        return self._try_inline_iife(
            node,
            fn,
            lambda call: self.effects.is_pure_call(call),
            self.model.read_has_dynamic_effect,
            lambda call: self.effects.call_clearable(
                call, lambda f: self.dominance.established_before(f, call)),
            self,
        )
    return (
        self._try_fold_static_method(node)
        or self._try_fold_free_function(node)
        or self._try_fold_instance_method(node)
        or self._try_fold_split(node)
        or self._try_fold_join(node)
    )
def visit_JsConditionalExpression(self, node)
Expand source code Browse git
def visit_JsConditionalExpression(self, node: JsConditionalExpression):
    self.generic_visit(node)
    if node.test is None or not is_statically_evaluable(node.test):
        return None
    truthy = is_truthy(node.test)
    if truthy is None:
        return None
    return node.consequent if truthy else node.alternate
def visit_JsSequenceExpression(self, node)
Expand source code Browse git
def visit_JsSequenceExpression(self, node: JsSequenceExpression):
    self.generic_visit(node)
    if not node.expressions:
        return None
    filtered = [
        e for i, e in enumerate(node.expressions)
        if i == len(node.expressions) - 1
        or not is_simple_expression(e)
        or self.model.read_has_dynamic_effect(e)
    ]
    if len(filtered) == len(node.expressions):
        return None
    if len(filtered) == 1:
        if is_invocation_target(node) and _callee_form_sensitive(filtered[0]):
            return None
        return filtered[0]
    node.expressions = filtered
    self.mark_changed()
    return None
def visit_JsParenthesizedExpression(self, node)
Expand source code Browse git
def visit_JsParenthesizedExpression(self, node: JsParenthesizedExpression):
    self.generic_visit(node)
    inner = node.expression
    if inner is None:
        return None
    if isinstance(inner, (
        JsSequenceExpression,
        JsFunctionExpression,
        JsArrowFunctionExpression,
        JsObjectExpression,
        JsClassExpression,
    )):
        return None
    if parens_required(inner, node.parent, node):
        return None
    return inner
def visit_JsMemberExpression(self, node)
Expand source code Browse git
def visit_JsMemberExpression(self, node: JsMemberExpression):
    self.generic_visit(node)
    if (
        not node.computed
        and isinstance(node.object, JsIdentifier)
        and isinstance(node.property, JsIdentifier)
        and (self.model.global_alias_member_name(node) is not None or self._global_object_alias_base(node))
        and not self._resolves_to_local(node, node.property.name)
        and self._alias_property_defined(node, node.property.name)
    ):
        p = node.parent
        if isinstance(p, JsAssignmentExpression) and p.left is node:
            return None
        if is_invocation_target(node):
            return None
        return node.property
    if node.computed and node.object is not None and node.property is not None:
        if (
            isinstance(node.object, JsArrayExpression)
            and isinstance(node.property, JsNumericLiteral)
        ):
            idx = node.property.value
            elements = node.object.elements
            if (
                isinstance(idx, int)
                and 0 <= idx < len(elements)
                and all(e is not None and is_literal(e) for e in elements)
            ):
                return elements[idx]
        prop_str = string_value(node.property)
        if prop_str is not None and is_valid_identifier(prop_str):
            node.computed = False
            node.property = JsIdentifier(name=prop_str)
            node._adopt(node.property)
            self.mark_changed()
            return None
    return None
def visit_JsProperty(self, node)
Expand source code Browse git
def visit_JsProperty(self, node: JsProperty):
    self.generic_visit(node)
    if node.computed and node.key is not None:
        key_str = string_value(node.key)
        if (
            key_str is not None
            and is_valid_property_key(key_str)
            and (node.method or key_str != '__proto__')
        ):
            node.computed = False
            node.key = JsIdentifier(name=key_str)
            node._adopt(node.key)
            self.mark_changed()
    return None
def visit_JsUnaryExpression(self, node)
Expand source code Browse git
def visit_JsUnaryExpression(self, node: JsUnaryExpression):
    self.generic_visit(node)
    if node.operand is None:
        return None
    op = node.operator
    if op == '!' and is_statically_evaluable(node.operand):
        truthy = is_truthy(node.operand)
        if truthy is not None:
            return JsBooleanLiteral(value=not truthy)
    if op == '-' and isinstance(node.operand, JsNumericLiteral):
        value = node.operand.value
        if value == 0 and isinstance(value, int):
            value = -0.0
        else:
            value = -value
        return make_numeric_literal(value)
    if op == '+' and isinstance(node.operand, JsNumericLiteral):
        return node.operand
    if op == '~' and isinstance(node.operand, JsNumericLiteral):
        value = node.operand.value
        if value == value and value not in (float('inf'), float('-inf')):
            return make_numeric_literal(_to_int32(~int(value)))
    if op == 'typeof' and is_literal(node.operand):
        if isinstance(node.operand, JsNumericLiteral):
            return make_string_literal('number')
        if isinstance(node.operand, JsStringLiteral):
            return make_string_literal('string')
        if isinstance(node.operand, JsBooleanLiteral):
            return make_string_literal('boolean')
    return None
def visit_JsStringLiteral(self, node)
Expand source code Browse git
def visit_JsStringLiteral(self, node: JsStringLiteral):
    quote = node.raw[0] if node.raw else '\''
    rebuilt = quote + escape_js_string(node.value, quote) + quote
    if rebuilt != node.raw:
        node.raw = rebuilt
        self.mark_changed()
    return None
def visit_JsLogicalExpression(self, node)
Expand source code Browse git
def visit_JsLogicalExpression(self, node: JsLogicalExpression):
    self.generic_visit(node)
    if node.left is None or node.right is None:
        return None
    if node.operator == '||' and self.effects.intrinsic_of(node.left) is not None:
        return node.left
    if not is_statically_evaluable(node.left):
        return None
    op = node.operator
    if op == '??':
        if is_nullish(node.left):
            return node.right
        return node.left
    truthy = is_truthy(node.left)
    if truthy is None:
        return None
    if op == '&&':
        return node.right if truthy else node.left
    if op == '||':
        return node.left if truthy else node.right
    return None

Inherited members