Module refinery.lib.scripts.js.analysis

Static-analysis substrate for JavaScript deobfuscation. Transforms query a shared, computed model of the program here instead of each re-deriving scope, binding, and dataflow facts on their own.

The foundation is refinery.lib.scripts.js.analysis.model, a flow-insensitive lexical model of scopes and resolved bindings. Later layers (control-flow graphs, effect summaries) attach behind the same representation-agnostic surface.

Expand source code Browse git
"""
Static-analysis substrate for JavaScript deobfuscation. Transforms query a shared, computed model of
the program here instead of each re-deriving scope, binding, and dataflow facts on their own.

The foundation is `model`, a flow-insensitive lexical model of scopes
and resolved bindings. Later layers (control-flow graphs, effect summaries) attach behind the same
representation-agnostic surface.
"""
from __future__ import annotations

from refinery.lib.scripts.js.analysis.cfg import (
    CfgNode,
    ControlFlowGraph,
    ControlFlowModel,
    build_cfg,
    build_control_flow,
)
from refinery.lib.scripts.js.analysis.effects import (
    EffectModel,
    EffectSummary,
    build_effects,
)
from refinery.lib.scripts.js.analysis.liveness import (
    LivenessModel,
    build_liveness,
)
from refinery.lib.scripts.js.analysis.model import (
    Binding,
    BindingKind,
    Role,
    Scope,
    ScopeKind,
    SemanticModel,
    build_semantic_model,
    is_use_position,
    pattern_identifiers,
    reference_role,
)

__all__ = [
    'Binding',
    'BindingKind',
    'CfgNode',
    'ControlFlowGraph',
    'ControlFlowModel',
    'EffectModel',
    'EffectSummary',
    'LivenessModel',
    'Role',
    'Scope',
    'ScopeKind',
    'SemanticModel',
    'build_cfg',
    'build_control_flow',
    'build_effects',
    'build_liveness',
    'build_semantic_model',
    'is_use_position',
    'pattern_identifiers',
    'reference_role',
]

Sub-modules

refinery.lib.scripts.js.analysis.cache

A per-run cache of the JavaScript analysis models. The deobfuscation pipeline builds one cache over the script being transformed and shares it across …

refinery.lib.scripts.js.analysis.cfg

Per-function control-flow graphs for JavaScript, derived from the AST. Each function (and the script itself) gets one ControlFlowGraph: a graph …

refinery.lib.scripts.js.analysis.dominance

Dominance over the per-function control-flow graphs of the SemanticModel. One node dominates another when …

refinery.lib.scripts.js.analysis.effects

Per-function effect summaries for JavaScript, computed over the SemanticModel's resolved bindings and call …

refinery.lib.scripts.js.analysis.liveness

Flow-sensitive live-variable analysis for JavaScript, computed over the per-function control-flow graphs and the resolved bindings of the …

refinery.lib.scripts.js.analysis.model

A lexical semantic model for JavaScript: a tree of scopes with resolved bindings and def/use sets, computed once over an AST and then queried by …

refinery.lib.scripts.js.analysis.reaching

Reaching-value queries for JavaScript inlining, over the per-function control-flow graphs of the …

Functions

def build_cfg(owner)

Build the control-flow graph of owner, a JsScript or a function node, over its own body without descending into nested function bodies.

Expand source code Browse git
def build_cfg(owner: Node) -> ControlFlowGraph:
    """
    Build the control-flow graph of *owner*, a `refinery.lib.scripts.js.model.JsScript` or a
    function node, over its own body without descending into nested function bodies.
    """
    return _Builder(owner).build()
def build_control_flow(root)

Build one control-flow graph per function and for the script itself, keyed by the owner node's identity. The graphs are independent: a nested function appears in its parent's graph only as the statement that defines it, never as descended-into control flow.

Expand source code Browse git
def build_control_flow(root: JsScript) -> dict[int, ControlFlowGraph]:
    """
    Build one control-flow graph per function and for the script itself, keyed by the owner node's
    identity. The graphs are independent: a nested function appears in its parent's graph only as the
    statement that defines it, never as descended-into control flow.
    """
    graphs: dict[int, ControlFlowGraph] = {id(root): build_cfg(root)}
    for node in root.walk():
        if isinstance(node, FUNCTION_NODES):
            graphs[id(node)] = build_cfg(node)
    return graphs
def build_effects(model)

Build the EffectModel for a script's SemanticModel.

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

Build the LivenessModel for a script's SemanticModel, reusing control_flow when the caller has one to share, or building a fresh one when it is None.

Expand source code Browse git
def build_liveness(
    model: SemanticModel, control_flow: ControlFlowModel | None = None,
) -> LivenessModel:
    """
    Build the `LivenessModel` for a script's `refinery.lib.scripts.js.analysis.model.SemanticModel`,
    reusing *control_flow* when the caller has one to share, or building a fresh one when it is `None`.
    """
    return LivenessModel(model, control_flow)
def build_semantic_model(root)

Build the SemanticModel for a parsed script.

Expand source code Browse git
def build_semantic_model(root: JsScript) -> SemanticModel:
    """
    Build the `SemanticModel` for a parsed script.
    """
    return SemanticModel(root)
def is_use_position(node)

Whether an identifier occupies a position where it reads or writes a value, as opposed to naming a property, an object-literal key, a label, or an import/export specifier. Binding sites are not excluded here; SemanticModel.is_reference() is the binding-aware predicate that also excludes them.

Expand source code Browse git
def is_use_position(node: JsIdentifier) -> bool:
    """
    Whether an identifier occupies a position where it reads or writes a value, as opposed to naming a
    property, an object-literal key, a label, or an import/export specifier. Binding sites are not
    excluded here; `SemanticModel.is_reference` is the binding-aware predicate that also excludes them.
    """
    p = node.parent
    if p is None:
        return False
    if isinstance(p, JsMemberExpression) and p.property is node and not p.computed:
        return False
    if isinstance(p, JsProperty) and p.key is node and not p.computed and not p.shorthand:
        return False
    if isinstance(p, (JsBreakStatement, JsContinueStatement, JsLabeledStatement)) and p.label is node:
        return False
    if isinstance(p, (
        JsImportSpecifier,
        JsImportDefaultSpecifier,
        JsImportNamespaceSpecifier,
        JsExportSpecifier,
    )):
        return False
    return True
def pattern_identifiers(target)

Yield every binding-site identifier introduced by a declaration target, descending through destructuring patterns ([a, {b: c}], {x, ...rest}), default patterns, and rest elements. A member-expression target ([a.b] = ...) introduces no binding and yields nothing.

Expand source code Browse git
def pattern_identifiers(target: Node | None) -> Iterator[JsIdentifier]:
    """
    Yield every binding-site identifier introduced by a declaration target, descending through
    destructuring patterns (`[a, {b: c}]`, `{x, ...rest}`), default patterns, and rest elements. A
    member-expression target (`[a.b] = ...`) introduces no binding and yields nothing.
    """
    if target is None:
        return
    if isinstance(target, JsIdentifier):
        yield target
    elif isinstance(target, JsArrayPattern):
        for element in target.elements:
            yield from pattern_identifiers(element)
    elif isinstance(target, JsObjectPattern):
        for prop in target.properties:
            if isinstance(prop, JsRestElement):
                yield from pattern_identifiers(prop.argument)
            elif isinstance(prop, JsProperty):
                yield from pattern_identifiers(prop.value)
    elif isinstance(target, JsAssignmentPattern):
        yield from pattern_identifiers(target.left)
    elif isinstance(target, JsRestElement):
        yield from pattern_identifiers(target.argument)
def reference_role(node)

Classify how a reference touches its binding: a plain read, a write-only target (the left of a simple =, including inside a destructuring pattern or a destructuring default, or a for-in/for-of head), or a read-and-write (compound assignment, ++/--, or a delete, each of which keeps the name live as a read rather than overwriting it outright). The shared _governing_target climb looks through destructuring containers, default patterns, and parentheses, so a target nested in a pattern or a grouping ([x = 9] = xs, (x)++, (o) = v) is still recognized as a write. The reference is usually an identifier, but the same rules classify a member access on a global-object alias (globalThis.g, globalThis.g = ...) against the global it denotes, so the def-use pass records such an access as the read or write it is.

Expand source code Browse git
def reference_role(node: JsIdentifier | JsMemberExpression) -> Role:
    """
    Classify how a reference touches its binding: a plain read, a write-only target (the left of a
    simple `=`, including inside a destructuring pattern or a destructuring default, or a
    `for-in`/`for-of` head), or a read-and-write (compound assignment, `++`/`--`, or a `delete`, each
    of which keeps the name live as a read rather than overwriting it outright). The shared
    `_governing_target` climb looks through destructuring containers, default patterns, and
    parentheses, so a target nested in a pattern or a grouping (`[x = 9] = xs`, `(x)++`, `(o) = v`) is
    still recognized as a write. The reference is usually an identifier, but the same rules classify a
    member access on a global-object alias (`globalThis.g`, `globalThis.g = ...`) against the global it
    denotes, so the def-use pass records such an access as the read or write it is.
    """
    governor, target = _governing_target(node)
    if isinstance(governor, JsAssignmentExpression) and strip_parens(governor.left) is target:
        return Role.WRITE if governor.operator == '=' else Role.READWRITE
    if isinstance(governor, JsUpdateExpression) and strip_parens(governor.argument) is target:
        return Role.READWRITE
    if (
        isinstance(governor, JsUnaryExpression)
        and governor.operator == 'delete'
        and strip_parens(governor.operand) is target
    ):
        return Role.READWRITE
    if isinstance(governor, (JsForInStatement, JsForOfStatement)) and strip_parens(governor.left) is target:
        return Role.WRITE
    return Role.READ

Classes

class Binding (name, kind, scope, declarations=<factory>, reads=<factory>, writes=<factory>, dynamic_refs=<factory>, captured=False)

A single declared name within one scope. declarations holds the binding-site identifier nodes that introduce the name; reads and writes hold the referencing identifiers that read and write it (a compound assignment or update appears in both). captured is set when the name is referenced from a function nested below the one that owns it. A read or write performed through a member access on a global-object alias (globalThis.g, globalThis.g = ...) has no referencing identifier for the global it targets, so the JsMemberExpression stands in for that reference; every other reads/writes entry is an identifier. dynamic_refs holds referencing identifiers a dynamic scope resolves at runtime — a name inside a with body that could denote this binding — which reads/writes omit because such a name resolves to no binding statically; its target is uncertain, so it is kept apart from the definite references.

Expand source code Browse git
@dataclass(eq=False)
class Binding:
    """
    A single declared name within one scope. `declarations` holds the binding-site identifier nodes
    that introduce the name; `reads` and `writes` hold the referencing identifiers that read and write
    it (a compound assignment or update appears in both). `captured` is set when the name is referenced
    from a function nested below the one that owns it. A read or write performed through a member access
    on a global-object alias (`globalThis.g`, `globalThis.g = ...`) has no referencing identifier for
    the global it targets, so the `JsMemberExpression` stands in for that reference; every other
    `reads`/`writes` entry is an identifier. `dynamic_refs` holds referencing identifiers a dynamic
    scope resolves at runtime — a name inside a `with` body that could denote this binding — which
    `reads`/`writes` omit because such a name resolves to no binding statically; its target is
    uncertain, so it is kept apart from the definite references.
    """
    name: str
    kind: BindingKind
    scope: Scope
    declarations: list[JsIdentifier] = field(default_factory=list)
    reads: list[JsIdentifier | JsMemberExpression] = field(default_factory=list)
    writes: list[JsIdentifier | JsMemberExpression] = field(default_factory=list)
    dynamic_refs: list[JsIdentifier] = field(default_factory=list)
    captured: bool = False

    @property
    def is_read(self) -> bool:
        """
        Whether the binding's value is ever read.
        """
        return bool(self.reads)

    @property
    def is_hoisted(self) -> bool:
        """
        Whether the binding is hoisted to the top of its variable scope — a `var` or a function
        declaration — and so is visible (as `undefined`, or the function) throughout that scope before
        its textual position, rather than sitting in a temporal dead zone.
        """
        return self.kind in (BindingKind.VAR, BindingKind.FUNCTION)

    @property
    def is_lexical(self) -> bool:
        """
        Whether the binding is block-scoped in a declarative environment — a `let`, `const`, or
        `class`. Defined positively: a parameter, catch binding, import, or implicit global is neither
        hoisted nor lexical in this sense.
        """
        return self.kind in (BindingKind.LET, BindingKind.CONST, BindingKind.CLASS)

    @property
    def is_dead(self) -> bool:
        """
        Whether no use observes the binding's value: it is read through no resolved reference and named
        inside no dynamic scope. Definitions of a dead binding can be removed if they carry no other
        side effect (which the caller decides). A name a `with` body could read is not dead even though
        `reads` is empty — the dynamic reference may observe it at runtime — so removers need not rely on
        a separate reflection gate to keep such a binding.
        """
        return not self.reads and not self.dynamic_refs

    @property
    def has_global_member_write(self) -> bool:
        """
        Whether the binding is written through a member access on a global-object alias
        (`globalThis.x = ...`), recorded as a `JsMemberExpression` write site rather than a referencing
        identifier (see the class docstring). Only a global ever carries such a write, so the answer is
        always false for a lexical binding whose writes are all identifiers.
        """
        return any(isinstance(write, JsMemberExpression) for write in self.writes)

    @property
    def has_member_reference(self) -> bool:
        """
        Whether the binding is read or written through a member access on a global-object alias
        (`globalThis.x`), recorded as a `JsMemberExpression` reference rather than a referencing
        identifier (see the class docstring). Such a binding is reachable through the global object, so
        a caller must not treat it as an ordinary local — it cannot be relocated into a function.
        """
        return any(isinstance(ref, JsMemberExpression) for ref in (*self.reads, *self.writes))

Instance variables

var name

The type of the None singleton.

var kind

The type of the None singleton.

var scope

The type of the None singleton.

var declarations

The type of the None singleton.

var reads

The type of the None singleton.

var writes

The type of the None singleton.

var dynamic_refs

The type of the None singleton.

var captured

The type of the None singleton.

var is_read

Whether the binding's value is ever read.

Expand source code Browse git
@property
def is_read(self) -> bool:
    """
    Whether the binding's value is ever read.
    """
    return bool(self.reads)
var is_hoisted

Whether the binding is hoisted to the top of its variable scope — a var or a function declaration — and so is visible (as undefined, or the function) throughout that scope before its textual position, rather than sitting in a temporal dead zone.

Expand source code Browse git
@property
def is_hoisted(self) -> bool:
    """
    Whether the binding is hoisted to the top of its variable scope — a `var` or a function
    declaration — and so is visible (as `undefined`, or the function) throughout that scope before
    its textual position, rather than sitting in a temporal dead zone.
    """
    return self.kind in (BindingKind.VAR, BindingKind.FUNCTION)
var is_lexical

Whether the binding is block-scoped in a declarative environment — a let, const, or class. Defined positively: a parameter, catch binding, import, or implicit global is neither hoisted nor lexical in this sense.

Expand source code Browse git
@property
def is_lexical(self) -> bool:
    """
    Whether the binding is block-scoped in a declarative environment — a `let`, `const`, or
    `class`. Defined positively: a parameter, catch binding, import, or implicit global is neither
    hoisted nor lexical in this sense.
    """
    return self.kind in (BindingKind.LET, BindingKind.CONST, BindingKind.CLASS)
var is_dead

Whether no use observes the binding's value: it is read through no resolved reference and named inside no dynamic scope. Definitions of a dead binding can be removed if they carry no other side effect (which the caller decides). A name a with body could read is not dead even though reads is empty — the dynamic reference may observe it at runtime — so removers need not rely on a separate reflection gate to keep such a binding.

Expand source code Browse git
@property
def is_dead(self) -> bool:
    """
    Whether no use observes the binding's value: it is read through no resolved reference and named
    inside no dynamic scope. Definitions of a dead binding can be removed if they carry no other
    side effect (which the caller decides). A name a `with` body could read is not dead even though
    `reads` is empty — the dynamic reference may observe it at runtime — so removers need not rely on
    a separate reflection gate to keep such a binding.
    """
    return not self.reads and not self.dynamic_refs
var has_global_member_write

Whether the binding is written through a member access on a global-object alias (globalThis.x = ...), recorded as a JsMemberExpression write site rather than a referencing identifier (see the class docstring). Only a global ever carries such a write, so the answer is always false for a lexical binding whose writes are all identifiers.

Expand source code Browse git
@property
def has_global_member_write(self) -> bool:
    """
    Whether the binding is written through a member access on a global-object alias
    (`globalThis.x = ...`), recorded as a `JsMemberExpression` write site rather than a referencing
    identifier (see the class docstring). Only a global ever carries such a write, so the answer is
    always false for a lexical binding whose writes are all identifiers.
    """
    return any(isinstance(write, JsMemberExpression) for write in self.writes)
var has_member_reference

Whether the binding is read or written through a member access on a global-object alias (globalThis.x), recorded as a JsMemberExpression reference rather than a referencing identifier (see the class docstring). Such a binding is reachable through the global object, so a caller must not treat it as an ordinary local — it cannot be relocated into a function.

Expand source code Browse git
@property
def has_member_reference(self) -> bool:
    """
    Whether the binding is read or written through a member access on a global-object alias
    (`globalThis.x`), recorded as a `JsMemberExpression` reference rather than a referencing
    identifier (see the class docstring). Such a binding is reachable through the global object, so
    a caller must not treat it as an ordinary local — it cannot be relocated into a function.
    """
    return any(isinstance(ref, JsMemberExpression) for ref in (*self.reads, *self.writes))
class BindingKind (*args, **kwds)

Create a collection of name/value pairs.

Example enumeration:

>>> class Color(Enum):
...     RED = 1
...     BLUE = 2
...     GREEN = 3

Access them by:

  • attribute access:

Color.RED

  • value lookup:

Color(1)

  • name lookup:

Color['RED']

Enumerations can be iterated over, and know how many members they have:

>>> len(Color)
3
>>> list(Color)
[<Color.RED: 1>, <Color.BLUE: 2>, <Color.GREEN: 3>]

Methods can be added to enumerations, and members can have their own attributes – see the documentation for details.

Expand source code Browse git
class BindingKind(enum.Enum):
    VAR             = 'var'              # noqa
    LET             = 'let'              # noqa
    CONST           = 'const'            # noqa
    PARAM           = 'param'            # noqa
    FUNCTION        = 'function'         # noqa
    CLASS           = 'class'            # noqa
    CATCH           = 'catch'            # noqa
    IMPORT          = 'import'           # noqa
    ARGUMENTS       = 'arguments'        # noqa
    FUNC_NAME       = 'func_name'        # noqa  the own name of a named function expression
    IMPLICIT_GLOBAL = 'implicit_global'  # noqa  a name assigned but never declared

Ancestors

  • enum.Enum

Class variables

var VAR

The type of the None singleton.

var LET

The type of the None singleton.

var CONST

The type of the None singleton.

var PARAM

The type of the None singleton.

var FUNCTION

The type of the None singleton.

var CLASS

The type of the None singleton.

var CATCH

The type of the None singleton.

var IMPORT

The type of the None singleton.

var ARGUMENTS

The type of the None singleton.

var FUNC_NAME

The type of the None singleton.

var IMPLICIT_GLOBAL

The type of the None singleton.

class CfgNode (element, successors=<factory>, predecessors=<factory>, is_entry=False, is_exit=False)

One vertex of a control-flow graph. element is the AST node it stands for — a statement, or a loop-head expression (for init/test/update) whose reads and writes occur at this point — or None for the synthetic entry and exit. successors lists the nodes control may pass to next.

Expand source code Browse git
@dataclass(eq=False)
class CfgNode:
    """
    One vertex of a control-flow graph. `element` is the AST node it stands for — a statement, or a
    loop-head expression (`for` init/test/update) whose reads and writes occur at this point — or
    `None` for the synthetic entry and exit. `successors` lists the nodes control may pass to next.
    """
    element: Node | None
    successors: list[CfgNode] = field(default_factory=list)
    predecessors: list[CfgNode] = field(default_factory=list)
    is_entry: bool = False
    is_exit: bool = False

Instance variables

var element

The type of the None singleton.

var successors

The type of the None singleton.

var predecessors

The type of the None singleton.

var is_entry

The type of the None singleton.

var is_exit

The type of the None singleton.

class ControlFlowGraph (owner)

The control-flow graph of one function or script body. entry and exit are synthetic; every other node wraps an AST element reachable through node_of.

Expand source code Browse git
class ControlFlowGraph:
    """
    The control-flow graph of one function or script body. `entry` and `exit` are synthetic; every
    other node wraps an AST element reachable through `node_of`.
    """

    def __init__(self, owner: Node):
        self.owner = owner
        self.entry = CfgNode(None, is_entry=True)
        self.exit = CfgNode(None, is_exit=True)
        self.nodes: list[CfgNode] = [self.entry, self.exit]
        self._node_of: dict[int, CfgNode] = {}
        self.exceptional_edges: set[tuple[int, int]] = set()

    def node_of(self, element: Node) -> CfgNode | None:
        """
        The graph node standing for *element*, or `None` if *element* is not part of this body (or is a
        node the graph does not represent on its own, such as a plain expression inside a statement).
        """
        return self._node_of.get(id(element))

    def is_exceptional(self, source: CfgNode, target: CfgNode) -> bool:
        """
        Whether the edge from *source* to *target* is taken only when *source* throws rather than
        completing normally. A definition *source* makes is not guaranteed to have happened along such
        an edge, so a flow-sensitive analysis must not treat it as a kill there.
        """
        return (id(source), id(target)) in self.exceptional_edges

Methods

def node_of(self, element)

The graph node standing for element, or None if element is not part of this body (or is a node the graph does not represent on its own, such as a plain expression inside a statement).

Expand source code Browse git
def node_of(self, element: Node) -> CfgNode | None:
    """
    The graph node standing for *element*, or `None` if *element* is not part of this body (or is a
    node the graph does not represent on its own, such as a plain expression inside a statement).
    """
    return self._node_of.get(id(element))
def is_exceptional(self, source, target)

Whether the edge from source to target is taken only when source throws rather than completing normally. A definition source makes is not guaranteed to have happened along such an edge, so a flow-sensitive analysis must not treat it as a kill there.

Expand source code Browse git
def is_exceptional(self, source: CfgNode, target: CfgNode) -> bool:
    """
    Whether the edge from *source* to *target* is taken only when *source* throws rather than
    completing normally. A definition *source* makes is not guaranteed to have happened along such
    an edge, so a flow-sensitive analysis must not treat it as a kill there.
    """
    return (id(source), id(target)) in self.exceptional_edges
class ControlFlowModel (root)

The per-function control-flow graphs of one script, paired with the ElementLocator that maps any AST node to the graph node evaluating it. Built once over the script root — the graphs are purely syntactic, needing no SemanticModel — and shared by the DominanceModel and LivenessModel layered on it, which would otherwise each rebuild the whole set.

Expand source code Browse git
class ControlFlowModel:
    """
    The per-function control-flow graphs of one script, paired with the `ElementLocator` that maps any
    AST node to the graph node evaluating it. Built once over the script root — the graphs are purely
    syntactic, needing no `refinery.lib.scripts.js.analysis.model.SemanticModel` — and shared by the
    `DominanceModel` and `LivenessModel` layered on it, which would otherwise each rebuild the whole set.
    """

    def __init__(self, root: JsScript):
        self.graphs = build_control_flow(root)
        self._locator = ElementLocator(self.graphs)

    def graph_of(self, owner: Node) -> ControlFlowGraph | None:
        """
        The control-flow graph owned by *owner* — a function node or the script root — or `None` when it
        owns none.
        """
        return self.graphs.get(id(owner))

    def node_of(self, element: Node) -> CfgNode | None:
        """
        The control-flow node standing for *element*, or `None` when the graphs do not represent it
        directly (a plain expression inside a statement). Delegates to the shared `ElementLocator`.
        """
        return self._locator.node_of(element)

    def locate(self, element: Node) -> tuple[ControlFlowGraph, CfgNode] | None:
        """
        The graph and node that evaluate *element*, climbing out of any enclosing expression, or `None`
        when it has no enclosing graph node. Delegates to the shared `ElementLocator`.
        """
        return self._locator.locate(element)

Methods

def graph_of(self, owner)

The control-flow graph owned by owner — a function node or the script root — or None when it owns none.

Expand source code Browse git
def graph_of(self, owner: Node) -> ControlFlowGraph | None:
    """
    The control-flow graph owned by *owner* — a function node or the script root — or `None` when it
    owns none.
    """
    return self.graphs.get(id(owner))
def node_of(self, element)

The control-flow node standing for element, or None when the graphs do not represent it directly (a plain expression inside a statement). Delegates to the shared ElementLocator.

Expand source code Browse git
def node_of(self, element: Node) -> CfgNode | None:
    """
    The control-flow node standing for *element*, or `None` when the graphs do not represent it
    directly (a plain expression inside a statement). Delegates to the shared `ElementLocator`.
    """
    return self._locator.node_of(element)
def locate(self, element)

The graph and node that evaluate element, climbing out of any enclosing expression, or None when it has no enclosing graph node. Delegates to the shared ElementLocator.

Expand source code Browse git
def locate(self, element: Node) -> tuple[ControlFlowGraph, CfgNode] | None:
    """
    The graph and node that evaluate *element*, climbing out of any enclosing expression, or `None`
    when it has no enclosing graph node. Delegates to the shared `ElementLocator`.
    """
    return self._locator.locate(element)
class EffectModel (model)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    def _fresh_container_origin(self, binding: Binding) -> bool:
        """
        Whether *binding* only ever holds a freshly built container: a rest parameter (always a new
        array) or a `var`/`let`/`const` whose every declaration initializes it to an object, array, or
        function literal. A plain parameter, a catch binding, or a local initialized from anything that
        could alias an external object fails, since a write through it could then be observed elsewhere.
        An object literal that declares its own getter or setter — or installs a custom prototype
        through `__proto__:`, which may carry an inherited one — also fails: a member write to such a
        container can run an accessor, an effect a caller can observe, so the write is not unobservable.
        """
        if self._is_rest_param(binding):
            return True
        if binding.kind not in (BindingKind.VAR, BindingKind.LET, BindingKind.CONST):
            return False
        if not binding.declarations:
            return False
        for decl in binding.declarations:
            declarator = decl.parent
            if not isinstance(declarator, JsVariableDeclarator):
                return False
            init = declarator.init
            if not isinstance(init, (
                JsArrayExpression, JsObjectExpression, JsFunctionExpression, JsArrowFunctionExpression,
            )):
                return False
            if isinstance(init, JsObjectExpression) and object_member_access_runs_accessor(init):
                return False
        return True

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    def _base_is_safe(self, node: Node) -> bool:
        """
        Whether a property access on *node* cannot throw because *node* is known not to be nullish: a
        freshly built value, the global object, a pristine intrinsic root, a never-rebound rest parameter,
        or a member chain on one. A rest parameter is bound to a fresh array at function entry, before any
        body statement runs — no temporal-dead-zone or hoisted-`undefined` window a flow-insensitive check
        could miss — so a member access on it is safe wherever it appears, provided the name is never
        reassigned to a value that could be nullish. A `var`/`let`/`const` local initialized to a literal
        is deliberately NOT admitted here: its initializer may not have run yet at the access
        (`function(){ a.x = 1; var a = []; }` throws), which this flow-insensitive predicate cannot rule
        out.
        """
        if isinstance(node, (
            JsArrayExpression,
            JsObjectExpression,
            JsFunctionExpression,
            JsStringLiteral,
            JsNumericLiteral,
            JsBooleanLiteral,
        )):
            return True
        if isinstance(node, JsIdentifier):
            if node.name in GLOBAL_OBJECT_ALIASES:
                return True
            if isinstance(self.intrinsic_of(node), str):
                return True
            binding = self.model.resolve(node)
            return binding is not None and self._is_rest_param(binding) and not binding.writes
        if isinstance(node, JsMemberExpression):
            return node.object is not None and self._base_is_safe(node.object)
        return False

    def _base_getter_safe(self, node: Node) -> bool:
        """
        Whether reading a property of *node* cannot run a user-defined getter, so the read carries no
        hidden effect: a freshly built value with no accessor of its own and no installed prototype, a
        primitive, or a pristine intrinsic root. Unlike `_base_is_safe`, the global object does not
        qualify — a global property such as `location` may be an accessor — so a read through it is
        treated as an unknown call.
        """
        if isinstance(node, (
            JsArrayExpression,
            JsFunctionExpression,
            JsStringLiteral,
            JsNumericLiteral,
            JsBooleanLiteral,
        )):
            return True
        if isinstance(node, JsObjectExpression):
            return not object_member_access_runs_accessor(node)
        if isinstance(node, JsIdentifier):
            return isinstance(self.intrinsic_of(node), str)
        if isinstance(node, JsMemberExpression):
            return node.object is not None and self._base_getter_safe(node.object)
        return False

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

Methods

def summary_of(self, func)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    @property
    def is_value_replaceable(self) -> bool:
        """
        Whether replacing a call to the summarized function with its computed return value drops no
        observable effect. This holds when the call writes no state visible after it returns — neither a
        global nor a captured binding, nor a fresh local it mutates and then returns
        (`mutates_returned_local`), which is a distinct object per call and so cannot be substituted as a
        shared value — and the call returns its value directly rather than wrapped:
        an `async` function's call is a promise and a generator's is an iterator, neither equal to the
        return expression, so `wraps_return` disqualifies it. Unlike `is_pure`, a call that may throw or
        read unknown state still qualifies: an evaluator that actually executes the call to a value
        reproduces those, and only a *write* would be silently lost. `is_pure`, which additionally
        forbids throwing and unknown reads, is the right test for removing a call outright rather than
        replacing it.
        """
        return not (
            self.writes_global
            or self.writes_captured
            or self.wraps_return
            or self.mutates_returned_local
        )

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

Instance variables

var written_bindings

The type of the None singleton.

var writes_global

The type of the None singleton.

var writes_captured

The type of the None singleton.

var throws

The type of the None singleton.

var calls_unknown

The type of the None singleton.

var mutates_returned_local

The type of the None singleton.

var wraps_return

The type of the None singleton.

var is_pure

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

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

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

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

Whether replacing a call to the summarized function with its computed return value drops no observable effect. This holds when the call writes no state visible after it returns — neither a global nor a captured binding, nor a fresh local it mutates and then returns (mutates_returned_local), which is a distinct object per call and so cannot be substituted as a shared value — and the call returns its value directly rather than wrapped: an async function's call is a promise and a generator's is an iterator, neither equal to the return expression, so wraps_return disqualifies it. Unlike is_pure, a call that may throw or read unknown state still qualifies: an evaluator that actually executes the call to a value reproduces those, and only a write would be silently lost. is_pure, which additionally forbids throwing and unknown reads, is the right test for removing a call outright rather than replacing it.

Expand source code Browse git
@property
def is_value_replaceable(self) -> bool:
    """
    Whether replacing a call to the summarized function with its computed return value drops no
    observable effect. This holds when the call writes no state visible after it returns — neither a
    global nor a captured binding, nor a fresh local it mutates and then returns
    (`mutates_returned_local`), which is a distinct object per call and so cannot be substituted as a
    shared value — and the call returns its value directly rather than wrapped:
    an `async` function's call is a promise and a generator's is an iterator, neither equal to the
    return expression, so `wraps_return` disqualifies it. Unlike `is_pure`, a call that may throw or
    read unknown state still qualifies: an evaluator that actually executes the call to a value
    reproduces those, and only a *write* would be silently lost. `is_pure`, which additionally
    forbids throwing and unknown reads, is the right test for removing a call outright rather than
    replacing it.
    """
    return not (
        self.writes_global
        or self.writes_captured
        or self.wraps_return
        or self.mutates_returned_local
    )

Methods

def absorb(self, other)

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

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

Flow-sensitive live-variable sets and dead-store verdicts for one script, built over a SemanticModel. Query a control-flow node's live sets with live_in and live_out, find the node standing for an AST element with node_of, and ask whether a write is dead with is_dead_store. Build through build_liveness().

Expand source code Browse git
class LivenessModel:
    """
    Flow-sensitive live-variable sets and dead-store verdicts for one script, built over a
    `refinery.lib.scripts.js.analysis.model.SemanticModel`. Query a control-flow node's live sets
    with `live_in` and `live_out`, find the node standing for an AST element with `node_of`, and ask
    whether a write is dead with `is_dead_store`. Build through `build_liveness`.
    """

    def __init__(self, model: SemanticModel, control_flow: ControlFlowModel | None = None):
        self.model = model
        self._flow = control_flow if control_flow is not None else build_control_flow_model(model.root)
        self._live_in: dict[int, frozenset[Binding]] = {}
        self._live_out: dict[int, frozenset[Binding]] = {}
        self._pseudo_locals: dict[int, frozenset[Binding]] = {}
        self._index_pseudo_locals()
        for graph in self._flow.graphs.values():
            self._compute_graph(graph)

    def live_in(self, node: CfgNode) -> frozenset[Binding]:
        """
        The bindings live on entry to *node* — those that may be read before being overwritten on some
        path that begins at *node*.
        """
        return self._live_in.get(id(node), frozenset())

    def live_out(self, node: CfgNode) -> frozenset[Binding]:
        """
        The bindings live on exit from *node* — those that may be read on some path that leaves it,
        including the path taken if *node* throws.
        """
        return self._live_out.get(id(node), frozenset())

    def node_of(self, element: Node) -> CfgNode | None:
        """
        The control-flow node standing for *element* in whichever function graph owns it, or `None` if
        *element* is not a node the graphs represent.
        """
        return self._flow.node_of(element)

    def is_dead_store(self, write: JsIdentifier) -> bool:
        """
        Whether the value written to a binding at *write* is never read on any execution. Only an
        unconditional store to an uncaptured function-local `var`/`let` qualifies; a read, a compound or
        conditional write, a captured or outer binding, or a store whose value may still be read all
        return `False`, the conservative verdict. The verdict concerns the stored *value* alone: a
        caller removing the store must still preserve any side effect of the expression producing it.

        No store is reported while a `with` or direct `eval` lexically inside the owning function could
        read the local by name without a reference the model sees. A reflective surface elsewhere in the
        program runs in the global scope and cannot reach a local, so it does not suppress the report.
        """
        located = self._flow.locate(write)
        if located is None:
            return False
        graph, node = located
        owner_scope = self.model.function_scope(graph.owner)
        binding, construct = self._store_target(write)
        if binding is None or construct is None:
            return False
        if not self._trackable(binding, owner_scope):
            return False
        if self.model.reflection_can_reach(binding):
            return False
        if binding in self.live_out(node):
            return False
        return self._unobserved_within(graph, node, write, binding, construct)

    def dead_stores(self) -> list[JsIdentifier]:
        """
        Every write identifier in the script whose stored value is dead, in source order.
        """
        result: list[JsIdentifier] = []
        for node in self.model.root.walk_in_order():
            if isinstance(node, JsIdentifier) and self._is_candidate_write(node):
                if self.is_dead_store(node):
                    result.append(node)
        return result

    def is_dead_on_entry(self, binding: Binding, function: Node) -> bool:
        """
        Whether *function* writes *binding* before reading it on every path, so no value carried into the
        call — from a previous invocation or from load — is ever observed. Answered from the liveness at
        the function's control-flow entry; a binding not tracked in *function* returns `False`.
        """
        graph = self._flow.graph_of(function)
        if graph is None:
            return False
        return binding not in self.live_in(graph.entry)

    def localization_target(self, binding: Binding) -> Node | None:
        """
        The function into which *binding*, a script-scope `var`, can be soundly relocated, or `None`. A
        binding qualifies when every reference is owned by one function, that function writes it before
        any read (so a value carried across calls or from load is never observed), it has no initializer
        whose load-time effect the move would strand, no reference reaches it through a global-object
        alias member (`globalThis.x`, which would no longer find it once it leaves the global object),
        and the program keeps no reflection surface that could read it by name. Relocating it tightens a
        pseudo-global into the local it behaves as.
        """
        if self.model.has_reflection_surface():
            return None
        if binding.kind is not BindingKind.VAR or binding.scope is not self.model.root_scope:
            return None
        if binding.has_member_reference:
            return None
        if self._has_initializer(binding):
            return None
        function = self._sole_owning_function(binding)
        if function is None:
            return None
        if not self.is_dead_on_entry(binding, function):
            return None
        return function

    def localizable_bindings(self) -> list[tuple[Binding, Node]]:
        """
        Every script-scope `var` binding that can be relocated into a function, each paired with that
        function, in the order the script declares them.
        """
        result: list[tuple[Binding, Node]] = []
        for binding in self.model.root_scope.bindings.values():
            function = self.localization_target(binding)
            if function is not None:
                result.append((binding, function))
        return result

    def _index_pseudo_locals(self):
        """
        Group the script-scope `var` bindings that each behave as a single function's locals, keyed by
        that function, so the dataflow can track them inside it. A binding qualifies when every reference
        lies in one function and none at script scope or in a function nested below it.
        """
        grouped: dict[int, set[Binding]] = {}
        for binding in self.model.root_scope.bindings.values():
            if binding.kind is not BindingKind.VAR:
                continue
            function = self._sole_owning_function(binding)
            if function is not None:
                grouped.setdefault(id(function), set()).add(binding)
        self._pseudo_locals = {owner: frozenset(bindings) for owner, bindings in grouped.items()}

    def _compute_graph(self, graph: ControlFlowGraph):
        owner_scope = self.model.function_scope(graph.owner)
        use: dict[int, set[Binding]] = {}
        kill: dict[int, set[Binding]] = {}
        for node in graph.nodes:
            use[id(node)], kill[id(node)] = self._node_sets(graph, node, owner_scope)
        live_in: dict[int, set[Binding]] = {id(n): set() for n in graph.nodes}
        live_out: dict[int, set[Binding]] = {id(n): set() for n in graph.nodes}
        changed = True
        while changed:
            changed = False
            for node in reversed(graph.nodes):
                normal: set[Binding] = set()
                exceptional: set[Binding] = set()
                for successor in node.successors:
                    if graph.is_exceptional(node, successor):
                        exceptional |= live_in[id(successor)]
                    else:
                        normal |= live_in[id(successor)]
                out = normal | exceptional
                inn = use[id(node)] | (normal - kill[id(node)]) | exceptional
                if out != live_out[id(node)] or inn != live_in[id(node)]:
                    live_out[id(node)] = out
                    live_in[id(node)] = inn
                    changed = True
        for node in graph.nodes:
            self._live_in[id(node)] = frozenset(live_in[id(node)])
            self._live_out[id(node)] = frozenset(live_out[id(node)])

    def _node_sets(
        self, graph: ControlFlowGraph, node: CfgNode, owner_scope: Scope | None,
    ) -> tuple[set[Binding], set[Binding]]:
        use: set[Binding] = set()
        kill: set[Binding] = set()
        if node.element is None:
            return use, kill
        for ident in self._shallow_idents(graph, node.element):
            declared = self.model.binding_of(ident)
            if declared is not None:
                if self._trackable(declared, owner_scope) and self._declarator_has_init(ident):
                    kill.add(declared)
                continue
            if not is_use_position(ident):
                continue
            binding = self.model.resolve(ident)
            if binding is None or not self._analysable(graph, binding, owner_scope):
                continue
            role = reference_role(ident)
            if role is not Role.WRITE:
                use.add(binding)
            elif self._is_assignment_kill(ident, node.element):
                kill.add(binding)
        return use, kill

    def _unobserved_within(
        self,
        graph: ControlFlowGraph,
        node: CfgNode,
        write: JsIdentifier,
        binding: Binding,
        construct: Node,
    ) -> bool:
        """
        Whether no reference to *binding* other than *write* within *node* can observe *write*'s value.
        A read nested in *construct* (the assignment or declarator performing the write) consumes the
        prior value, so it is ignored; any other reference — a later read or a second write in the same
        statement — is treated conservatively as observing the store, since intra-statement order is not
        modelled.
        """
        assert node.element is not None
        for ident in self._shallow_idents(graph, node.element):
            if ident is write:
                continue
            if self._reference_binding(ident) is not binding:
                continue
            if self._is_read(ident) and ident.is_descendant_of(construct):
                continue
            return False
        return True

    def _shallow_idents(self, graph: ControlFlowGraph, element: Node) -> Iterator[JsIdentifier]:
        """
        Yield the identifiers belonging to *element*'s own control-flow node: those in its subtree that
        are not inside a nested function or a descendant that is itself a separate control-flow node
        (whose identifiers are accounted there). This keeps a loop or branch head from double-counting
        the body that follows it.
        """
        stack: list[Node] = list(element.children())
        while stack:
            current = stack.pop()
            if isinstance(current, FUNCTION_NODES):
                continue
            if graph.node_of(current) is not None:
                continue
            if isinstance(current, JsIdentifier):
                yield current
            stack.extend(current.children())

    def _store_target(self, write: JsIdentifier) -> tuple[Binding | None, Node | None]:
        """
        The binding *write* stores into and the construct whose completion performs the store, or
        `(None, None)` if *write* is not an unconditional value store: a `var`/`let`/`const` declarator
        with an initializer, or the target of a plain `=` assignment.
        """
        declared = self.model.binding_of(write)
        if declared is not None:
            if not self._declarator_has_init(write):
                return None, None
            return declared, self._enclosing_declarator(write)
        if not is_use_position(write):
            return None, None
        binding = self.model.resolve(write)
        if binding is None or reference_role(write) is not Role.WRITE:
            return None, None
        governor = self._governor(write)
        if not isinstance(governor, JsAssignmentExpression) or governor.operator != '=':
            return None, None
        return binding, governor

    def _is_candidate_write(self, ident: JsIdentifier) -> bool:
        if self.model.binding_of(ident) is not None:
            return self._declarator_has_init(ident)
        if not is_use_position(ident):
            return False
        return reference_role(ident) is Role.WRITE

    def _is_assignment_kill(self, ident: JsIdentifier, element: Node) -> bool:
        governor = self._governor(ident)
        if not isinstance(governor, JsAssignmentExpression) or governor.operator != '=':
            return False
        return self._is_unconditional(ident, element)

    def _is_read(self, ident: JsIdentifier) -> bool:
        if not self.model.is_reference(ident):
            return False
        return reference_role(ident) is not Role.WRITE

    def _reference_binding(self, ident: JsIdentifier) -> Binding | None:
        declared = self.model.binding_of(ident)
        if declared is not None:
            return declared
        if not is_use_position(ident):
            return None
        return self.model.resolve(ident)

    def _declarator_has_init(self, ident: JsIdentifier) -> bool:
        declarator = self._enclosing_declarator(ident)
        return declarator is not None and declarator.init is not None

    def _enclosing_declarator(self, ident: JsIdentifier) -> JsVariableDeclarator | None:
        governor, target = _governing_target(ident)
        if isinstance(governor, JsVariableDeclarator) and governor.id is target:
            return governor
        return None

    def _governor(self, ident: JsIdentifier) -> Node | None:
        """
        The construct that governs the binding target *ident* sits in — the assignment, declarator, or
        loop head reached by climbing out through any destructuring containers and parentheses around
        it, or `None` past the top of the tree.
        """
        governor, _ = _governing_target(ident)
        return governor

    @staticmethod
    def _is_unconditional(ident: JsIdentifier, element: Node) -> bool:
        """
        Whether *ident* is written every time its control-flow node *element* runs, i.e. its position is
        not guarded by a short-circuit operand, a conditional branch, or a destructuring default.
        """
        cursor: Node = ident
        while cursor is not element:
            parent = cursor.parent
            if parent is None:
                return True
            if isinstance(parent, JsLogicalExpression) and parent.right is cursor:
                return False
            if isinstance(parent, JsConditionalExpression) and cursor in (
                parent.consequent, parent.alternate,
            ):
                return False
            if isinstance(parent, JsAssignmentPattern) and parent.right is cursor:
                return False
            cursor = parent
        return True

    def _trackable(self, binding: Binding, owner_scope: Scope | None) -> bool:
        return (
            binding.kind in _CANDIDATE_KINDS
            and not binding.captured
            and owner_scope is not None
            and owner_scope.kind is ScopeKind.FUNCTION
            and binding.scope.var_scope is owner_scope
        )

    def _analysable(
        self, graph: ControlFlowGraph, binding: Binding, owner_scope: Scope | None,
    ) -> bool:
        """
        Whether *binding* is tracked in *graph*: either an uncaptured function-local of the graph's own
        function (the strict store candidate) or a script-scope `var` whose every reference is owned by
        that function and so behaves as one of its locals. The second case feeds only the entry-liveness
        `localization_target` reads; it never reaches `is_dead_store`, which keeps the strict candidacy.
        """
        if self._trackable(binding, owner_scope):
            return True
        return binding in self._pseudo_locals.get(id(graph.owner), frozenset())

    def _sole_owning_function(self, binding: Binding) -> Node | None:
        """
        The one function whose body lexically contains every reference to *binding*, or `None` when the
        references span more than one function, include one at script scope, or do not exist. A reference
        inside a function nested below the candidate counts as a separate owner, so a binding captured by
        such a nested closure is rejected.
        """
        owner: Node | None = None
        for ref in (*binding.reads, *binding.writes):
            function = enclosing_function(ref)
            if function is None:
                return None
            if owner is None:
                owner = function
            elif function is not owner:
                return None
        return owner

    def _has_initializer(self, binding: Binding) -> bool:
        for declaration in binding.declarations:
            declarator = self._enclosing_declarator(declaration)
            if declarator is not None and declarator.init is not None:
                return True
        return False

Methods

def live_in(self, node)

The bindings live on entry to node — those that may be read before being overwritten on some path that begins at node.

Expand source code Browse git
def live_in(self, node: CfgNode) -> frozenset[Binding]:
    """
    The bindings live on entry to *node* — those that may be read before being overwritten on some
    path that begins at *node*.
    """
    return self._live_in.get(id(node), frozenset())
def live_out(self, node)

The bindings live on exit from node — those that may be read on some path that leaves it, including the path taken if node throws.

Expand source code Browse git
def live_out(self, node: CfgNode) -> frozenset[Binding]:
    """
    The bindings live on exit from *node* — those that may be read on some path that leaves it,
    including the path taken if *node* throws.
    """
    return self._live_out.get(id(node), frozenset())
def node_of(self, element)

The control-flow node standing for element in whichever function graph owns it, or None if element is not a node the graphs represent.

Expand source code Browse git
def node_of(self, element: Node) -> CfgNode | None:
    """
    The control-flow node standing for *element* in whichever function graph owns it, or `None` if
    *element* is not a node the graphs represent.
    """
    return self._flow.node_of(element)
def is_dead_store(self, write)

Whether the value written to a binding at write is never read on any execution. Only an unconditional store to an uncaptured function-local var/let qualifies; a read, a compound or conditional write, a captured or outer binding, or a store whose value may still be read all return False, the conservative verdict. The verdict concerns the stored value alone: a caller removing the store must still preserve any side effect of the expression producing it.

No store is reported while a with or direct eval lexically inside the owning function could read the local by name without a reference the model sees. A reflective surface elsewhere in the program runs in the global scope and cannot reach a local, so it does not suppress the report.

Expand source code Browse git
def is_dead_store(self, write: JsIdentifier) -> bool:
    """
    Whether the value written to a binding at *write* is never read on any execution. Only an
    unconditional store to an uncaptured function-local `var`/`let` qualifies; a read, a compound or
    conditional write, a captured or outer binding, or a store whose value may still be read all
    return `False`, the conservative verdict. The verdict concerns the stored *value* alone: a
    caller removing the store must still preserve any side effect of the expression producing it.

    No store is reported while a `with` or direct `eval` lexically inside the owning function could
    read the local by name without a reference the model sees. A reflective surface elsewhere in the
    program runs in the global scope and cannot reach a local, so it does not suppress the report.
    """
    located = self._flow.locate(write)
    if located is None:
        return False
    graph, node = located
    owner_scope = self.model.function_scope(graph.owner)
    binding, construct = self._store_target(write)
    if binding is None or construct is None:
        return False
    if not self._trackable(binding, owner_scope):
        return False
    if self.model.reflection_can_reach(binding):
        return False
    if binding in self.live_out(node):
        return False
    return self._unobserved_within(graph, node, write, binding, construct)
def dead_stores(self)

Every write identifier in the script whose stored value is dead, in source order.

Expand source code Browse git
def dead_stores(self) -> list[JsIdentifier]:
    """
    Every write identifier in the script whose stored value is dead, in source order.
    """
    result: list[JsIdentifier] = []
    for node in self.model.root.walk_in_order():
        if isinstance(node, JsIdentifier) and self._is_candidate_write(node):
            if self.is_dead_store(node):
                result.append(node)
    return result
def is_dead_on_entry(self, binding, function)

Whether function writes binding before reading it on every path, so no value carried into the call — from a previous invocation or from load — is ever observed. Answered from the liveness at the function's control-flow entry; a binding not tracked in function returns False.

Expand source code Browse git
def is_dead_on_entry(self, binding: Binding, function: Node) -> bool:
    """
    Whether *function* writes *binding* before reading it on every path, so no value carried into the
    call — from a previous invocation or from load — is ever observed. Answered from the liveness at
    the function's control-flow entry; a binding not tracked in *function* returns `False`.
    """
    graph = self._flow.graph_of(function)
    if graph is None:
        return False
    return binding not in self.live_in(graph.entry)
def localization_target(self, binding)

The function into which binding, a script-scope var, can be soundly relocated, or None. A binding qualifies when every reference is owned by one function, that function writes it before any read (so a value carried across calls or from load is never observed), it has no initializer whose load-time effect the move would strand, no reference reaches it through a global-object alias member (globalThis.x, which would no longer find it once it leaves the global object), and the program keeps no reflection surface that could read it by name. Relocating it tightens a pseudo-global into the local it behaves as.

Expand source code Browse git
def localization_target(self, binding: Binding) -> Node | None:
    """
    The function into which *binding*, a script-scope `var`, can be soundly relocated, or `None`. A
    binding qualifies when every reference is owned by one function, that function writes it before
    any read (so a value carried across calls or from load is never observed), it has no initializer
    whose load-time effect the move would strand, no reference reaches it through a global-object
    alias member (`globalThis.x`, which would no longer find it once it leaves the global object),
    and the program keeps no reflection surface that could read it by name. Relocating it tightens a
    pseudo-global into the local it behaves as.
    """
    if self.model.has_reflection_surface():
        return None
    if binding.kind is not BindingKind.VAR or binding.scope is not self.model.root_scope:
        return None
    if binding.has_member_reference:
        return None
    if self._has_initializer(binding):
        return None
    function = self._sole_owning_function(binding)
    if function is None:
        return None
    if not self.is_dead_on_entry(binding, function):
        return None
    return function
def localizable_bindings(self)

Every script-scope var binding that can be relocated into a function, each paired with that function, in the order the script declares them.

Expand source code Browse git
def localizable_bindings(self) -> list[tuple[Binding, Node]]:
    """
    Every script-scope `var` binding that can be relocated into a function, each paired with that
    function, in the order the script declares them.
    """
    result: list[tuple[Binding, Node]] = []
    for binding in self.model.root_scope.bindings.values():
        function = self.localization_target(binding)
        if function is not None:
            result.append((binding, function))
    return result
class Role (*args, **kwds)

Create a collection of name/value pairs.

Example enumeration:

>>> class Color(Enum):
...     RED = 1
...     BLUE = 2
...     GREEN = 3

Access them by:

  • attribute access:

Color.RED

  • value lookup:

Color(1)

  • name lookup:

Color['RED']

Enumerations can be iterated over, and know how many members they have:

>>> len(Color)
3
>>> list(Color)
[<Color.RED: 1>, <Color.BLUE: 2>, <Color.GREEN: 3>]

Methods can be added to enumerations, and members can have their own attributes – see the documentation for details.

Expand source code Browse git
class Role(enum.Enum):
    READ      = 'read'        # noqa
    WRITE     = 'write'       # noqa
    READWRITE = 'readwrite'   # noqa

Ancestors

  • enum.Enum

Class variables

var READ

The type of the None singleton.

var WRITE

The type of the None singleton.

var READWRITE

The type of the None singleton.

class Scope (kind, node, parent=None, children=<factory>, bindings=<factory>, is_dynamic=False)

A lexical scope. node is the AST node that introduces it (the script, a function, a block, a catch clause, a class, or a with). is_dynamic marks a region whose bindings cannot be resolved statically because names may be injected at runtime (with, direct eval).

Expand source code Browse git
@dataclass(eq=False)
class Scope:
    """
    A lexical scope. `node` is the AST node that introduces it (the script, a function, a block, a
    catch clause, a class, or a `with`). `is_dynamic` marks a region whose bindings cannot be resolved
    statically because names may be injected at runtime (`with`, direct `eval`).
    """
    kind: ScopeKind
    node: Node
    parent: Scope | None = None
    children: list[Scope] = field(default_factory=list)
    bindings: dict[str, Binding] = field(default_factory=dict)
    is_dynamic: bool = False

    @property
    def is_var_scope(self) -> bool:
        """
        Whether this scope is the target of `var`/function-declaration hoisting: a function body, a
        class static block, or the script itself.
        """
        return (
            self.kind is ScopeKind.FUNCTION
            or self.kind is ScopeKind.SCRIPT
            or self.kind is ScopeKind.STATIC_BLOCK
        )

    @property
    def var_scope(self) -> Scope | None:
        """
        The function or script scope that governs `var`/function-declaration hoisting for this scope:
        this scope itself when it is already a var-scope, otherwise the nearest enclosing one (the
        boundary a closure crosses).
        """
        scope: Scope | None = self
        while scope is not None and not scope.is_var_scope:
            scope = scope.parent
        return scope

    def contains(self, other: Scope, *, strict: bool = False) -> bool:
        """
        Whether this scope lexically contains *other*: *other* itself or any scope nested below it.
        With *strict*, the reflexive case is excluded, so only a scope nested strictly below this one
        qualifies — the shape of the shadowing test in `SemanticModel.is_shadowed`.
        """
        cursor: Scope | None = other.parent if strict else other
        while cursor is not None:
            if cursor is self:
                return True
            cursor = cursor.parent
        return False

Instance variables

var kind

The type of the None singleton.

var node

The type of the None singleton.

var children

The type of the None singleton.

var bindings

The type of the None singleton.

var parent

The type of the None singleton.

var is_dynamic

The type of the None singleton.

var is_var_scope

Whether this scope is the target of var/function-declaration hoisting: a function body, a class static block, or the script itself.

Expand source code Browse git
@property
def is_var_scope(self) -> bool:
    """
    Whether this scope is the target of `var`/function-declaration hoisting: a function body, a
    class static block, or the script itself.
    """
    return (
        self.kind is ScopeKind.FUNCTION
        or self.kind is ScopeKind.SCRIPT
        or self.kind is ScopeKind.STATIC_BLOCK
    )
var var_scope

The function or script scope that governs var/function-declaration hoisting for this scope: this scope itself when it is already a var-scope, otherwise the nearest enclosing one (the boundary a closure crosses).

Expand source code Browse git
@property
def var_scope(self) -> Scope | None:
    """
    The function or script scope that governs `var`/function-declaration hoisting for this scope:
    this scope itself when it is already a var-scope, otherwise the nearest enclosing one (the
    boundary a closure crosses).
    """
    scope: Scope | None = self
    while scope is not None and not scope.is_var_scope:
        scope = scope.parent
    return scope

Methods

def contains(self, other, *, strict=False)

Whether this scope lexically contains other: other itself or any scope nested below it. With strict, the reflexive case is excluded, so only a scope nested strictly below this one qualifies — the shape of the shadowing test in SemanticModel.is_shadowed().

Expand source code Browse git
def contains(self, other: Scope, *, strict: bool = False) -> bool:
    """
    Whether this scope lexically contains *other*: *other* itself or any scope nested below it.
    With *strict*, the reflexive case is excluded, so only a scope nested strictly below this one
    qualifies — the shape of the shadowing test in `SemanticModel.is_shadowed`.
    """
    cursor: Scope | None = other.parent if strict else other
    while cursor is not None:
        if cursor is self:
            return True
        cursor = cursor.parent
    return False
class ScopeKind (*args, **kwds)

Create a collection of name/value pairs.

Example enumeration:

>>> class Color(Enum):
...     RED = 1
...     BLUE = 2
...     GREEN = 3

Access them by:

  • attribute access:

Color.RED

  • value lookup:

Color(1)

  • name lookup:

Color['RED']

Enumerations can be iterated over, and know how many members they have:

>>> len(Color)
3
>>> list(Color)
[<Color.RED: 1>, <Color.BLUE: 2>, <Color.GREEN: 3>]

Methods can be added to enumerations, and members can have their own attributes – see the documentation for details.

Expand source code Browse git
class ScopeKind(enum.Enum):
    SCRIPT   = 'script'    # noqa
    FUNCTION = 'function'  # noqa
    BLOCK    = 'block'     # noqa
    CATCH    = 'catch'     # noqa
    CLASS    = 'class'     # noqa
    WITH     = 'with'      # noqa
    STATIC_BLOCK = 'static-block'  # noqa

Ancestors

  • enum.Enum

Class variables

var SCRIPT

The type of the None singleton.

var FUNCTION

The type of the None singleton.

var BLOCK

The type of the None singleton.

var CATCH

The type of the None singleton.

var CLASS

The type of the None singleton.

var WITH

The type of the None singleton.

var STATIC_BLOCK

The type of the None singleton.

class SemanticModel (root)

The resolved scope/binding/def-use model for one script. Build it with build_semantic_model() and query it through resolve, scope_of, binding_of, references, is_shadowed, would_capture, and has_reflection_surface.

Expand source code Browse git
class SemanticModel:
    """
    The resolved scope/binding/def-use model for one script. Build it with `build_semantic_model` and
    query it through `resolve`, `scope_of`, `binding_of`, `references`, `is_shadowed`,
    `would_capture`, and `has_reflection_surface`.
    """

    def __init__(self, root: JsScript):
        self.root = root
        self._node_scope: dict[int, Scope] = {}
        self._binding_of: dict[int, Binding] = {}
        self._reflection_surface: bool | None = None
        self._opaque_surface_sites: list[Node] | None = None
        self._function_direct_eval_sites: dict[int, list[Node]] = {}
        self.root_scope: Scope = _ScopeBuilder(self).build(root)
        self._build_def_use()

    def scope_of(self, node: Node) -> Scope | None:
        """
        The innermost scope that lexically contains *node*, or `None` if the node was not part of the
        script the model was built from.
        """
        return self._node_scope.get(id(node))

    def function_scope(self, func: Node) -> Scope | None:
        """
        The scope a function (or the script) introduces for its body: the script's `root_scope`, or
        the body block's scope for a function node, and `None` when *func* has no body block.
        """
        if isinstance(func, JsScript):
            return self.root_scope
        body = getattr(func, 'body', None)
        if body is None:
            return None
        return self.scope_of(body)

    def binding_of(self, decl_id: JsIdentifier) -> Binding | None:
        """
        The binding introduced by a binding-site identifier (a declarator id, parameter, function or
        class name, catch parameter, or import local), or `None` if the identifier is not a binding
        site.
        """
        return self._binding_of.get(id(decl_id))

    def lookup(self, name: str, scope: Scope | None, *, cross_dynamic: bool = False) -> Binding | None:
        """
        Resolve *name* from *scope* outward through enclosing scopes, stopping at a dynamically-scoped
        region where the name could be injected at runtime. Returns `None` for a free name. With
        *cross_dynamic*, the walk does not stop at a dynamic boundary but continues outward to the binding
        the name would denote if the `with` object lacked the property — the lexical binding a dynamic
        scope could still reach at runtime — which is how a `with`-body reference is attributed to the
        binding it may touch. The default keeps the definite-resolution semantics every other caller
        relies on.
        """
        while scope is not None:
            binding = scope.bindings.get(name)
            if binding is not None:
                return binding
            if scope.is_dynamic and not cross_dynamic:
                return None
            scope = scope.parent
        return None

    def is_reference(self, node: JsIdentifier) -> bool:
        """
        Whether *node* is a referencing occurrence of a name: it occupies a use position and is not a
        binding site, so it reads or writes an existing binding rather than declaring one or naming a
        property, key, label, or import/export specifier. The binding-aware companion to the syntactic
        `is_use_position`; `resolve` resolves exactly the identifiers for which this holds.
        """
        return is_use_position(node) and id(node) not in self._binding_of

    def resolve(self, ref: JsIdentifier) -> Binding | None:
        """
        The binding a referencing identifier reads or writes, found by walking outward from its scope.
        Returns `None` when the name is free (an external global the program never assigns), when the
        identifier is not a reference (a property name, key, or label), or when resolution crosses a
        dynamically-scoped region where the name could be injected at runtime.
        """
        if not self.is_reference(ref):
            return None
        return self.lookup(ref.name, self._node_scope.get(id(ref)))

    def references(
        self, binding: Binding, *, exclude: Node | None = None,
    ) -> list[JsIdentifier | JsMemberExpression]:
        """
        Every reference (read or write) bound to *binding*, optionally omitting those that lie within
        the subtree of *exclude*. Each is a referencing identifier except the member-expression write
        site of a global written through an alias (see `Binding`).
        """
        nodes = binding.reads + binding.writes
        if exclude is None:
            return nodes
        return [n for n in nodes if n is not exclude and not n.is_descendant_of(exclude)]

    def dynamic_references(
        self, binding: Binding, *, exclude: Node | None = None,
    ) -> list[JsIdentifier]:
        """
        Every reference to *binding* that a dynamic scope resolves at runtime — an identifier inside a
        `with` body that could denote *binding* (it may instead denote a property of the `with` object,
        which is why the static `references` set omits it) — optionally omitting those within the subtree
        of *exclude*. Each is classified on demand by `reference_role` or `container_reference_role`, the
        same oracles the definite references use, so a consumer applies one role logic to both; only the
        ordering and alias-following a resolved reference permits do not carry to an uncertain one.
        """
        nodes = binding.dynamic_refs
        if exclude is None:
            return list(nodes)
        return [n for n in nodes if n is not exclude and not n.is_descendant_of(exclude)]

    def read_has_dynamic_effect(self, node: Node) -> bool:
        """
        Whether reading *node* as a value resolves through a dynamic scope — a bare identifier inside a
        `with` body — so that evaluating it is not a pure, droppable, or reorderable operand. Reading the
        bare name consults the `with` object first: a matching property fires the object's getter (or a
        proxy trap), an observable side effect; a missing one falls through to the lexical binding, or,
        failing that, throws a `ReferenceError`. Neither the getter nor the throw can be proved absent for
        an unknown object, so any reference that crosses a dynamic scope is effectful regardless of a
        lexical fallback. False for a statically resolved reference and any non-reference node.
        """
        if not isinstance(node, JsIdentifier) or not self.is_reference(node):
            return False
        return crosses_dynamic_scope(self._node_scope.get(id(node)))

    def naming_binding(self, function: Node) -> Binding | None:
        """
        The binding that gives *function* a name through which it can be invoked: the declared name of a
        named function declaration, or the single `var`/`let`/`const` declarator a function or arrow
        expression is the initializer of. `None` for an anonymous function whose invocation point cannot
        be pinned to a name — an IIFE, a callback, a function stored through any other expression.
        """
        if isinstance(function, JsFunctionDeclaration) and function.id is not None:
            return self.binding_of(function.id)
        parent = function.parent
        if (
            isinstance(parent, JsVariableDeclarator)
            and parent.init is function
            and isinstance(parent.id, JsIdentifier)
        ):
            return self.binding_of(parent.id)
        return None

    def invocation_binding(self, function: Node) -> Binding | None:
        """
        The binding whose value-reads are the sites through which *function* is invoked — its
        `naming_binding`, extended to a lone assignment installing it in an already-declared name
        (`f = function(){}`) as well as a named declaration or a declarator initializer. `None` for a
        function with no such name — an anonymous IIFE or callback, or one stored through a member or
        other non-identifier target — whose invocation cannot be pinned to a name. Unlike `naming_binding`
        this also recognizes the bare-assignment form, so a function held in a hoisted `var` assigned once
        is ordered by its calls rather than by its creation; a caller confirms the binding is singly
        declared, `binding_pinned_to` *function*, and free of dynamic references before trusting its reads
        to enumerate every invocation.
        """
        binding = self.naming_binding(function)
        if binding is not None:
            return binding
        parent = function.parent
        if (
            isinstance(parent, JsAssignmentExpression)
            and parent.operator == '='
            and parent.right is function
        ):
            target = strip_parens(parent.left)
            if isinstance(target, JsIdentifier):
                return self.resolve(target)
        return None

    def binding_pinned_to(self, binding: Binding, function: Node) -> bool:
        """
        Whether *binding* holds *function* as its one assigned value, so every read of it outside the
        value's temporal dead zone denotes *function* and its reads enumerate *function*'s invocations.
        True when the binding's only write is the assignment that establishes *function* — a bare
        `name = function(){}` records that target as its sole write — and false once any other write could
        give the name a different value. A named function declaration or a declarator initializer installs
        the value with no recorded write, so any write at all is a reassignment that unpins it. The
        single-declaration and dynamic-reference checks a caller also needs are left to the caller; this
        answers only the reassignment question.
        """
        parent = function.parent
        establishing = None
        if (
            isinstance(parent, JsAssignmentExpression)
            and parent.operator == '='
            and parent.right is function
        ):
            establishing = strip_parens(parent.left)
        return all(write is establishing for write in binding.writes)

    def object_property_reference_points(self, function: Node) -> list[Node] | None:
        """
        The reference points that no invocation of *function* can precede when it is installed as a
        property of a non-escaping local object — the read sites of that property. Returns them when
        *function* is the value of a `BASE.key = function` assignment whose `BASE` identifier resolves to
        a local binding that holds one object value (`singular_value` is a `JsObjectExpression`) and never
        escapes as a bare value — every reference to it is the object of a member access, so the object
        identity is pinned to that binding and the only way to obtain the callable is to read `BASE.key`.
        Every such read is a point the invocation follows, including one whose value is stored and called
        later; the establishing write installs the value without reading it and is excluded, as is an
        access of a statically different property, which never reads the value. A computed access whose
        key is not statically known (`BASE[expr]`) may read the property and is kept. The opaque reflective
        surfaces that could name the binding are added as points exactly as the name-based enumeration adds
        them, and a `with` that could rename the base (a `dynamic_refs` entry) makes the ordering
        unknowable and yields `None`, as does any pattern the recognition does not match, so a caller falls
        through to its name-based ordering.

        This is a bounded points-to fact: a method reached only through property reads on an object that
        never leaks is ordered by those reads, not by its creation site, which a member assignment target
        gives no name to order by. It answers, at the binding level, the ordering `invocation_binding`
        cannot when the callable is pinned to a member rather than a name.
        """
        parent = function.parent
        if not (
            isinstance(parent, JsAssignmentExpression)
            and parent.operator == '='
            and parent.right is function
        ):
            return None
        target = strip_parens(parent.left)
        if not isinstance(target, JsMemberExpression) or not isinstance(target.object, JsIdentifier):
            return None
        key = _member_property_name(target)
        if key is None:
            return None
        binding = self.resolve(target.object)
        if binding is None or not isinstance(self.singular_value(binding), JsObjectExpression):
            return None
        if binding.dynamic_refs:
            return None
        points: list[Node] = []
        for read in binding.reads:
            node = read
            access = node.parent
            while isinstance(access, JsParenthesizedExpression):
                node, access = access, access.parent
            if not isinstance(access, JsMemberExpression) or access.object is not node:
                return None
            name = _member_property_name(access)
            if name is not None and name != key:
                continue
            if _is_member_assignment_target(access):
                continue
            points.append(access)
        points.extend(
            site
            for site in self.reflection_surface_sites(binding)
            if not site.is_descendant_of(function)
        )
        return points

    def singular_value(self, binding: Binding | None) -> Node | None:
        """
        The single value node a *binding* provably holds: the initializer of a sole `var`/`let`/`const`
        declarator, the function of a sole function declaration, or the right-hand side of the one
        assignment that establishes a name written exactly once (`x = <value>`, the form namespace
        flattening leaves). `None` when the binding is absent, redeclared, reassigned to more than one
        value, dynamically rebindable, or declared with no initializer and never assigned. The value is
        what the name denotes wherever it is not in the value's temporal dead zone; a consumer that also
        needs the value established before a use orders it separately, since a bare-assignment binding
        reads `undefined` before its write. `EffectModel.function_of` is the function-typed specialization
        of this query, and it is the value-resolution the bare-assignment recognition sites route through
        instead of re-deriving binding shapes.
        """
        if binding is None or len(binding.declarations) != 1:
            return None
        if self.binding_maybe_reassigned_dynamically(binding):
            return None
        decl = binding.declarations[0]
        parent = decl.parent
        if not binding.writes:
            if isinstance(parent, JsFunctionDeclaration) and parent.id is decl:
                return parent
            if isinstance(parent, JsClassDeclaration) and parent.id is decl:
                return parent
            if isinstance(parent, JsVariableDeclarator) and parent.id is decl:
                return parent.init
            return None
        if len(binding.writes) == 1:
            assignment = binding.writes[0].parent
            if (
                isinstance(assignment, JsAssignmentExpression)
                and assignment.operator == '='
                and strip_parens(assignment.left) is binding.writes[0]
            ):
                return strip_parens(assignment.right)
        return None

    def establishment_sites(self, function: Node) -> list[Node] | None:
        """
        The nodes that must all have executed before *function*'s callable value is installed under the
        name it is invoked through, for a consumer that gates a use on execution order. The
        function-invocation view of `binding_establishment_sites`: `None` when *function* is not invoked
        through a single orderable name, so its presence cannot be ordered and the caller declines.
        """
        return self.binding_establishment_sites(self.invocation_binding(function))

    def binding_establishment_sites(self, binding: Binding | None) -> list[Node] | None:
        """
        The nodes that must all have executed before *binding*'s `singular_value` is installed, for a
        consumer that gates a use on execution order. An empty list when the value is hoisted into place
        before any statement runs — a function declaration — so no ordering is required; the declarator
        when the value is a `var`/`let`/`const` initializer, which is absent until that declarator runs;
        the class declaration when the value is a class, which is in its temporal dead zone until it runs;
        the recorded writes when a lone assignment installs it (`f = function(){}`, the form namespace
        flattening leaves). `None` when the binding holds no single such value, so its presence cannot be
        ordered and the caller declines. This mirrors `singular_value`'s binding shapes exactly, one query
        returning the value and the other the nodes that establish it. Ordering the returned nodes against
        the use is the caller's job, since that needs the dominance model this layer must not depend on.
        """
        if binding is None or len(binding.declarations) != 1:
            return None
        if binding.writes:
            return list(binding.writes)
        declaration = binding.declarations[0]
        parent = declaration.parent
        if isinstance(parent, JsFunctionDeclaration):
            return []
        if isinstance(parent, JsClassDeclaration):
            return [parent]
        if isinstance(parent, JsVariableDeclarator):
            return [parent]
        return None

    def is_shadowed(self, name: str, at: Node, outer: Scope) -> bool:
        """
        Whether *name*, referenced at *at*, resolves to a binding declared strictly inside *outer*
        rather than in *outer* itself or an enclosing scope. This replaces the various hand-rolled
        shadowing checks: a name shadowed below *outer* does not refer to *outer*'s binding.
        """
        binding = self.lookup(name, self._node_scope.get(id(at)))
        if binding is None:
            return False
        return outer.contains(binding.scope, strict=True)

    def would_capture(self, names: set[str], scope: Scope) -> bool:
        """
        Whether introducing a binding for any of *names* directly in *scope* would capture an
        identifier already meaningful there. Every use-position occurrence of one of *names* within
        *scope*, including in a nested function that would close over the new binding, must already
        resolve to a binding strictly nested below *scope* (see `is_shadowed`); otherwise that
        occurrence — free, inherited from an enclosing scope, or bound in *scope* itself — would be
        rebound by the introduced declaration.
        """
        for node in name_uses_in_scope(names, scope):
            if not self.is_shadowed(node.name, node, scope):
                return True
        return False

    def has_reflection_surface(self) -> bool:
        """
        Whether the program still contains a construct through which code could reference a global by
        name at runtime: a value-read of the `eval` or `Function` intrinsic in any form — a direct or
        indirect call, an alias (`var e = eval`), a comma sequence (`(0, eval)`), or a member access
        (`window.eval`, `g['Function']`) — a string-valued timer, a dynamic property access on the
        global object (`window[expr]`), or a `with` statement. Computed conservatively (over-reporting
        is safe): while any such surface remains, a dead global must not be removed, because reflective
        code may read it.
        """
        self._ensure_reflection_detected()
        assert self._reflection_surface is not None
        return self._reflection_surface

    def reflection_can_reach(self, binding: Binding) -> bool:
        """
        Whether a runtime name lookup could read or write *binding* without a reference this model
        records. Derived over the precise dynamic-scope facts. A global is reachable through any
        reflective surface — `eval`, `Function`, a string timer, dynamic global access, `with` — all of
        which run in the global scope, so it defers to the whole-program `has_reflection_surface`. A
        function-local is reachable only from within its own function and only by name: a `with` body that
        names it (a `dynamic_references` entry) or a direct `eval` in the function
        (`local_reachable_by_direct_eval`). A `with` that never names it cannot reach it, and reflective
        code in the global scope cannot name a local — so the local answer is exact, while the global one
        stays conservative (any surface).
        """
        owner = binding.scope.var_scope
        if owner is None or owner.kind is ScopeKind.SCRIPT:
            return self.has_reflection_surface()
        return bool(binding.dynamic_refs) or self._function_has_direct_eval(owner.node)

    def reachable_by_opaque_reflection(self, binding: Binding) -> bool:
        """
        Whether an opaque reflective surface — a value-read of `eval` or `Function`, a string timer, or a
        dynamic access on the global object — could name *binding* at runtime with no reference this model
        records. Unlike `reflection_can_reach`, a `with` body is not counted: a `with` that names the
        binding is attributed precisely as a `dynamic_references` entry, so a caller that already consults
        `dynamic_refs` needs only the opaque surfaces here, the ones that leave no attributable reference.
        A global is reachable through any such surface, all of which run in the global scope; a
        function-local only through a direct `eval` in its own function, since a surface running in the
        global scope cannot name a local. The boolean companion of `reflection_surface_sites` — true
        exactly when that site list is non-empty.
        """
        return bool(self.reflection_surface_sites(binding))

    def reflection_surface_sites(self, binding: Binding) -> list[Node]:
        """
        The AST nodes of the opaque reflective surfaces that could name *binding* at runtime with no
        reference this model records — the points no reflected invocation of it can precede. A caller
        ranks a definition against these to prove it runs before every such invocation, the site-level
        companion of `reachable_by_opaque_reflection`. For a global (script-scope) binding they are the
        whole-program opaque surfaces (`_opaque_reflection_sites`), each running in the global scope and
        able to name any global; for a function-local, the direct `eval` sites in its owning function
        (`_direct_eval_sites`), the only opaque surface that runs in the local's own scope and can name
        it. Empty exactly when the binding is not opaque-reflection reachable. A `with` surface is not
        included — a `with` that names the binding is attributed as a `dynamic_references` entry a caller
        consults separately.
        """
        owner = binding.scope.var_scope
        if owner is None or owner.kind is ScopeKind.SCRIPT:
            return self._opaque_reflection_sites()
        return self._direct_eval_sites(owner.node)

    def local_reachable_by_direct_eval(self, binding: Binding) -> bool:
        """
        Whether a direct `eval` positioned to name *binding* could read or write it with no reference this
        model records. True only for a function-local whose owning function — or a closure nested inside
        it, which inherits its scope — contains a direct `eval`, the one reflective surface that runs in
        the caller's own scope and can therefore name a local. False for a global: an opaque global-scope
        surface can name any global, but that is what the whole-program `reflection_can_reach` answers, and
        freezing every global on it is an over-approximation the caller must choose to accept, not a fact
        this query asserts. The `with` surface is not counted — a `with` body's accesses are attributed
        precisely as `dynamic_references`, so only the opaque `eval` case needs this per-function answer.
        """
        owner = binding.scope.var_scope
        if owner is None or owner.kind is ScopeKind.SCRIPT:
            return False
        return self._function_has_direct_eval(owner.node)

    def binding_maybe_reassigned_dynamically(self, binding: Binding) -> bool:
        """
        Whether a dynamic scope could rebind *binding* — give the name a new value through a surface
        the static `writes` set does not record. A `with` body that names it as an assignment target
        may rebind it (the target may instead be a property of the `with` object, but may equally be
        this binding, so it is treated as a possible rebind), and a direct `eval` in its owning
        function can rebind it opaquely. A member write or method call through the name does not
        rebind it — the name keeps its value — so only a dynamic reference whose role is not a plain
        read counts. A consumer that judges a binding's value stable from `writes` alone must also
        consult this, since neither reassignment leaves a `writes` entry; a script-scope binding
        reassigned only through an opaque `eval` stays the documented residual, as
        `local_reachable_by_direct_eval` reports it false there.
        """
        if self.local_reachable_by_direct_eval(binding):
            return True
        return any(
            reference_role(ref) is not Role.READ
            for ref in self.dynamic_references(binding)
        )

    def binding_never_reassigned(self, binding: Binding) -> bool:
        """
        Whether *binding* holds one value for its whole lifetime: it is never written after its
        declaration, statically (`writes`) or through a dynamic scope
        (`binding_maybe_reassigned_dynamically`). This is the value-stability contract a caller needs
        before treating the binding's initializer as its value everywhere — distinct from the
        orderability contract `dynamic_refs` expresses (whether every reference can be ranked), which a
        `with`-body read violates while a stable value does not. It does not itself require a single
        declaration; a caller that needs one checks `declarations` alongside.
        """
        return not binding.writes and not self.binding_maybe_reassigned_dynamically(binding)

    def reaches_global_object(self, binding: Binding, *, module_scope: bool) -> bool:
        """
        Whether *binding* is a property of the global object at runtime — the global a free name in
        global-scope reflected code (a `Function` body, an indirect `eval`, a string timer) resolves to.
        An implicit global always is. A top-level `var`/function declaration is, but only under the
        script execution model; under the module model (*module_scope*) it is scoped to the module and
        never reaches the global. A top-level `let`/`const`/`class`, or any binding nested below the
        script, is a distinct lexical binding that global-scope code cannot see.
        """
        if binding.kind is BindingKind.IMPLICIT_GLOBAL:
            return True
        if module_scope:
            return False
        return (
            binding.scope is self.root_scope
            and binding.is_hoisted
        )

    def _direct_eval_sites(self, function: Node) -> list[Node]:
        """
        The direct `eval` call sites within *function* — every call whose callee, once parentheses are
        stripped, is the bare identifier `eval` (see `is_direct_eval_call`), the one reflective surface
        that runs in the function's own scope and can therefore name its locals. Nested functions are
        included, since a direct `eval` in a closure inherits the enclosing locals. The `with` surface is
        not scanned — a `with` body's accesses are attributed precisely as dynamic references — so only
        direct eval needs a per-function answer. Computed once per function and memoized.
        """
        cached = self._function_direct_eval_sites.get(id(function))
        if cached is None:
            cached = [node for node in function.walk() if is_direct_eval_call(node)]
            self._function_direct_eval_sites[id(function)] = cached
        return cached

    def _function_has_direct_eval(self, function: Node) -> bool:
        return bool(self._direct_eval_sites(function))

    def _reads_reflective_intrinsic(self, node: JsIdentifier) -> bool:
        """
        Whether *node* obtains the genuine `eval`/`Function` intrinsic as a value: a read of the bare name
        in a use position that resolves to no binding, so it denotes the intrinsic rather than a local
        shadow. Naming the intrinsic as a value is itself the reflective surface — once obtained it can be
        aliased, sequenced (`(0, eval)(...)`), or passed on, all beyond what this model tracks — so the read
        alone is conclusive, with no need to follow where the value flows. A binding site that declares the
        name (`function eval(){}`, `var Function`) introduces a shadow rather than reading the intrinsic,
        and a name that resolves to such a shadow is not the intrinsic, so neither is a surface.
        """
        if node.name not in REFLECTIVE_INTRINSICS:
            return False
        if not self.is_reference(node):
            return False
        if reference_role(node) is not Role.READ:
            return False
        return self.lookup(node.name, self._node_scope.get(id(node))) is None

    def _ensure_reflection_detected(self) -> None:
        """
        Populate the reflection-surface memos in a single AST walk. A `with` statement contributes only
        to the whole-program surface; every other surface — an `import()`, a value-read of the
        `eval`/`Function` intrinsic, a reflective global-object member, or a string-valued timer — is
        opaque, and its node is collected so a caller can order a definition against the site. The
        whole-program surface is present when any opaque site exists or a `with` statement is seen.
        """
        if self._reflection_surface is not None:
            return
        sites: list[Node] = []
        saw_with = False
        for node in self.root.walk():
            if isinstance(node, JsWithStatement):
                saw_with = True
            elif isinstance(node, JsImportExpression):
                sites.append(node)
            elif isinstance(node, JsIdentifier):
                if self._reads_reflective_intrinsic(node):
                    sites.append(node)
            elif isinstance(node, JsMemberExpression):
                if _is_reflective_member(node):
                    sites.append(node)
            elif isinstance(node, JsCallExpression):
                if _is_string_timer(node):
                    sites.append(node)
        self._opaque_surface_sites = sites
        self._reflection_surface = saw_with or bool(sites)

    def _opaque_reflection_sites(self) -> list[Node]:
        """
        The AST nodes of the whole-program opaque reflective surfaces — a value-read of the
        `eval`/`Function` intrinsic, a reflective global-object member, a string-valued timer, or an
        `import()`. A `with` statement is not opaque (its body's accesses are attributed as dynamic
        references) and is excluded. Computed once and memoized; empty exactly when the program has no
        opaque surface, which `_has_opaque_reflection_surface` reports as its non-emptiness.
        """
        self._ensure_reflection_detected()
        assert self._opaque_surface_sites is not None
        return self._opaque_surface_sites

    def _has_opaque_reflection_surface(self) -> bool:
        return bool(self._opaque_reflection_sites())

    def _build_def_use(self):
        self._create_implicit_globals()
        for node in self.root.walk():
            if isinstance(node, JsMemberExpression):
                self._record_global_alias_member_reference(node)
                continue
            if not isinstance(node, JsIdentifier):
                continue
            if not self.is_reference(node):
                continue
            ref_scope = self._node_scope.get(id(node))
            binding = self.lookup(node.name, ref_scope)
            if binding is None:
                self._attribute_dynamic_reference(node, ref_scope)
                continue
            role = reference_role(node)
            if role is not Role.WRITE:
                binding.reads.append(node)
            if role is not Role.READ:
                binding.writes.append(node)
            if ref_scope is None or ref_scope.var_scope is not binding.scope.var_scope:
                binding.captured = True

    def _attribute_dynamic_reference(self, node: JsIdentifier, scope: Scope | None):
        """
        Attribute a reference that did not resolve statically to the binding it could reach across a
        dynamic scope. A name inside a `with` body resolves to `None` — it may denote a property of the
        `with` object or a lexical binding — so the def-use walk would otherwise drop it. Only a name that
        crosses a dynamic scope is a candidate; continuing the lookup past that boundary finds the lexical
        binding it may touch, and the reference is recorded on that binding's `dynamic_refs`. A genuinely
        free name that crosses no dynamic scope (an external global the program never declares) is left
        untouched, as is one whose cross-boundary lookup still finds no binding.
        """
        if not crosses_dynamic_scope(scope):
            return
        binding = self.lookup(node.name, scope, cross_dynamic=True)
        if binding is not None:
            binding.dynamic_refs.append(node)

    def _create_implicit_globals(self):
        """
        Give every implicitly-declared global a binding at script scope, so that the def-use pass that
        follows resolves its references to it like any other binding. A name becomes an implicit global
        when the program writes it — an assignment, update, or `for-in`/`for-of` target — without it
        resolving to any lexical binding, which in sloppy mode creates a property on the global object.
        A write through a member access on a global-object alias (`globalThis.g = ...`) likewise creates
        the named global; the reference itself — the alias write, and any alias read — is recorded
        against the binding by `_build_def_use` like any other reference, so this pass establishes
        existence only. A write that resolves through a dynamic scope is skipped: inside a `with` body
        the target may be a property of the `with` object rather than a global, so the model cannot
        claim a global binding.
        """
        for node in self.root.walk():
            if isinstance(node, JsMemberExpression):
                self._ensure_implicit_global_from_alias_write(node)
                continue
            if not isinstance(node, JsIdentifier) or not self.is_reference(node):
                continue
            scope = self._node_scope.get(id(node))
            if reference_role(node) is Role.READ:
                continue
            if self.lookup(node.name, scope) is not None or crosses_dynamic_scope(scope):
                continue
            self.root_scope.bindings.setdefault(
                node.name, Binding(node.name, BindingKind.IMPLICIT_GLOBAL, self.root_scope))

    def global_alias_member_name(self, member: JsMemberExpression) -> str | None:
        """
        The name of the global that a member access on a global-object alias references
        (`globalThis.g`, `window['g']` → `g`), or `None` when *member* is not such an access. The alias
        must be an unshadowed `GLOBAL_OBJECT_ALIASES` identifier (a local `window` names an ordinary
        object, not the global) with a statically known property name, and the access must not cross a
        dynamic scope, where the alias could be rebound or the target could be a `with`-object property —
        in either case the model cannot claim the reference denotes a global.
        """
        base = member.object
        if not isinstance(base, JsIdentifier) or base.name not in GLOBAL_OBJECT_ALIASES:
            return None
        name = _member_property_name(member)
        if name is None:
            return None
        scope = self._node_scope.get(id(member))
        if self.lookup(base.name, scope) is not None or crosses_dynamic_scope(scope):
            return None
        return name

    def _ensure_implicit_global_from_alias_write(self, member: JsMemberExpression):
        """
        Give a global written through a member access on a global-object alias (`globalThis.g = ...`) an
        implicit-global binding when the name is otherwise undeclared, so the def-use pass resolves the
        reference to it. Only a write creates a global property, so a read establishes nothing; the write
        itself is recorded against the binding by `_build_def_use` like any other reference, so this
        establishes existence only.
        """
        if not is_member_write_target(member):
            return
        name = self.global_alias_member_name(member)
        if name is None:
            return
        self.root_scope.bindings.setdefault(
            name, Binding(name, BindingKind.IMPLICIT_GLOBAL, self.root_scope))

    def _global_alias_member_binding(self, member: JsMemberExpression) -> Binding | None:
        """
        The existing global binding a member access on a global-object alias references, or `None`.
        Unlike `_ensure_implicit_global_from_alias_write` this never creates a binding: a read of an
        otherwise-undeclared global has none to attribute and leaves the name free.
        """
        name = self.global_alias_member_name(member)
        if name is None:
            return None
        return self.root_scope.bindings.get(name)

    def _record_global_alias_member_reference(self, member: JsMemberExpression):
        """
        Record a reference performed through a member access on a global-object alias (`globalThis.g`,
        `globalThis.g = ...`, `globalThis.g += 1`) against the global's binding, exactly as an ordinary
        identifier reference is recorded: `reference_role` decides whether the access reads, writes, or
        both. The binding must already exist — `_ensure_implicit_global_from_alias_write` established one
        for an alias write, while a read of an undeclared global stays free. The member node stands in
        for the referencing identifier the global has none of (see `Binding`). Without the read half a
        `globalThis.g` read would leave the binding looking unreferenced, so a remover could drop a live
        global whose only use is through the alias.
        """
        binding = self._global_alias_member_binding(member)
        if binding is None:
            return
        role = reference_role(member)
        if role is not Role.WRITE:
            binding.reads.append(member)
        if role is not Role.READ:
            binding.writes.append(member)
        scope = self._node_scope.get(id(member))
        if scope is None or scope.var_scope is not binding.scope.var_scope:
            binding.captured = True

Methods

def scope_of(self, node)

The innermost scope that lexically contains node, or None if the node was not part of the script the model was built from.

Expand source code Browse git
def scope_of(self, node: Node) -> Scope | None:
    """
    The innermost scope that lexically contains *node*, or `None` if the node was not part of the
    script the model was built from.
    """
    return self._node_scope.get(id(node))
def function_scope(self, func)

The scope a function (or the script) introduces for its body: the script's root_scope, or the body block's scope for a function node, and None when func has no body block.

Expand source code Browse git
def function_scope(self, func: Node) -> Scope | None:
    """
    The scope a function (or the script) introduces for its body: the script's `root_scope`, or
    the body block's scope for a function node, and `None` when *func* has no body block.
    """
    if isinstance(func, JsScript):
        return self.root_scope
    body = getattr(func, 'body', None)
    if body is None:
        return None
    return self.scope_of(body)
def binding_of(self, decl_id)

The binding introduced by a binding-site identifier (a declarator id, parameter, function or class name, catch parameter, or import local), or None if the identifier is not a binding site.

Expand source code Browse git
def binding_of(self, decl_id: JsIdentifier) -> Binding | None:
    """
    The binding introduced by a binding-site identifier (a declarator id, parameter, function or
    class name, catch parameter, or import local), or `None` if the identifier is not a binding
    site.
    """
    return self._binding_of.get(id(decl_id))
def lookup(self, name, scope, *, cross_dynamic=False)

Resolve name from scope outward through enclosing scopes, stopping at a dynamically-scoped region where the name could be injected at runtime. Returns None for a free name. With cross_dynamic, the walk does not stop at a dynamic boundary but continues outward to the binding the name would denote if the with object lacked the property — the lexical binding a dynamic scope could still reach at runtime — which is how a with-body reference is attributed to the binding it may touch. The default keeps the definite-resolution semantics every other caller relies on.

Expand source code Browse git
def lookup(self, name: str, scope: Scope | None, *, cross_dynamic: bool = False) -> Binding | None:
    """
    Resolve *name* from *scope* outward through enclosing scopes, stopping at a dynamically-scoped
    region where the name could be injected at runtime. Returns `None` for a free name. With
    *cross_dynamic*, the walk does not stop at a dynamic boundary but continues outward to the binding
    the name would denote if the `with` object lacked the property — the lexical binding a dynamic
    scope could still reach at runtime — which is how a `with`-body reference is attributed to the
    binding it may touch. The default keeps the definite-resolution semantics every other caller
    relies on.
    """
    while scope is not None:
        binding = scope.bindings.get(name)
        if binding is not None:
            return binding
        if scope.is_dynamic and not cross_dynamic:
            return None
        scope = scope.parent
    return None
def is_reference(self, node)

Whether node is a referencing occurrence of a name: it occupies a use position and is not a binding site, so it reads or writes an existing binding rather than declaring one or naming a property, key, label, or import/export specifier. The binding-aware companion to the syntactic is_use_position(); resolve resolves exactly the identifiers for which this holds.

Expand source code Browse git
def is_reference(self, node: JsIdentifier) -> bool:
    """
    Whether *node* is a referencing occurrence of a name: it occupies a use position and is not a
    binding site, so it reads or writes an existing binding rather than declaring one or naming a
    property, key, label, or import/export specifier. The binding-aware companion to the syntactic
    `is_use_position`; `resolve` resolves exactly the identifiers for which this holds.
    """
    return is_use_position(node) and id(node) not in self._binding_of
def resolve(self, ref)

The binding a referencing identifier reads or writes, found by walking outward from its scope. Returns None when the name is free (an external global the program never assigns), when the identifier is not a reference (a property name, key, or label), or when resolution crosses a dynamically-scoped region where the name could be injected at runtime.

Expand source code Browse git
def resolve(self, ref: JsIdentifier) -> Binding | None:
    """
    The binding a referencing identifier reads or writes, found by walking outward from its scope.
    Returns `None` when the name is free (an external global the program never assigns), when the
    identifier is not a reference (a property name, key, or label), or when resolution crosses a
    dynamically-scoped region where the name could be injected at runtime.
    """
    if not self.is_reference(ref):
        return None
    return self.lookup(ref.name, self._node_scope.get(id(ref)))
def references(self, binding, *, exclude=None)

Every reference (read or write) bound to binding, optionally omitting those that lie within the subtree of exclude. Each is a referencing identifier except the member-expression write site of a global written through an alias (see Binding).

Expand source code Browse git
def references(
    self, binding: Binding, *, exclude: Node | None = None,
) -> list[JsIdentifier | JsMemberExpression]:
    """
    Every reference (read or write) bound to *binding*, optionally omitting those that lie within
    the subtree of *exclude*. Each is a referencing identifier except the member-expression write
    site of a global written through an alias (see `Binding`).
    """
    nodes = binding.reads + binding.writes
    if exclude is None:
        return nodes
    return [n for n in nodes if n is not exclude and not n.is_descendant_of(exclude)]
def dynamic_references(self, binding, *, exclude=None)

Every reference to binding that a dynamic scope resolves at runtime — an identifier inside a with body that could denote binding (it may instead denote a property of the with object, which is why the static references set omits it) — optionally omitting those within the subtree of exclude. Each is classified on demand by reference_role() or container_reference_role, the same oracles the definite references use, so a consumer applies one role logic to both; only the ordering and alias-following a resolved reference permits do not carry to an uncertain one.

Expand source code Browse git
def dynamic_references(
    self, binding: Binding, *, exclude: Node | None = None,
) -> list[JsIdentifier]:
    """
    Every reference to *binding* that a dynamic scope resolves at runtime — an identifier inside a
    `with` body that could denote *binding* (it may instead denote a property of the `with` object,
    which is why the static `references` set omits it) — optionally omitting those within the subtree
    of *exclude*. Each is classified on demand by `reference_role` or `container_reference_role`, the
    same oracles the definite references use, so a consumer applies one role logic to both; only the
    ordering and alias-following a resolved reference permits do not carry to an uncertain one.
    """
    nodes = binding.dynamic_refs
    if exclude is None:
        return list(nodes)
    return [n for n in nodes if n is not exclude and not n.is_descendant_of(exclude)]
def read_has_dynamic_effect(self, node)

Whether reading node as a value resolves through a dynamic scope — a bare identifier inside a with body — so that evaluating it is not a pure, droppable, or reorderable operand. Reading the bare name consults the with object first: a matching property fires the object's getter (or a proxy trap), an observable side effect; a missing one falls through to the lexical binding, or, failing that, throws a ReferenceError. Neither the getter nor the throw can be proved absent for an unknown object, so any reference that crosses a dynamic scope is effectful regardless of a lexical fallback. False for a statically resolved reference and any non-reference node.

Expand source code Browse git
def read_has_dynamic_effect(self, node: Node) -> bool:
    """
    Whether reading *node* as a value resolves through a dynamic scope — a bare identifier inside a
    `with` body — so that evaluating it is not a pure, droppable, or reorderable operand. Reading the
    bare name consults the `with` object first: a matching property fires the object's getter (or a
    proxy trap), an observable side effect; a missing one falls through to the lexical binding, or,
    failing that, throws a `ReferenceError`. Neither the getter nor the throw can be proved absent for
    an unknown object, so any reference that crosses a dynamic scope is effectful regardless of a
    lexical fallback. False for a statically resolved reference and any non-reference node.
    """
    if not isinstance(node, JsIdentifier) or not self.is_reference(node):
        return False
    return crosses_dynamic_scope(self._node_scope.get(id(node)))
def naming_binding(self, function)

The binding that gives function a name through which it can be invoked: the declared name of a named function declaration, or the single var/let/const declarator a function or arrow expression is the initializer of. None for an anonymous function whose invocation point cannot be pinned to a name — an IIFE, a callback, a function stored through any other expression.

Expand source code Browse git
def naming_binding(self, function: Node) -> Binding | None:
    """
    The binding that gives *function* a name through which it can be invoked: the declared name of a
    named function declaration, or the single `var`/`let`/`const` declarator a function or arrow
    expression is the initializer of. `None` for an anonymous function whose invocation point cannot
    be pinned to a name — an IIFE, a callback, a function stored through any other expression.
    """
    if isinstance(function, JsFunctionDeclaration) and function.id is not None:
        return self.binding_of(function.id)
    parent = function.parent
    if (
        isinstance(parent, JsVariableDeclarator)
        and parent.init is function
        and isinstance(parent.id, JsIdentifier)
    ):
        return self.binding_of(parent.id)
    return None
def invocation_binding(self, function)

The binding whose value-reads are the sites through which function is invoked — its naming_binding, extended to a lone assignment installing it in an already-declared name (f = function(){}) as well as a named declaration or a declarator initializer. None for a function with no such name — an anonymous IIFE or callback, or one stored through a member or other non-identifier target — whose invocation cannot be pinned to a name. Unlike naming_binding this also recognizes the bare-assignment form, so a function held in a hoisted var assigned once is ordered by its calls rather than by its creation; a caller confirms the binding is singly declared, binding_pinned_to function, and free of dynamic references before trusting its reads to enumerate every invocation.

Expand source code Browse git
def invocation_binding(self, function: Node) -> Binding | None:
    """
    The binding whose value-reads are the sites through which *function* is invoked — its
    `naming_binding`, extended to a lone assignment installing it in an already-declared name
    (`f = function(){}`) as well as a named declaration or a declarator initializer. `None` for a
    function with no such name — an anonymous IIFE or callback, or one stored through a member or
    other non-identifier target — whose invocation cannot be pinned to a name. Unlike `naming_binding`
    this also recognizes the bare-assignment form, so a function held in a hoisted `var` assigned once
    is ordered by its calls rather than by its creation; a caller confirms the binding is singly
    declared, `binding_pinned_to` *function*, and free of dynamic references before trusting its reads
    to enumerate every invocation.
    """
    binding = self.naming_binding(function)
    if binding is not None:
        return binding
    parent = function.parent
    if (
        isinstance(parent, JsAssignmentExpression)
        and parent.operator == '='
        and parent.right is function
    ):
        target = strip_parens(parent.left)
        if isinstance(target, JsIdentifier):
            return self.resolve(target)
    return None
def binding_pinned_to(self, binding, function)

Whether binding holds function as its one assigned value, so every read of it outside the value's temporal dead zone denotes function and its reads enumerate function's invocations. True when the binding's only write is the assignment that establishes function — a bare name = function(){} records that target as its sole write — and false once any other write could give the name a different value. A named function declaration or a declarator initializer installs the value with no recorded write, so any write at all is a reassignment that unpins it. The single-declaration and dynamic-reference checks a caller also needs are left to the caller; this answers only the reassignment question.

Expand source code Browse git
def binding_pinned_to(self, binding: Binding, function: Node) -> bool:
    """
    Whether *binding* holds *function* as its one assigned value, so every read of it outside the
    value's temporal dead zone denotes *function* and its reads enumerate *function*'s invocations.
    True when the binding's only write is the assignment that establishes *function* — a bare
    `name = function(){}` records that target as its sole write — and false once any other write could
    give the name a different value. A named function declaration or a declarator initializer installs
    the value with no recorded write, so any write at all is a reassignment that unpins it. The
    single-declaration and dynamic-reference checks a caller also needs are left to the caller; this
    answers only the reassignment question.
    """
    parent = function.parent
    establishing = None
    if (
        isinstance(parent, JsAssignmentExpression)
        and parent.operator == '='
        and parent.right is function
    ):
        establishing = strip_parens(parent.left)
    return all(write is establishing for write in binding.writes)
def object_property_reference_points(self, function)

The reference points that no invocation of function can precede when it is installed as a property of a non-escaping local object — the read sites of that property. Returns them when function is the value of a BASE.key = function assignment whose BASE identifier resolves to a local binding that holds one object value (singular_value is a JsObjectExpression) and never escapes as a bare value — every reference to it is the object of a member access, so the object identity is pinned to that binding and the only way to obtain the callable is to read BASE.key. Every such read is a point the invocation follows, including one whose value is stored and called later; the establishing write installs the value without reading it and is excluded, as is an access of a statically different property, which never reads the value. A computed access whose key is not statically known (BASE[expr]) may read the property and is kept. The opaque reflective surfaces that could name the binding are added as points exactly as the name-based enumeration adds them, and a with that could rename the base (a dynamic_refs entry) makes the ordering unknowable and yields None, as does any pattern the recognition does not match, so a caller falls through to its name-based ordering.

This is a bounded points-to fact: a method reached only through property reads on an object that never leaks is ordered by those reads, not by its creation site, which a member assignment target gives no name to order by. It answers, at the binding level, the ordering invocation_binding cannot when the callable is pinned to a member rather than a name.

Expand source code Browse git
def object_property_reference_points(self, function: Node) -> list[Node] | None:
    """
    The reference points that no invocation of *function* can precede when it is installed as a
    property of a non-escaping local object — the read sites of that property. Returns them when
    *function* is the value of a `BASE.key = function` assignment whose `BASE` identifier resolves to
    a local binding that holds one object value (`singular_value` is a `JsObjectExpression`) and never
    escapes as a bare value — every reference to it is the object of a member access, so the object
    identity is pinned to that binding and the only way to obtain the callable is to read `BASE.key`.
    Every such read is a point the invocation follows, including one whose value is stored and called
    later; the establishing write installs the value without reading it and is excluded, as is an
    access of a statically different property, which never reads the value. A computed access whose
    key is not statically known (`BASE[expr]`) may read the property and is kept. The opaque reflective
    surfaces that could name the binding are added as points exactly as the name-based enumeration adds
    them, and a `with` that could rename the base (a `dynamic_refs` entry) makes the ordering
    unknowable and yields `None`, as does any pattern the recognition does not match, so a caller falls
    through to its name-based ordering.

    This is a bounded points-to fact: a method reached only through property reads on an object that
    never leaks is ordered by those reads, not by its creation site, which a member assignment target
    gives no name to order by. It answers, at the binding level, the ordering `invocation_binding`
    cannot when the callable is pinned to a member rather than a name.
    """
    parent = function.parent
    if not (
        isinstance(parent, JsAssignmentExpression)
        and parent.operator == '='
        and parent.right is function
    ):
        return None
    target = strip_parens(parent.left)
    if not isinstance(target, JsMemberExpression) or not isinstance(target.object, JsIdentifier):
        return None
    key = _member_property_name(target)
    if key is None:
        return None
    binding = self.resolve(target.object)
    if binding is None or not isinstance(self.singular_value(binding), JsObjectExpression):
        return None
    if binding.dynamic_refs:
        return None
    points: list[Node] = []
    for read in binding.reads:
        node = read
        access = node.parent
        while isinstance(access, JsParenthesizedExpression):
            node, access = access, access.parent
        if not isinstance(access, JsMemberExpression) or access.object is not node:
            return None
        name = _member_property_name(access)
        if name is not None and name != key:
            continue
        if _is_member_assignment_target(access):
            continue
        points.append(access)
    points.extend(
        site
        for site in self.reflection_surface_sites(binding)
        if not site.is_descendant_of(function)
    )
    return points
def singular_value(self, binding)

The single value node a binding provably holds: the initializer of a sole var/let/const declarator, the function of a sole function declaration, or the right-hand side of the one assignment that establishes a name written exactly once (x = <value>, the form namespace flattening leaves). None when the binding is absent, redeclared, reassigned to more than one value, dynamically rebindable, or declared with no initializer and never assigned. The value is what the name denotes wherever it is not in the value's temporal dead zone; a consumer that also needs the value established before a use orders it separately, since a bare-assignment binding reads undefined before its write. EffectModel.function_of() is the function-typed specialization of this query, and it is the value-resolution the bare-assignment recognition sites route through instead of re-deriving binding shapes.

Expand source code Browse git
def singular_value(self, binding: Binding | None) -> Node | None:
    """
    The single value node a *binding* provably holds: the initializer of a sole `var`/`let`/`const`
    declarator, the function of a sole function declaration, or the right-hand side of the one
    assignment that establishes a name written exactly once (`x = <value>`, the form namespace
    flattening leaves). `None` when the binding is absent, redeclared, reassigned to more than one
    value, dynamically rebindable, or declared with no initializer and never assigned. The value is
    what the name denotes wherever it is not in the value's temporal dead zone; a consumer that also
    needs the value established before a use orders it separately, since a bare-assignment binding
    reads `undefined` before its write. `EffectModel.function_of` is the function-typed specialization
    of this query, and it is the value-resolution the bare-assignment recognition sites route through
    instead of re-deriving binding shapes.
    """
    if binding is None or len(binding.declarations) != 1:
        return None
    if self.binding_maybe_reassigned_dynamically(binding):
        return None
    decl = binding.declarations[0]
    parent = decl.parent
    if not binding.writes:
        if isinstance(parent, JsFunctionDeclaration) and parent.id is decl:
            return parent
        if isinstance(parent, JsClassDeclaration) and parent.id is decl:
            return parent
        if isinstance(parent, JsVariableDeclarator) and parent.id is decl:
            return parent.init
        return None
    if len(binding.writes) == 1:
        assignment = binding.writes[0].parent
        if (
            isinstance(assignment, JsAssignmentExpression)
            and assignment.operator == '='
            and strip_parens(assignment.left) is binding.writes[0]
        ):
            return strip_parens(assignment.right)
    return None
def establishment_sites(self, function)

The nodes that must all have executed before function's callable value is installed under the name it is invoked through, for a consumer that gates a use on execution order. The function-invocation view of binding_establishment_sites: None when function is not invoked through a single orderable name, so its presence cannot be ordered and the caller declines.

Expand source code Browse git
def establishment_sites(self, function: Node) -> list[Node] | None:
    """
    The nodes that must all have executed before *function*'s callable value is installed under the
    name it is invoked through, for a consumer that gates a use on execution order. The
    function-invocation view of `binding_establishment_sites`: `None` when *function* is not invoked
    through a single orderable name, so its presence cannot be ordered and the caller declines.
    """
    return self.binding_establishment_sites(self.invocation_binding(function))
def binding_establishment_sites(self, binding)

The nodes that must all have executed before binding's singular_value is installed, for a consumer that gates a use on execution order. An empty list when the value is hoisted into place before any statement runs — a function declaration — so no ordering is required; the declarator when the value is a var/let/const initializer, which is absent until that declarator runs; the class declaration when the value is a class, which is in its temporal dead zone until it runs; the recorded writes when a lone assignment installs it (f = function(){}, the form namespace flattening leaves). None when the binding holds no single such value, so its presence cannot be ordered and the caller declines. This mirrors singular_value's binding shapes exactly, one query returning the value and the other the nodes that establish it. Ordering the returned nodes against the use is the caller's job, since that needs the dominance model this layer must not depend on.

Expand source code Browse git
def binding_establishment_sites(self, binding: Binding | None) -> list[Node] | None:
    """
    The nodes that must all have executed before *binding*'s `singular_value` is installed, for a
    consumer that gates a use on execution order. An empty list when the value is hoisted into place
    before any statement runs — a function declaration — so no ordering is required; the declarator
    when the value is a `var`/`let`/`const` initializer, which is absent until that declarator runs;
    the class declaration when the value is a class, which is in its temporal dead zone until it runs;
    the recorded writes when a lone assignment installs it (`f = function(){}`, the form namespace
    flattening leaves). `None` when the binding holds no single such value, so its presence cannot be
    ordered and the caller declines. This mirrors `singular_value`'s binding shapes exactly, one query
    returning the value and the other the nodes that establish it. Ordering the returned nodes against
    the use is the caller's job, since that needs the dominance model this layer must not depend on.
    """
    if binding is None or len(binding.declarations) != 1:
        return None
    if binding.writes:
        return list(binding.writes)
    declaration = binding.declarations[0]
    parent = declaration.parent
    if isinstance(parent, JsFunctionDeclaration):
        return []
    if isinstance(parent, JsClassDeclaration):
        return [parent]
    if isinstance(parent, JsVariableDeclarator):
        return [parent]
    return None
def is_shadowed(self, name, at, outer)

Whether name, referenced at at, resolves to a binding declared strictly inside outer rather than in outer itself or an enclosing scope. This replaces the various hand-rolled shadowing checks: a name shadowed below outer does not refer to outer's binding.

Expand source code Browse git
def is_shadowed(self, name: str, at: Node, outer: Scope) -> bool:
    """
    Whether *name*, referenced at *at*, resolves to a binding declared strictly inside *outer*
    rather than in *outer* itself or an enclosing scope. This replaces the various hand-rolled
    shadowing checks: a name shadowed below *outer* does not refer to *outer*'s binding.
    """
    binding = self.lookup(name, self._node_scope.get(id(at)))
    if binding is None:
        return False
    return outer.contains(binding.scope, strict=True)
def would_capture(self, names, scope)

Whether introducing a binding for any of names directly in scope would capture an identifier already meaningful there. Every use-position occurrence of one of names within scope, including in a nested function that would close over the new binding, must already resolve to a binding strictly nested below scope (see is_shadowed); otherwise that occurrence — free, inherited from an enclosing scope, or bound in scope itself — would be rebound by the introduced declaration.

Expand source code Browse git
def would_capture(self, names: set[str], scope: Scope) -> bool:
    """
    Whether introducing a binding for any of *names* directly in *scope* would capture an
    identifier already meaningful there. Every use-position occurrence of one of *names* within
    *scope*, including in a nested function that would close over the new binding, must already
    resolve to a binding strictly nested below *scope* (see `is_shadowed`); otherwise that
    occurrence — free, inherited from an enclosing scope, or bound in *scope* itself — would be
    rebound by the introduced declaration.
    """
    for node in name_uses_in_scope(names, scope):
        if not self.is_shadowed(node.name, node, scope):
            return True
    return False
def has_reflection_surface(self)

Whether the program still contains a construct through which code could reference a global by name at runtime: a value-read of the eval or Function intrinsic in any form — a direct or indirect call, an alias (var e = eval), a comma sequence ((0, eval)), or a member access (window.eval, g['Function']) — a string-valued timer, a dynamic property access on the global object (window[expr]), or a with statement. Computed conservatively (over-reporting is safe): while any such surface remains, a dead global must not be removed, because reflective code may read it.

Expand source code Browse git
def has_reflection_surface(self) -> bool:
    """
    Whether the program still contains a construct through which code could reference a global by
    name at runtime: a value-read of the `eval` or `Function` intrinsic in any form — a direct or
    indirect call, an alias (`var e = eval`), a comma sequence (`(0, eval)`), or a member access
    (`window.eval`, `g['Function']`) — a string-valued timer, a dynamic property access on the
    global object (`window[expr]`), or a `with` statement. Computed conservatively (over-reporting
    is safe): while any such surface remains, a dead global must not be removed, because reflective
    code may read it.
    """
    self._ensure_reflection_detected()
    assert self._reflection_surface is not None
    return self._reflection_surface
def reflection_can_reach(self, binding)

Whether a runtime name lookup could read or write binding without a reference this model records. Derived over the precise dynamic-scope facts. A global is reachable through any reflective surface — eval, Function, a string timer, dynamic global access, with — all of which run in the global scope, so it defers to the whole-program has_reflection_surface. A function-local is reachable only from within its own function and only by name: a with body that names it (a dynamic_references entry) or a direct eval in the function (local_reachable_by_direct_eval). A with that never names it cannot reach it, and reflective code in the global scope cannot name a local — so the local answer is exact, while the global one stays conservative (any surface).

Expand source code Browse git
def reflection_can_reach(self, binding: Binding) -> bool:
    """
    Whether a runtime name lookup could read or write *binding* without a reference this model
    records. Derived over the precise dynamic-scope facts. A global is reachable through any
    reflective surface — `eval`, `Function`, a string timer, dynamic global access, `with` — all of
    which run in the global scope, so it defers to the whole-program `has_reflection_surface`. A
    function-local is reachable only from within its own function and only by name: a `with` body that
    names it (a `dynamic_references` entry) or a direct `eval` in the function
    (`local_reachable_by_direct_eval`). A `with` that never names it cannot reach it, and reflective
    code in the global scope cannot name a local — so the local answer is exact, while the global one
    stays conservative (any surface).
    """
    owner = binding.scope.var_scope
    if owner is None or owner.kind is ScopeKind.SCRIPT:
        return self.has_reflection_surface()
    return bool(binding.dynamic_refs) or self._function_has_direct_eval(owner.node)
def reachable_by_opaque_reflection(self, binding)

Whether an opaque reflective surface — a value-read of eval or Function, a string timer, or a dynamic access on the global object — could name binding at runtime with no reference this model records. Unlike reflection_can_reach, a with body is not counted: a with that names the binding is attributed precisely as a dynamic_references entry, so a caller that already consults dynamic_refs needs only the opaque surfaces here, the ones that leave no attributable reference. A global is reachable through any such surface, all of which run in the global scope; a function-local only through a direct eval in its own function, since a surface running in the global scope cannot name a local. The boolean companion of reflection_surface_sites — true exactly when that site list is non-empty.

Expand source code Browse git
def reachable_by_opaque_reflection(self, binding: Binding) -> bool:
    """
    Whether an opaque reflective surface — a value-read of `eval` or `Function`, a string timer, or a
    dynamic access on the global object — could name *binding* at runtime with no reference this model
    records. Unlike `reflection_can_reach`, a `with` body is not counted: a `with` that names the
    binding is attributed precisely as a `dynamic_references` entry, so a caller that already consults
    `dynamic_refs` needs only the opaque surfaces here, the ones that leave no attributable reference.
    A global is reachable through any such surface, all of which run in the global scope; a
    function-local only through a direct `eval` in its own function, since a surface running in the
    global scope cannot name a local. The boolean companion of `reflection_surface_sites` — true
    exactly when that site list is non-empty.
    """
    return bool(self.reflection_surface_sites(binding))
def reflection_surface_sites(self, binding)

The AST nodes of the opaque reflective surfaces that could name binding at runtime with no reference this model records — the points no reflected invocation of it can precede. A caller ranks a definition against these to prove it runs before every such invocation, the site-level companion of reachable_by_opaque_reflection. For a global (script-scope) binding they are the whole-program opaque surfaces (_opaque_reflection_sites), each running in the global scope and able to name any global; for a function-local, the direct eval sites in its owning function (_direct_eval_sites), the only opaque surface that runs in the local's own scope and can name it. Empty exactly when the binding is not opaque-reflection reachable. A with surface is not included — a with that names the binding is attributed as a dynamic_references entry a caller consults separately.

Expand source code Browse git
def reflection_surface_sites(self, binding: Binding) -> list[Node]:
    """
    The AST nodes of the opaque reflective surfaces that could name *binding* at runtime with no
    reference this model records — the points no reflected invocation of it can precede. A caller
    ranks a definition against these to prove it runs before every such invocation, the site-level
    companion of `reachable_by_opaque_reflection`. For a global (script-scope) binding they are the
    whole-program opaque surfaces (`_opaque_reflection_sites`), each running in the global scope and
    able to name any global; for a function-local, the direct `eval` sites in its owning function
    (`_direct_eval_sites`), the only opaque surface that runs in the local's own scope and can name
    it. Empty exactly when the binding is not opaque-reflection reachable. A `with` surface is not
    included — a `with` that names the binding is attributed as a `dynamic_references` entry a caller
    consults separately.
    """
    owner = binding.scope.var_scope
    if owner is None or owner.kind is ScopeKind.SCRIPT:
        return self._opaque_reflection_sites()
    return self._direct_eval_sites(owner.node)
def local_reachable_by_direct_eval(self, binding)

Whether a direct eval positioned to name binding could read or write it with no reference this model records. True only for a function-local whose owning function — or a closure nested inside it, which inherits its scope — contains a direct eval, the one reflective surface that runs in the caller's own scope and can therefore name a local. False for a global: an opaque global-scope surface can name any global, but that is what the whole-program reflection_can_reach answers, and freezing every global on it is an over-approximation the caller must choose to accept, not a fact this query asserts. The with surface is not counted — a with body's accesses are attributed precisely as dynamic_references, so only the opaque eval case needs this per-function answer.

Expand source code Browse git
def local_reachable_by_direct_eval(self, binding: Binding) -> bool:
    """
    Whether a direct `eval` positioned to name *binding* could read or write it with no reference this
    model records. True only for a function-local whose owning function — or a closure nested inside
    it, which inherits its scope — contains a direct `eval`, the one reflective surface that runs in
    the caller's own scope and can therefore name a local. False for a global: an opaque global-scope
    surface can name any global, but that is what the whole-program `reflection_can_reach` answers, and
    freezing every global on it is an over-approximation the caller must choose to accept, not a fact
    this query asserts. The `with` surface is not counted — a `with` body's accesses are attributed
    precisely as `dynamic_references`, so only the opaque `eval` case needs this per-function answer.
    """
    owner = binding.scope.var_scope
    if owner is None or owner.kind is ScopeKind.SCRIPT:
        return False
    return self._function_has_direct_eval(owner.node)
def binding_maybe_reassigned_dynamically(self, binding)

Whether a dynamic scope could rebind binding — give the name a new value through a surface the static writes set does not record. A with body that names it as an assignment target may rebind it (the target may instead be a property of the with object, but may equally be this binding, so it is treated as a possible rebind), and a direct eval in its owning function can rebind it opaquely. A member write or method call through the name does not rebind it — the name keeps its value — so only a dynamic reference whose role is not a plain read counts. A consumer that judges a binding's value stable from writes alone must also consult this, since neither reassignment leaves a writes entry; a script-scope binding reassigned only through an opaque eval stays the documented residual, as local_reachable_by_direct_eval reports it false there.

Expand source code Browse git
def binding_maybe_reassigned_dynamically(self, binding: Binding) -> bool:
    """
    Whether a dynamic scope could rebind *binding* — give the name a new value through a surface
    the static `writes` set does not record. A `with` body that names it as an assignment target
    may rebind it (the target may instead be a property of the `with` object, but may equally be
    this binding, so it is treated as a possible rebind), and a direct `eval` in its owning
    function can rebind it opaquely. A member write or method call through the name does not
    rebind it — the name keeps its value — so only a dynamic reference whose role is not a plain
    read counts. A consumer that judges a binding's value stable from `writes` alone must also
    consult this, since neither reassignment leaves a `writes` entry; a script-scope binding
    reassigned only through an opaque `eval` stays the documented residual, as
    `local_reachable_by_direct_eval` reports it false there.
    """
    if self.local_reachable_by_direct_eval(binding):
        return True
    return any(
        reference_role(ref) is not Role.READ
        for ref in self.dynamic_references(binding)
    )
def binding_never_reassigned(self, binding)

Whether binding holds one value for its whole lifetime: it is never written after its declaration, statically (writes) or through a dynamic scope (binding_maybe_reassigned_dynamically). This is the value-stability contract a caller needs before treating the binding's initializer as its value everywhere — distinct from the orderability contract dynamic_refs expresses (whether every reference can be ranked), which a with-body read violates while a stable value does not. It does not itself require a single declaration; a caller that needs one checks declarations alongside.

Expand source code Browse git
def binding_never_reassigned(self, binding: Binding) -> bool:
    """
    Whether *binding* holds one value for its whole lifetime: it is never written after its
    declaration, statically (`writes`) or through a dynamic scope
    (`binding_maybe_reassigned_dynamically`). This is the value-stability contract a caller needs
    before treating the binding's initializer as its value everywhere — distinct from the
    orderability contract `dynamic_refs` expresses (whether every reference can be ranked), which a
    `with`-body read violates while a stable value does not. It does not itself require a single
    declaration; a caller that needs one checks `declarations` alongside.
    """
    return not binding.writes and not self.binding_maybe_reassigned_dynamically(binding)
def reaches_global_object(self, binding, *, module_scope)

Whether binding is a property of the global object at runtime — the global a free name in global-scope reflected code (a Function body, an indirect eval, a string timer) resolves to. An implicit global always is. A top-level var/function declaration is, but only under the script execution model; under the module model (module_scope) it is scoped to the module and never reaches the global. A top-level let/const/class, or any binding nested below the script, is a distinct lexical binding that global-scope code cannot see.

Expand source code Browse git
def reaches_global_object(self, binding: Binding, *, module_scope: bool) -> bool:
    """
    Whether *binding* is a property of the global object at runtime — the global a free name in
    global-scope reflected code (a `Function` body, an indirect `eval`, a string timer) resolves to.
    An implicit global always is. A top-level `var`/function declaration is, but only under the
    script execution model; under the module model (*module_scope*) it is scoped to the module and
    never reaches the global. A top-level `let`/`const`/`class`, or any binding nested below the
    script, is a distinct lexical binding that global-scope code cannot see.
    """
    if binding.kind is BindingKind.IMPLICIT_GLOBAL:
        return True
    if module_scope:
        return False
    return (
        binding.scope is self.root_scope
        and binding.is_hoisted
    )
def global_alias_member_name(self, member)

The name of the global that a member access on a global-object alias references (globalThis.g, window['g']g), or None when member is not such an access. The alias must be an unshadowed GLOBAL_OBJECT_ALIASES identifier (a local window names an ordinary object, not the global) with a statically known property name, and the access must not cross a dynamic scope, where the alias could be rebound or the target could be a with-object property — in either case the model cannot claim the reference denotes a global.

Expand source code Browse git
def global_alias_member_name(self, member: JsMemberExpression) -> str | None:
    """
    The name of the global that a member access on a global-object alias references
    (`globalThis.g`, `window['g']` → `g`), or `None` when *member* is not such an access. The alias
    must be an unshadowed `GLOBAL_OBJECT_ALIASES` identifier (a local `window` names an ordinary
    object, not the global) with a statically known property name, and the access must not cross a
    dynamic scope, where the alias could be rebound or the target could be a `with`-object property —
    in either case the model cannot claim the reference denotes a global.
    """
    base = member.object
    if not isinstance(base, JsIdentifier) or base.name not in GLOBAL_OBJECT_ALIASES:
        return None
    name = _member_property_name(member)
    if name is None:
        return None
    scope = self._node_scope.get(id(member))
    if self.lookup(base.name, scope) is not None or crosses_dynamic_scope(scope):
        return None
    return name