Module 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 deobfuscation transforms instead of each transform re-deriving scope, binding, and liveness facts on its own.
This is the foundation layer of the analysis substrate. Its public surface is intentionally
representation-agnostic: callers receive Scope and Binding objects and ask questions about AST
nodes by identity, never about how the facts were computed. Later layers (control-flow graphs, effect
summaries) attach behind the same surface without changing it.
The model is flow-insensitive. It answers lexical questions — which declaration a name resolves to, what a scope binds, where a binding is read or written, whether it is captured by a closure — but not control-flow questions such as which definition reaches a use. A read that only ever consumes a value that is never observed (a dead store) is still counted as a read; distinguishing those needs a control-flow graph and is left to a later layer.
Where JavaScript scoping is genuinely ambiguous the model is deliberately conservative, resolving a
name to a wider binding rather than risk treating a live reference as free: a function declaration
nested in a block is hoisted to the enclosing function scope (legacy/Annex-B semantics), and a name
used inside a with body or any dynamically-scoped region resolves to None (unknown) rather than to
a guessed binding. has_reflection_surface likewise errs toward reporting reflection.
A name the program assigns without ever declaring it (an implicit global) is given a synthetic binding
at script scope so that its whole-program liveness can be reasoned about; a name that is only ever
read without being assigned stays free (None), since it denotes an external or built-in global the
model cannot describe. Writes inside a with body do not create such a binding, because the name may
denote a property of the with object rather than a global.
Expand source code Browse git
"""
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 deobfuscation transforms instead of each transform
re-deriving scope, binding, and liveness facts on its own.
This is the foundation layer of the analysis substrate. Its public surface is intentionally
representation-agnostic: callers receive `Scope` and `Binding` objects and ask questions about AST
nodes by identity, never about how the facts were computed. Later layers (control-flow graphs, effect
summaries) attach behind the same surface without changing it.
The model is *flow-insensitive*. It answers lexical questions — which declaration a name resolves to,
what a scope binds, where a binding is read or written, whether it is captured by a closure — but not
control-flow questions such as which definition reaches a use. A read that only ever consumes a value
that is never observed (a dead store) is still counted as a read; distinguishing those needs a
control-flow graph and is left to a later layer.
Where JavaScript scoping is genuinely ambiguous the model is deliberately conservative, resolving a
name to a *wider* binding rather than risk treating a live reference as free: a function declaration
nested in a block is hoisted to the enclosing function scope (legacy/Annex-B semantics), and a name
used inside a `with` body or any dynamically-scoped region resolves to `None` (unknown) rather than to
a guessed binding. `has_reflection_surface` likewise errs toward reporting reflection.
A name the program assigns without ever declaring it (an implicit global) is given a synthetic binding
at script scope so that its whole-program liveness can be reasoned about; a name that is only ever
*read* without being assigned stays free (`None`), since it denotes an external or built-in global the
model cannot describe. Writes inside a `with` body do not create such a binding, because the name may
denote a property of the `with` object rather than a global.
"""
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from typing import Callable, Iterator
from refinery.lib.scripts import Node, Statement
from refinery.lib.scripts.js.model import (
FUNCTION_NODES,
JsArrayExpression,
JsArrayPattern,
JsArrowFunctionExpression,
JsAssignmentExpression,
JsAssignmentPattern,
JsBlockStatement,
JsBreakStatement,
JsCallExpression,
JsCatchClause,
JsClassDeclaration,
JsClassExpression,
JsConditionalExpression,
JsContinueStatement,
JsDoWhileStatement,
JsExportAllDeclaration,
JsExportDefaultDeclaration,
JsExportNamedDeclaration,
JsExportSpecifier,
JsExpressionStatement,
JsForInStatement,
JsForOfStatement,
JsForStatement,
JsFunctionDeclaration,
JsFunctionExpression,
JsFunctionNode,
JsIdentifier,
JsIfStatement,
JsImportDeclaration,
JsImportDefaultSpecifier,
JsImportExpression,
JsImportNamespaceSpecifier,
JsImportSpecifier,
JsLabeledStatement,
JsLogicalExpression,
JsMemberExpression,
JsMethodDefinition,
JsNewExpression,
JsNumericLiteral,
JsObjectExpression,
JsObjectPattern,
JsParenthesizedExpression,
JsProperty,
JsPropertyDefinition,
JsRestElement,
JsReturnStatement,
JsScript,
JsSpreadElement,
JsStaticBlock,
JsStringLiteral,
JsSwitchCase,
JsSwitchStatement,
JsTaggedTemplateExpression,
JsThisExpression,
JsUnaryExpression,
JsUpdateExpression,
JsVariableDeclaration,
JsVariableDeclarator,
JsVarKind,
JsWhileStatement,
JsWithStatement,
names_a_property,
strip_parens,
)
from refinery.lib.scripts.js.numbers import canonical_array_index, exact_integer
from refinery.lib.scripts.js.strict import (
has_parameter_expressions,
has_simple_parameters,
strict_mode_at,
)
# A class static block hoists its own `var`/function declarations, so it bounds the hoist walk like a
# function body. It is deliberately absent from FUNCTION_NODES so the effect model stays transparent to
# it: its statements run once, at class-definition time, as part of the enclosing function.
HOIST_BOUNDARY = FUNCTION_NODES + (JsStaticBlock,)
GLOBAL_OBJECT_ALIASES = frozenset({'globalThis', 'global', 'window', 'self', 'top', 'frames'})
TIMER_NAMES = frozenset({'setTimeout', 'setInterval', 'setImmediate'})
SYNC_EVAL_NAMES = frozenset({'execScript'})
STRING_EVAL_NAMES = TIMER_NAMES | SYNC_EVAL_NAMES
REFLECTIVE_INTRINSICS = frozenset({'eval', 'Function'})
GUARANTEED_GLOBALS = frozenset({
'globalThis',
'NaN',
'Infinity',
'undefined',
'eval',
'isFinite',
'isNaN',
'parseFloat',
'parseInt',
'decodeURI',
'decodeURIComponent',
'encodeURI',
'encodeURIComponent',
'Object',
'Function',
'Boolean',
'Symbol',
'BigInt',
'Error',
'AggregateError',
'EvalError',
'RangeError',
'ReferenceError',
'SyntaxError',
'TypeError',
'URIError',
'Number',
'Math',
'Date',
'String',
'RegExp',
'Array',
'Int8Array',
'Uint8Array',
'Uint8ClampedArray',
'Int16Array',
'Uint16Array',
'Int32Array',
'Uint32Array',
'Float32Array',
'Float64Array',
'BigInt64Array',
'BigUint64Array',
'Map',
'Set',
'WeakMap',
'WeakSet',
'WeakRef',
'FinalizationRegistry',
'ArrayBuffer',
'DataView',
'JSON',
'Promise',
'Reflect',
'Proxy',
})
"""
Names the ECMAScript specification mandates as properties of the global object and that every mainstream
engine exposes unconditionally, so a bare read of one is guaranteed to resolve rather than throw a
`ReferenceError`. This is an *existence* allowlist — distinct from the getter-purity and host-presence
sets in `refinery.lib.scripts.js.analysis.effects` — used to decide whether `<global-alias>.name` may be
collapsed to the bare `name` without turning the member read's `undefined` into a throw. It excludes host
and alias names (`window`, `self`, `global`, `top`, `frames`, `console`, timers, `Buffer`, …) that are not
universal, and `SharedArrayBuffer`/`Atomics`, which a conformant host may withhold outside a
cross-origin-isolated context.
"""
_PATTERN_CONTAINERS = (
JsArrayExpression,
JsArrayPattern,
JsObjectExpression,
JsObjectPattern,
JsRestElement,
JsSpreadElement,
)
class ScopeKind(enum.Enum):
SCRIPT = 'script' # noqa
FUNCTION = 'function' # noqa
NAME = 'name' # noqa the own name of a named function expression
PARAMS = 'params' # noqa a parameter list holding an expression
BLOCK = 'block' # noqa
CATCH = 'catch' # noqa
CLASS = 'class' # noqa
WITH = 'with' # noqa
STATIC_BLOCK = 'static-block' # noqa
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
class Role(enum.Enum):
READ = 'read' # noqa
WRITE = 'write' # noqa
READWRITE = 'readwrite' # noqa
class ContainerRole(enum.Enum):
"""
How a reference touches the container value (object or array) its binding holds — a finer
distinction than `Role`, which describes how a reference touches the *binding* itself. `obj.k = v`
reads the binding `obj` (so `reference_role` reports `READ`) yet writes the container it holds, so
here it is a `MEMBER_WRITE`.
"""
MEMBER_READ = 'member_read' # noqa read through the container: `obj.k`, `obj[i]`
MEMBER_WRITE = 'member_write' # noqa write through it: `obj.k = v`, `obj[i]++`, `delete obj[i]`
MEMBER_CALL = 'member_call' # noqa method invoked on it: `obj.m(...)`, which may mutate it
REBIND = 'rebind' # noqa plain reassignment of the name: `obj = ...`
ESCAPE = 'escape' # noqa any other use, through which the container could be aliased
#: What a binding's reference lists hold. A reference is ordinarily the identifier naming the
#: binding; where an object aliases it there is no such identifier, and the node the program reached
#: the object through stands in — a member access for `globalThis.g` and for `arguments[0]`, and the
#: `this` of a script's top level where the object itself is handed to a call.
ReferenceNode = JsIdentifier | JsMemberExpression | JsThisExpression
@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 an object that
aliases the binding has no referencing identifier for the name it targets, so the
`JsMemberExpression` stands in for that reference; every other `reads`/`writes` entry is an
identifier. Two objects alias this way — a global-object alias (`globalThis.g`) reaching a global,
and a mapped `arguments` reaching a parameter — and they are told apart by what the access is on,
never by the entry being a member access at all. `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[ReferenceNode] = field(default_factory=list)
writes: list[ReferenceNode] = field(default_factory=list)
dynamic_refs: list[JsIdentifier] = field(default_factory=list)
indefinite_writes: list[ReferenceNode] = field(default_factory=list)
captured: bool = False
#: Whether the call writes this binding before any statement of its scope runs. A `var` of a
#: parameter's name is the one shape that does: the body's name starts out holding the argument,
#: and only a declarator that runs later says anything about what it holds after that. There is
#: no node for that write - the call makes it, not anything in the text - so it can be neither a
#: `writes` nor an `indefinite_writes` entry, both of which every consumer orders by position.
written_at_entry: bool = False
#: Whether the program hands a call the object that carries this binding, so a body no reading of
#: the text follows can name it. The references such a call may make are recorded like any other,
#: and this says the one thing they cannot: that a walk which finds a name only where the text
#: spells it is looking at less than the whole program. Only a global is ever carried this way.
reachable_through_a_handed_object: bool = False
#: Whether the binding is exported, so an importer observes its value across the module boundary
#: after the module runs. Like the two flags above, it names an observer no reading of the text
#: reaches: its declaration must be kept and never relocated out of module scope, and a write to
#: it is never a dead store, because the final value is read from outside. `export var a`,
#: `export function`/`class`, and the local half of a sourceless `export { a }` all set it; a
#: `from`-clause list and a re-export name a binding of another module and set nothing here.
exported: bool = False
def note_reference_from(self, scope: Scope | None) -> None:
"""
Mark this binding captured where *scope* is on the far side of a closure boundary from the
scope declaring it, which is what a reference made from a scope with a different variable
scope is. A reference whose own scope is not known is counted as a capture, since nothing
about it says that it is not one.
Three walks record a reference and each of them asks this: the identifier walk, the one
reading a binding through an alias of the global object, and the one reading a parameter
through a mapped `arguments`. They have to agree, and one of them being written differently
from the others is not a difference anything downstream could act on.
"""
if scope is None or scope.closure_home is not self.scope.closure_home:
self.captured = True
@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, named
inside no dynamic scope, and not exported. 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 — nor is an
exported one, whose value an importer reads across the module boundary, so removers need not
rely on a separate reflection gate to keep such a binding.
"""
return not self.reads and not self.dynamic_refs and not self.exported
@property
def has_indefinite_write(self) -> bool:
"""
Whether some access writes the binding at a point where what it stores, or whether it stores
at all, is decided only at run time, so that its value stops holding there and no definition
says what replaced it. Every write through a mapped `arguments` object is one. `arguments[k]
= v` for a `k` no reading of the text computes writes exactly one parameter of the function
and which one is not decidable, so it is a kill of each with a value for none; `arguments[0]
= v` names its parameter but still lands only where the call supplied that argument
(§10.2.11 maps an element onto a parameter only for a position `index < len`), so it is a
kill of that one with a value for none. The object handed to a call or bound to a second
name is another, and the entry is then the identifier the object escaped through rather than
an access on it.
It is kept apart from `writes` for the same reason `dynamic_refs` is kept apart: a `writes`
entry is a definition, and a consumer reading one expects to find the value it stored. Recording
this as a definition of every parameter would let a fold answer with a value only one of them
can hold; recording it nowhere lets a fold carry a value across it that the write destroyed.
The write a call makes on entry is one of these too, and it is the one with no node at all,
so it is carried by `written_at_entry` and read here beside the rest.
"""
return bool(self.indefinite_writes) or self.written_at_entry
@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.
The access is tested by what it is on and not by its being a member access, because a parameter
of a sloppy function carries member-access writes too — through the `arguments` object that
aliases it — and those reach one function's own parameter rather than the global object.
"""
return any(_is_global_alias_access(write) 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.
As with `has_global_member_write`, an access through a mapped `arguments` object is not one of
these: it reaches a parameter, which no other function can name.
"""
return any(_is_global_alias_access(ref) for ref in (*self.reads, *self.writes))
@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 `with` body, whose bindings cannot be
resolved statically because the object supplies them at run time.
A direct `eval` is not marked here even though it too can inject a name. It would have to mark the
whole enclosing function, which would make every name in a function containing one unresolvable,
where what an `eval` actually does is narrower and is answered by the two queries written for it:
`local_reachable_by_direct_eval` for a binding that already exists, and
`free_name_reachable_by_direct_eval` for one the `eval` may have declared.
"""
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
#: For one of the two scopes a function introduces around its body - the one holding its own
#: name and the one holding its parameters - the scope holding that body. It is what says the
#: three are one call rather than three, which `closure_home` reads and nothing else does.
function_body: Scope | None = None
@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
or self.kind is ScopeKind.PARAMS
)
@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
@property
def closure_home(self) -> Scope | None:
"""
The scope that decides whether a reference made from this one crosses a closure boundary: a
name read from a scope with a different one is read by a function other than the one that
declares it, and is a capture.
This is the variable scope for every scope but the two a function introduces around its
body. A parameter default and the body it belongs to are run by one call and share every
binding either of them makes, and so does the name a function expression answers to inside
itself, so no closure boundary runs between the three: all of them answer the body's scope.
"""
if self.function_body is not None:
return self.function_body
home = self.var_scope
if home is not None and home.function_body is not None:
return home.function_body
return home
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
def crosses_dynamic_scope(scope: Scope | None) -> bool:
"""
Whether resolving a name from *scope* outward passes through a dynamically-scoped region.
"""
while scope is not None:
if scope.is_dynamic:
return True
scope = scope.parent
return False
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, a key, a label, or something across a module boundary. `names_a_property` answers
for the four positions that name a property; what is added here is the positions that name
something else the program cannot refer to — the name a module is re-exported under, a label,
either side of an import specifier, and an export specifier that names nothing local. The local
half of an export list without a `from` clause reads the binding it names, which is why an
engine refuses to link `export { a };` where nothing declares `a`; with the clause the same half
names a binding of the module the clause spells and nothing local at all. Where nothing renames,
one node fills both halves of the specifier, and that node is the local half and reads. 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 names_a_property(node):
return False
if isinstance(p, JsExportAllDeclaration) and p.exported is node:
return False
if isinstance(p, (JsBreakStatement, JsContinueStatement, JsLabeledStatement)) and p.label is node:
return False
if isinstance(p, (
JsImportSpecifier,
JsImportDefaultSpecifier,
JsImportNamespaceSpecifier,
)):
return False
if isinstance(p, JsExportSpecifier):
declaration = p.parent
return (
p.local is node
and isinstance(declaration, JsExportNamedDeclaration)
and declaration.source is None
)
return True
def name_uses_in_scope(names: set[str], scope: Scope) -> Iterator[JsIdentifier]:
"""
Every use-position identifier within *scope* (descending into nested functions) whose name is one
of *names* — the shared walk behind the capture check and the reflection dominance gate, which both
enumerate the live occurrences of a set of names across a region.
"""
for node in scope.node.walk():
if isinstance(node, JsIdentifier) and node.name in names and is_use_position(node):
yield node
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: ReferenceNode) -> 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
the node an object aliasing the binding was reached through — a member access on a global-object
alias (`globalThis.g`, `globalThis.g = ...`), and the global object itself where a call is handed
it — so the def-use pass records each 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
def enclosing_operator(node: Node) -> Node | None:
"""
The nearest ancestor of *node* that is not merely a parenthesization of it — the construct whose
operator actually governs *node*.
"""
parent = node.parent
while isinstance(parent, JsParenthesizedExpression):
parent = parent.parent
return parent
def _governing_target(node: Node) -> tuple[Node | None, Node]:
"""
Climb outward from *node* through the destructuring containers and parentheses that keep it in
an assignment or binding target position — array and object patterns (and the literal-shaped
forms a destructuring assignment or `for-in`/`for-of` target is parsed as), their rest and
spread elements, the value side of a pattern property, and the target side of a default pattern
(`[a = d] = ...`, climbing the `a` side only, never into the default `d`) — then return the
first ancestor that does not continue the target, together with the operand it sees: the
outermost container the climb carried *node* up to. An object shorthand-default
(`({a = d} = ...)`) is one such default: the parser reuses its key node as that default's
target, so the climb follows the shared key as the write it also is instead of stopping at it as
a bare property key. That ancestor is the construct whose operator governs the target; when
*node* really sits in a target it is an assignment, update, `delete`, `for-in`/`for-of` head, or
declarator, but it is some other node (a call, an operand) when *node* is not a target, and
`None` past the top of the tree — so a caller decides a write by asking whether the returned
operand is the governor's write side, never from the governor's type alone. Centralizing the
climb keeps the pattern-and-parenthesis handling identical for every def-use, write-target, and
liveness query, so a case one copy forgot — such as the array-default `JsAssignmentPattern`
target or a `for-of` rest element — cannot be missed by one and not another.
"""
cursor: Node = node
parent = enclosing_operator(cursor)
while parent is not None:
if isinstance(parent, JsProperty):
value = strip_parens(parent.value)
if value is not cursor and not (
parent.shorthand
and isinstance(value, JsAssignmentPattern)
and strip_parens(value.left) is cursor
):
break
elif isinstance(parent, JsAssignmentPattern):
if strip_parens(parent.left) is not cursor:
break
elif not isinstance(parent, _PATTERN_CONTAINERS):
break
cursor = parent
parent = enclosing_operator(cursor)
return parent, cursor
_UNRESOLVABLE_TOLERANT_OPERATORS = frozenset({'typeof', 'delete'})
"""
The two unary operators the language lets stand in front of a name that resolves to nothing:
`typeof` answers `'undefined'` (§13.5.3) and `delete` answers `true` (§13.5.1.2), where every other
read of an unresolvable reference throws a `ReferenceError`. This is about the operand and nothing
further: `typeof name === 'undefined'` is spared, but a read the guard stands in front of is a
separate position and is still answered as a throw, since nothing here orders the guard before it.
So the feature-detection idiom parses its own guard for free and its guarded body does not.
"""
def _is_unary_operand(governor: Node | None, node: Node, operators: frozenset[str]) -> bool:
"""
Whether *governor* is a unary expression whose operator is one of *operators* and whose
operand, looked through parentheses, is *node*. The single test behind every question of the
form `does this operator stand in front of this reference`, so a parenthesization or
operand-shape case fixed once is fixed for all of them; a caller that has not already resolved
the governing construct obtains it from `enclosing_operator`.
"""
return (
isinstance(governor, JsUnaryExpression)
and governor.operator in operators
and strip_parens(governor.operand) is node
)
def tolerates_unresolvable(node: Node) -> bool:
"""
Whether the operator governing *node* reads it without demanding that the name resolve, so a
free name standing there names nothing and still yields a value rather than throwing.
"""
return _is_unary_operand(enclosing_operator(node), node, _UNRESOLVABLE_TOLERANT_OPERATORS)
def container_reference_role(node: ReferenceNode) -> ContainerRole:
"""
Classify how the reference *node* touches the container value (object or array) its binding holds.
A member access based on *node* is a `MEMBER_READ` unless the outermost member of the chain it
begins is being written — the left of an assignment, the operand of `++`/`--` or `delete`, or a
target of a `for-in`/`for-of` head or a destructuring pattern — which makes it a `MEMBER_WRITE` (a
write through `a.b.c = v` mutates the object `a` holds), or is invoked as a method (`a.m(...)`, also
as a template tag `` a.m`...` ``), which makes it a `MEMBER_CALL` since the call may mutate the
receiver. A plain `node = ...` reassignment is a `REBIND`; anything else — passed as an argument,
aliased to another binding, returned, used as an operand or a computed key — is an `ESCAPE`, through
which an alias could mutate the container. Parentheses are looked through throughout, so a grouped
write or call (`(a.b) = v`, `(a.sort)()`) is classified by the operator that applies, not as a bare
read. This is the per-reference primitive the EffectModel composes over a binding's whole reference
set (with alias-following and callee summaries) to decide container immutability.
"""
parent = enclosing_operator(node)
if isinstance(parent, JsMemberExpression) and strip_parens(parent.object) is node:
member: Node = parent
while True:
outer = enclosing_operator(member)
if isinstance(outer, JsMemberExpression) and strip_parens(outer.object) is member:
member = outer
continue
break
if _is_invocation_of(enclosing_operator(member), member):
return ContainerRole.MEMBER_CALL
return ContainerRole.MEMBER_WRITE if is_member_write_target(member) else ContainerRole.MEMBER_READ
if isinstance(parent, JsAssignmentExpression) and strip_parens(parent.left) is node and parent.operator == '=':
return ContainerRole.REBIND
return ContainerRole.ESCAPE
def _is_invocation_of(node: Node | None, callee: Node) -> bool:
"""
Whether *node* invokes *callee* — a call `callee(...)` or a tagged template `` callee`...` `` —
looking through parentheses around the callee.
"""
if isinstance(node, JsCallExpression):
return strip_parens(node.callee) is callee
if isinstance(node, JsTaggedTemplateExpression):
return strip_parens(node.tag) is callee
return False
def is_invocation_target(node: Node) -> bool:
"""
Whether *node* is the callee a call invokes or the tag a tagged template applies — `node(...)` or
`` node`...` `` — looking through parentheses around both *node* and the operator that governs it.
The shared primitive for "is this reference actually being called", replacing the hand-rolled
`parent.callee is node` checks that a parenthesized or tagged callee slips past.
A `new` is not one of them, because what makes these two positions special is the receiver a call
reads off a member and the scope a direct `eval` runs in, and a construction has neither. A caller
that asks instead whether the value it is about to write down will be invoked at all wants
`is_constructed_or_invoked`.
"""
return _is_invocation_of(enclosing_operator(node), node)
def is_constructed_or_invoked(node: Node) -> bool:
"""
Whether the value *node* produces is immediately applied — called, tagged, or constructed with
`new` — looking through parentheses around *node* and around the operator that governs it.
This is the question a fold asks before writing a constant in place of an expression. A constant
is not callable and not a constructor, so the application throws either way; but a `TypeError`
names the thing that could not be applied, and `new (3)()` reports the `3` a file never wrote
where `new ('abc'.length)()` reported the access it did. Nothing is gained by folding a value
into the one position where it can only fail, so the access is left as the file spelled it.
"""
if is_invocation_target(node):
return True
operator = enclosing_operator(node)
return isinstance(operator, JsNewExpression) and strip_parens(operator.callee) is node
def is_member_write_target(member: Node) -> bool:
"""
Whether the outermost *member* of a container's access chain is being written rather than read: the
left of an assignment, the operand of `++`/`--` or `delete`, or a target of a `for-in`/`for-of` head
or a destructuring pattern (including a destructuring default, `[a.b = d] = ...`). The shared
`_governing_target` climb looks through destructuring containers and parentheses (`(a.b) = v`), so a
member nested in a pattern or a grouping is still recognized as a write, mirroring `reference_role`
and the binding-target climb in the liveness model.
"""
governor, target = _governing_target(member)
if isinstance(governor, JsAssignmentExpression):
return strip_parens(governor.left) is target
if isinstance(governor, JsUpdateExpression):
return strip_parens(governor.argument) is target
if isinstance(governor, JsUnaryExpression):
return governor.operator == 'delete' and strip_parens(governor.operand) is target
if isinstance(governor, (JsForInStatement, JsForOfStatement)):
return strip_parens(governor.left) is target
return False
def is_simple_assignment_target(node: Node) -> bool:
"""
Whether *node* is the write-only target of a simple (`=`) assignment — the left of `=`, looking
through destructuring patterns, destructuring defaults, and parentheses — but not a compound
assignment (`+=`, `++`), a `delete`, or a `for-in`/`for-of` head, each of which keeps the name
live as a read instead of overwriting it outright. Built on the shared `_governing_target` climb,
so the pattern, default, and parenthesis handling matches every other write-target query rather
than a hand-rolled copy that a later case could drift away from.
"""
governor, target = _governing_target(node)
return (
isinstance(governor, JsAssignmentExpression)
and governor.operator == '='
and strip_parens(governor.left) is target
)
def _walk_skipping_functions(stmts: list) -> Iterator[Node]:
"""
Yield the statements in *stmts* and all their descendants, but do not descend into nested function
bodies or class static blocks — each hoists its own declarations (the boundary nodes themselves are
yielded so their declared names can be read).
"""
stack: list[Node] = list(reversed(stmts))
while stack:
node = stack.pop()
yield node
if isinstance(node, HOIST_BOUNDARY):
continue
stack.extend(reversed(node.children()))
def statement_list_of(node: Node) -> list[Statement] | None:
"""
The list of statements *node* holds, or `None` where it holds none. A script and a block hold
theirs directly, a function holds its body's, and a `switch` case holds the statements written
under it - the four places a declaration may stand in a list at all.
A `switch` holds every case's statements as one list, freshly built, because that is what its
one scope binds: a `let` written under one case is visible under all of them, so a reader asking
what a switch declares has to be given the cases together and not one at a time.
"""
if isinstance(node, JsScript):
return node.body
if isinstance(node, JsSwitchStatement):
return [statement for case in node.cases for statement in case.body]
if isinstance(node, JsBlockStatement):
return node.body
if isinstance(node, JsStaticBlock):
return node.body
if isinstance(node, JsSwitchCase):
return node.body
if isinstance(node, FUNCTION_NODES):
body = node.body
return body.body if isinstance(body, JsBlockStatement) else None
return None
def is_a_var_home(node: Node) -> bool:
"""
Whether *node* is where a `var` and a function declaration written in it are bound: a function,
a class static block, or the script. The same set `HOIST_BOUNDARY` bounds a hoist walk by, asked
of one node rather than used to stop one.
"""
return isinstance(node, (JsScript, HOIST_BOUNDARY))
def declaration_under_export(statement: Statement | None) -> Statement | None:
"""
The declaration *statement* is an `export` of, or *statement* itself where it is not one. An
export names what the declaration written under it declares and declares nothing of its own, so
a reader asking what a statement list binds reads through it.
"""
while isinstance(statement, (JsExportNamedDeclaration, JsExportDefaultDeclaration)):
inner = statement.declaration
if not isinstance(inner, Statement):
break
statement = inner
return statement
def _writes_the_statement_of(parent: Node, child: Node) -> bool:
"""
Whether *parent* is a wrapper *child* is the statement of rather than a list holding it: the
label a statement may be written under, or the `export` a declaration may be written under.
"""
if isinstance(parent, JsLabeledStatement):
return parent.body is child
if isinstance(parent, (JsExportNamedDeclaration, JsExportDefaultDeclaration)):
return parent.declaration is child
return False
def statement_list_holding(node: Node) -> Node | None:
"""
The nearest node holding *node* in a statement list, looking through the labels and the `export`
a statement may be written under, and through the `switch` case a statement stands in. A
labelled declaration stands in the list its label stands in - Annex B reads a label as
transparent at every level, and so does the placement of a `var` - an exported one stands where
the export stands, and every case of a `switch` stands in the one list its scope binds.
"""
cursor: Node = node
parent: Node | None = cursor.parent
while parent is not None and _writes_the_statement_of(parent, cursor):
cursor, parent = parent, parent.parent
if isinstance(parent, JsSwitchCase):
cursor, parent = parent, parent.parent
if parent is None:
return None
return parent if statement_list_of(parent) is not None else None
def lexically_declared_names(statements: list[Statement]) -> frozenset[str]:
"""
The names *statements* declares with a `let`, a `const` or a class directly, read through the
labels and the `export` a declaration may be written under.
"""
names: set[str] = set()
for statement in statements:
statement = declaration_under_export(statement)
while isinstance(statement, JsLabeledStatement):
statement = declaration_under_export(statement.body)
if isinstance(statement, JsVariableDeclaration) and statement.kind in (
JsVarKind.LET, JsVarKind.CONST,
):
for declarator in statement.declarations:
if isinstance(declarator, JsVariableDeclarator):
names.update(ident.name for ident in pattern_identifiers(declarator.id))
elif isinstance(statement, JsClassDeclaration) and statement.id is not None:
names.add(statement.id.name)
return frozenset(names)
class LexicalNameCache:
"""
The lexically declared names of each statement list a walk passes, held for as long as the tree
is not being rewritten.
`annex_b_var_home` climbs from a declaration to the body that gives it a `var`, asking every
list on the way whether it declares the name lexically. A body holding many function
declarations is asked the same question about the same list once per declaration, and the scope
builder asks it twice over, so the answer is quadratic in the size of the body without this.
A cache is only ever right where nothing moves, which is why it is passed in rather than kept:
the caller is the one that knows its rewrites have not started.
"""
def __init__(self):
self._names: dict[int, frozenset[str]] = {}
def declares(self, holder: Node, name: str) -> bool:
"""
Whether the statement list *holder* holds declares *name* lexically, false where it holds
none.
"""
names = self._names.get(id(holder))
if names is None:
statements = statement_list_of(holder)
names = frozenset() if statements is None else lexically_declared_names(statements)
self._names[id(holder)] = names
return name in names
def annex_b_var_home(
declaration: JsFunctionDeclaration, cache: LexicalNameCache | None = None,
) -> Node | None:
"""
The function, script or static block whose `var` names the function *declaration* declares, or
`None` where nothing outside the block holding it ever does.
A declaration written directly in such a body names something there and is answered with that
body. A declaration written inside a block is a lexical binding of that block, and only Annex
B.3.3 puts the name outside it: the enclosing body gets a `var` of the name, which the copy the
declaration makes writes to when it runs. That happens in sloppy code only, and §B.3.3.1 names
three things that stop it:
- a `let`, `const` or `class` of the same name between the block and the body, the body's own
list included, which the `var` would conflict with;
- a parameter of the function, which already binds the name;
- the name `arguments`, whose binding the function already has, which is a condition about a
function and not about a script or a static block, neither of which has one.
A catch parameter is not one of them, and the mismatch is deliberate: a simple catch parameter
does not stop the copy, so the enclosing name still ends up holding the function, while a
destructuring one does, being a lexical declaration the `var` would conflict with. Both were
read from an engine rather than from the text of the specification.
*cache* holds the lexical names of the lists the climb passes, for a caller asking this of many
declarations over a tree it is not rewriting.
"""
cache = cache if cache is not None else LexicalNameCache()
home = statement_list_holding(declaration)
if home is not None and is_a_var_home(home):
return home
if strict_mode_at(declaration):
return None
name = declaration.id.name if declaration.id is not None else None
if name is None:
return None
cursor: Node | None = declaration.parent
while cursor is not None:
if cache.declares(cursor, name):
return None
if (
isinstance(cursor, JsCatchClause)
and cursor.param is not None
and not isinstance(cursor.param, JsIdentifier)
and any(ident.name == name for ident in pattern_identifiers(cursor.param))
):
return None
if is_a_var_home(cursor):
if (
name == 'arguments'
and isinstance(cursor, FUNCTION_NODES)
and not isinstance(cursor, JsArrowFunctionExpression)
):
return None
if any(
ident.name == name
for param in getattr(cursor, 'params', ())
for ident in pattern_identifiers(param)
):
return None
return cursor
cursor = cursor.parent
return None
def annex_b_copies_into(binding: Binding) -> bool:
"""
Whether *binding* holds a function Annex B copies into its scope rather than declares there.
The difference the copy makes is one of time. A function declared in the scope it names holds
its value before any statement of that scope runs, so nothing has to be ordered against it; one
Annex B copies holds it only from the point the declaration is reached, so a read before that
point - or in a run in which the block is never entered - finds whatever was there instead.
`binding_establishment_sites` is where that is answered, by naming the declaration as the node
the value waits on rather than by answering with the empty list a hoisted value gets.
Spelled over the scope the binding is in rather than over its variable scope, because a block
function that is *not* copied is declared in the block it stands in, and that one is a plain
lexical binding: it is initialized before any statement of the block runs and the declaration is
exactly what it holds.
"""
if binding.kind is not BindingKind.FUNCTION or len(binding.declarations) != 1:
return False
declaration = binding.declarations[0].parent
if not isinstance(declaration, JsFunctionDeclaration):
return False
return statement_list_holding(declaration) is not _statement_list_holder_of(binding.scope)
def _statement_list_holder_of(scope: Scope) -> Node | None:
"""
The node whose statement list *scope* binds the declarations of, which is the scope's own node
but for a function, whose statements are its body's.
"""
node = scope.node
if isinstance(node, FUNCTION_NODES):
body = node.body
return body if isinstance(body, JsBlockStatement) else None
return node
def enclosing_function(node: Node) -> Node | None:
"""
The nearest function node — declaration, expression, or arrow — that lexically encloses *node*, or
`None` when *node* sits at the top level below no function.
"""
cursor = node.parent
while cursor is not None:
if isinstance(cursor, FUNCTION_NODES):
return cursor
cursor = cursor.parent
return None
def walk_receiver_scope(root: Node) -> Iterator[Node]:
"""
Yield every node in the subtree at *root* that shares *root*'s `this`/`super` receiver, without
descending into a nested regular or generator function, which rebinds `this`. Arrow functions are
descended, since they inherit the receiver lexically. A class rebinds `this` for its method bodies
and field initializers, but its `extends` clause and any computed member keys are evaluated in the
enclosing receiver context, so only those parts of a class are descended. *root* itself is always
yielded and descended, so a method reached directly through *root* is included.
The receiver scope is also the *argument* scope: an arrow has no `arguments` object of its own and
reads the enclosing one, a nested function has its own. So `references_own_arguments` asks the same
boundary, and both live here rather than beside either caller.
"""
stack: list[Node] = [root]
while stack:
node = stack.pop()
yield node
if isinstance(node, (JsFunctionExpression, JsFunctionDeclaration)) and node is not root:
continue
if isinstance(node, (JsClassDeclaration, JsClassExpression)):
if node.super_class is not None:
stack.append(node.super_class)
if node.body is not None:
for member in node.body.body:
if isinstance(member, JsStaticBlock):
continue
if member.computed and member.key is not None:
stack.append(member.key)
continue
stack.extend(node.children())
def _shares_the_receiver_of(parent: Node, child: Node) -> bool:
"""
Whether *child*, a child of *parent*, is evaluated with the receiver *parent* is - the one step
`walk_receiver_scope` takes, read from the child upwards rather than from the root down.
The two have to agree, because they are one boundary asked from two directions: the walk gives
every node under a receiver, and this gives the receiver of one node without a root to start
from. A node has no receiver of its own unless something above it made one, so answering from
below is what lets the question be asked of a node whose tree is being rewritten around it.
"""
if isinstance(parent, (JsFunctionExpression, JsFunctionDeclaration, JsStaticBlock)):
return False
if isinstance(parent, (JsMethodDefinition, JsPropertyDefinition)):
return parent.computed and child is parent.key
if isinstance(parent, (JsClassDeclaration, JsClassExpression)):
return child is parent.super_class or child is parent.body
return True
def is_the_this_of_a_script(node: Node) -> bool:
"""
Whether *node* is a `this` denoting what the top level of a classic script does, which is the
global object: the position it stands in is reached from the top of the file without crossing
anything that makes a receiver of its own.
Written as a climb rather than as a walk from the root so that it answers for a node whose tree
is being rewritten: a climb that runs out of parents has reached a root and answers `True`, so a
node lifted out of the tree reads as the top level, which is the direction that keeps a
declaration rather than removing one.
An arrow crosses nothing - it has no `this` of its own - so a `this` inside any number of them
is still the top level's, and so is one in the `extends` clause or a computed key of a class,
which are evaluated where the class is written. A method, an accessor, a field initializer and a
static block each hold one of their own. A decorator is not answered for, because no engine this
is checked against parses one.
"""
if not isinstance(node, JsThisExpression):
return False
child: Node = node
cursor = node.parent
while cursor is not None:
if not _shares_the_receiver_of(cursor, child):
return False
child, cursor = cursor, cursor.parent
return True
def references_own_arguments(fn: Node) -> bool:
"""
Whether *fn* reads its own `arguments` object, rather than one belonging to a function around it or
inside it. `walk_receiver_scope` draws that boundary: an arrow inherits the enclosing `arguments`
and is descended, a nested regular or generator function has its own and is not.
"""
return any(
isinstance(node, JsIdentifier) and node.name == 'arguments' and is_use_position(node)
for node in walk_receiver_scope(fn)
)
def has_mapped_arguments(fn: Node, *, strict: bool) -> bool:
"""
Whether *fn* observes an `arguments` object whose elements alias its parameters. Aliasing holds for
a regular or generator function, in sloppy mode, with a simple parameter list: writing a parameter
then writes `arguments[i]`, and writing `arguments[i]` writes the parameter. Strict mode gives an
independent copy, and so does any default, rest or destructuring parameter; an arrow has no
`arguments` of its own at all.
*strict* is the mode *fn*'s body runs in and is supplied by the caller rather than derived here,
because a payload being examined out of place has no tree above it to derive it from, and because
module code is strict for a reason no node records.
The parameter list must also be non-empty: `has_simple_parameters` answers §15.1.3, which an empty
list satisfies, but with no parameters there is nothing for an element to alias.
"""
if strict or not isinstance(fn, (JsFunctionExpression, JsFunctionDeclaration)):
return False
if not fn.params or not has_simple_parameters(fn):
return False
return references_own_arguments(fn)
def is_global_object_base(node: Node | None) -> bool:
"""
Whether *node*, written as the base of a member access, denotes the global object - so that a
property named on it is a global, and a property named on it at run time could be any global.
Only the spelling is read. Parentheses around the base are looked through, because they change
what a base is written as and nothing about what it denotes: `(window).eval` obtains the same
intrinsic `window.eval` does, and a reader that saw the two differently would refuse a fold for
one of them and not the other.
Whether the name is bound to something else is a separate question, and the two callers want
opposite answers to it, which is why it is not asked here.
`SemanticModel.global_alias_member_name` asks it, because a reference it records has to be a
reference to the global it names, and a local `window` names an ordinary object.
`_is_reflective_member` deliberately does not: a surface it reports where there is none only
refuses a fold, and one it misses removes code that runs.
"""
base = strip_parens(node) if node is not None else None
if base is None:
return False
if isinstance(base, JsThisExpression):
return is_the_this_of_a_script(base)
return isinstance(base, JsIdentifier) and base.name in GLOBAL_OBJECT_ALIASES
def may_be_global_object_base(node: Node | None) -> bool:
"""
Whether *node*, written as the base of a member access, may denote the global object once the
program runs. `is_global_object_base` widened by the receiver a call supplies: a function called
with no receiver is given `undefined`, and sloppy code replaces that with the global object
before the body runs, so a `this` in such a body reads the same properties the top level does.
Which calls reach a body is not decided here, and every `this` is admitted rather than only the
ones a bare call can reach. The two directions cost different things: admitting a receiver that
is some other object keeps a declaration a reader may never reach, while missing one removes a
declaration the program still reads.
The readers that decide a *rewrite* keep the narrow question. `_is_reflective_member` does
because a `this` reaches every method of every object in a file, and reporting a surface for one
freezes every removal in it; `_timer_callee_name` because it names a callee it will act on.
"""
base = strip_parens(node) if node is not None else None
if isinstance(base, JsThisExpression):
return True
return is_global_object_base(base)
def _member_property_name(member: JsMemberExpression) -> str | None:
"""
The statically known property name a member access designates: the property identifier of a dot
access (`o.g`) or the value of a string-literal computed access (`o['g']`). A non-literal computed
key (`o[expr]`) has no static name and yields `None`. The base is not inspected — a caller that
needs the base to be a global-object alias checks that separately.
"""
prop = member.property
if member.computed:
return prop.value if isinstance(prop, JsStringLiteral) else None
return prop.name if isinstance(prop, JsIdentifier) else None
def _last_positions(params: list[Binding | None]) -> list[Binding | None]:
"""
*params* with every position a repeated parameter name occupies but the final one blanked out. A
repeated name declares one binding, and only its last occurrence is the one an `arguments`
element is mapped onto (§10.2.11), so every earlier position of a repeated name aliases nothing.
"""
result = list(params)
seen: set[int] = set()
for index in reversed(range(len(result))):
binding = result[index]
if binding is None:
continue
if id(binding) in seen:
result[index] = None
seen.add(id(binding))
return result
_DELETE_OPERATOR = frozenset({'delete'})
def _is_delete_operand(node: Node) -> bool:
"""
Whether *node* stands as the operand of a `delete`. `reference_role` answers `Role.READWRITE`
for such a reference as it does for a compound assignment, because both keep the name live as a
read; only the assignment puts a value under the name.
"""
return _is_unary_operand(enclosing_operator(node), node, _DELETE_OPERATOR)
def _displaces_arguments(
binding: Binding,
fn: JsFunctionExpression | JsFunctionDeclaration,
) -> bool:
"""
Whether the name `arguments` inside *fn* denotes something other than the mapped object *fn* is
given, either because no such object is ever created or because a value is put in its place.
§10.2.11 creates no object at all where the name is a parameter of *fn*, or where the body
declares it lexically (`let arguments;`) or as a function of its own: each of those puts the
name in a list the creation is conditioned on, and the name then denotes whatever that
declaration binds. An assignment over the name and a `var` that initializes it are the other
case — the object is created and then replaced by a value whose elements alias no parameter.
Three shapes displace nothing. A `var arguments;` with no initializer redeclares a name the
function already has, and a `var` never overwrites a value already bound to its name, so the
object and its aliasing are what they were; refusing it would stop recording the reads that keep
a write to a parameter alive, and that write would then be dropped as dead. A `delete` of the
name stores nothing — the binding is not configurable, so the operator evaluates to `false` and
leaves the object where it was. And a function expression's own name is bound in an environment
the object's own shadows, so `var f = function arguments(a) { return arguments[0]; }` still
reads the mapped object; that site is skipped here because the scope model records the two names
as one binding.
The declarator distinction lives here because a declarator with an initializer is recorded as a
declaration and not as a write, so asking for writes alone would let `var arguments = [7]` through.
"""
if any(not _is_delete_operand(write) for write in binding.writes):
return True
for site in binding.declarations:
if site is fn.id:
continue
declarator = site.parent
if not isinstance(declarator, JsVariableDeclarator) or declarator.id is not site:
return True
if declarator.init is not None:
return True
declaration = declarator.parent
if not isinstance(declaration, JsVariableDeclaration):
return True
if declaration.kind is not JsVarKind.VAR:
return True
if isinstance(declaration.parent, (JsForInStatement, JsForOfStatement)):
return True
return False
def _is_global_alias_access(node: Node) -> bool:
"""
Whether *node* is a member access on a global-object alias, which is the one kind of access that
reaches a binding no lexical name of its own is written for. An access on a mapped `arguments`
object is the other, and reaches a parameter of the one function that holds it.
"""
return isinstance(node, JsMemberExpression) and may_be_global_object_base(node.object)
def _enclosing_member_access(node: Node) -> JsMemberExpression | None:
"""
The member access *node* is the receiver of, looking through any parentheses written around it,
or `None` where *node* is used as something other than a receiver. `(arguments)[0]` reaches its
element exactly as `arguments[0]` does, so a receiver is recognized through a grouping the way
every other operand in this module is — through the one climb `enclosing_operator` performs.
"""
parent = enclosing_operator(node)
if isinstance(parent, JsMemberExpression) and strip_parens(parent.object) is node:
return parent
return None
def _is_call_argument(node: Node) -> bool:
"""
Whether *node* is written as an argument of a call or a `new`, so that evaluating the call hands
what it denotes to a body this walk does not read. The callee position is not one: a call on the
global object reaches the object as a receiver, which is the question
`may_be_global_object_base` answers, and a call *of* it throws.
"""
governor = enclosing_operator(node)
if not isinstance(governor, (JsCallExpression, JsNewExpression)):
return False
return any(strip_parens(argument) is node for argument in governor.arguments)
def _enclosing_call(node: Node) -> JsCallExpression | JsNewExpression | None:
"""
The call or `new` *node* is written as an argument of, looking through parentheses, or `None`
when it is not an argument. This is the call whose body may observe what *node* denotes, and it
is recognized the way `_is_call_argument` recognizes the position — the two ask one question,
one for the answer and one for the call.
"""
governor = enclosing_operator(node)
if not isinstance(governor, (JsCallExpression, JsNewExpression)):
return None
if any(strip_parens(argument) is node for argument in governor.arguments):
return governor
return None
def _sole_returned_function(function: Node) -> JsFunctionNode | None:
"""
The single function literal *function* returns to its caller, or `None` when it returns none or
more than one. Only a `return` in *function*'s own receiver scope is one of its returns, so a
`return` inside a function it defines is not counted; `walk_receiver_scope` draws that boundary.
This is the shape a self-defending wrapper's factory takes — a zero-argument IIFE whose one
`return` yields the `function(a, b)` that a guard site calls.
"""
returned: list[JsFunctionNode] = []
for node in walk_receiver_scope(function):
if not isinstance(node, JsReturnStatement) or node.argument is None:
continue
value = strip_parens(node.argument)
if isinstance(value, FUNCTION_NODES):
returned.append(value)
return returned[0] if len(returned) == 1 else None
_GLOBAL_ALIAS_CHAIN_LIMIT = 4
"""
How far `SemanticModel.names_the_global_object` follows one name to the next before it
gives up. A program names the global object once and reads through that name; a chain of four
is already past anything a file writes, and the bound is what keeps `var a = b, b = a` from
recurring forever.
"""
_HANDED_OBJECT_OBSERVATION_DEPTH = 4
"""
How far `SemanticModel.global_object_argument_is_observed` follows a handed object through nested
calls — a callee that hands its receiver on with `apply`, whose target hands it on again — before
it gives up and takes the object for observed. The obfuscator's self-defending wrapper is one hop
deep; the bound is what keeps a chain of mutual hand-offs from recurring forever.
"""
_IDENTITY_OPERATORS = frozenset({'typeof', 'void', '!'})
"""
The unary operators that take their operand to a type name or a truth value without entering the
object protocol. `+`, `-` and `~` are not in it: `ToNumber` calls `valueOf`, which a poisoned
`Object.prototype` answers with code that receives the operand as `this`.
"""
def _observes_identity_alone(governor: Node | None, operand: Node) -> bool:
"""
Whether the construct governing a bare reference to a mapped `arguments` object observes only
what the object is, never what it holds or where it goes: the operand of `typeof`, `void` or
`!`, and the test of a branch, a loop or a conditional expression, take the object to a type
name or a truth value without reading an element, writing one, or letting the object itself
flow anywhere. `for-in` is here and `for-of` is not, because enumeration reads the key set,
which a write to a parameter never changes — §10.2.11 keys the mapping by the call's arity —
while iteration reads the elements.
A statement that is the reference and nothing else is the plainest of them: it reads the
binding and discards what it read, which is what `void` spells with a keyword in front. A
statement value is unobservable in a function body, and a mapped `arguments` object is only
ever named inside one, so nothing downstream can pick the object up from there.
"""
if isinstance(governor, JsUnaryExpression):
return _is_unary_operand(governor, operand, _IDENTITY_OPERATORS)
if isinstance(governor, (
JsIfStatement,
JsWhileStatement,
JsDoWhileStatement,
JsForStatement,
JsConditionalExpression,
)):
return strip_parens(governor.test) is operand
if isinstance(governor, JsForInStatement):
return strip_parens(governor.right) is operand
if isinstance(governor, JsExpressionStatement):
return strip_parens(governor.expression) is operand
return False
def _reads_every_element_alone(governor: Node | None, operand: Node) -> bool:
"""
Whether the construct governing a bare reference to a mapped `arguments` object reads the
elements out of it and does nothing else: a spread copies the values and a synchronous `for-of`
walks them, and both leave the object behind — the values escape, the object does not, and a
value cannot write the parameter it was read from. Both walks go through the object's own
`@@iterator`, which holds the values intrinsic from the moment of creation and can only be
replaced through a member write that `_record_arguments_alias_references` already takes as an
indefinite write of every parameter, so a program that could turn the walk into a write has
refused every fold before this answer is consulted.
`for await` is not one of them, and being one keyword away from a walk that is makes it worth
saying why: asynchronous iteration asks for `@@asyncIterator` first, which §10.2.11 gives the
object none of, so the lookup leaves the object and reaches `Object.prototype` — and whatever
stands there is then called with the object as `this`. Node prints `10` rather than `3` for
`f(2)` where `f` is `async function f(a) { for await (const v of arguments) {} return a + 1; }`
and `Object.prototype[Symbol.asyncIterator]` writes `this[0]`, which is a parameter written
through a walk this answer would have called incapable of writing one.
"""
if isinstance(governor, JsSpreadElement):
return strip_parens(governor.argument) is operand
if isinstance(governor, JsForOfStatement) and not governor.is_await:
return strip_parens(governor.right) is operand
return False
def _aliased_parameter_positions(member: JsMemberExpression, count: int) -> range | None:
"""
The parameter positions a member access on a mapped `arguments` object names, out of *count*
parameters, or `None` where it names none of them because the key is not statically known. A key
that is the canonical spelling of an index in range designates that one parameter; a key that is
statically known and is not — `length`, `callee`, an index past the end of the list — designates
the empty range, an element being the only thing that aliases anything.
Naming and reaching are separate answers, which is why an unknown key is `None` rather than the
whole range. Every position is in reach of such an access, but no position is *named* by it, and a
caller that recorded the access's own role against each would turn one indefinite write into a
definite write of every parameter.
The key is read through `exact_integer` and `canonical_array_index` rather than through `int`,
which raises on the infinities a Number literal may denote and accepts the non-ASCII digits a
property key may spell.
"""
prop = member.property
if not member.computed:
return range(0)
if isinstance(prop, JsNumericLiteral):
index = exact_integer(prop.value)
elif isinstance(prop, JsStringLiteral):
index = canonical_array_index(prop.value)
else:
return None
if index is None or not 0 <= index < count:
return range(0)
return range(index, index + 1)
def _is_member_assignment_target(member: JsMemberExpression) -> bool:
"""
Whether *member* is the target of a plain `=` assignment (`m = x`), the one position where a member
access is written without its prior value being read. A compound assignment (`m += x`) or an update
(`m++`) reads the value before writing, so neither is counted here.
"""
parent = member.parent
return (
isinstance(parent, JsAssignmentExpression)
and parent.operator == '='
and strip_parens(parent.left) is member
)
def _is_a_named_global_object_base(node: Node | None) -> bool:
"""
Whether *node* denotes the global object by one of the names written for it, which is
`is_global_object_base` without the positional reading of `this`. Only `_timer_callee_name`
reads it, and its docstring says why. Asked of the one reading rather than spelled a second
time, so that what counts as a named base is decided in one place.
"""
base = strip_parens(node) if node is not None else None
return not isinstance(base, JsThisExpression) and is_global_object_base(base)
def _is_reflective_member(member: JsMemberExpression) -> bool:
"""
Whether a member access is a reflective surface — one through which code obtains the `eval`/`Function`
intrinsic or reads an unknown global by a runtime-computed name. A statically named property is a
surface exactly when the name is a reflective intrinsic: `window.eval`, `g['Function']`, and the same
under any unrecognized base, since the base may alias the global object. A computed access with a
non-literal key is a surface when its base is a global-object alias (`window[expr]`), through which any
global can be named at runtime; on any other base it designates a property of one specific object and
is not a surface.
"""
prop = member.property
if member.computed:
if isinstance(prop, JsStringLiteral):
return prop.value in REFLECTIVE_INTRINSICS
return is_global_object_base(member.object)
return isinstance(prop, JsIdentifier) and prop.name in REFLECTIVE_INTRINSICS
def is_direct_eval_call(node: Node) -> bool:
"""
Whether *node* is a direct `eval` call — a call whose callee, once parentheses are stripped, is
the bare identifier `eval`. Parentheses are transparent to the reference, so `(eval)(...)` is a
direct eval exactly as `eval(...)`; a callee that instead only yields the function as a value —
the comma sequence `(0, eval)(...)` that strips to a sequence expression, or a member
`o.eval(...)` — is indirect, runs in the global scope, and is excluded. Direct eval is the one
reflective surface that runs in the caller's own scope and can therefore name its locals; the
excluded indirect forms name only globals, and `has_reflection_surface` accounts for them
whole-program.
"""
if not isinstance(node, JsCallExpression):
return False
callee = strip_parens(node.callee)
return isinstance(callee, JsIdentifier) and callee.name == 'eval'
def _timer_callee_name(callee: Node | None) -> str | None:
"""
The timer/`execScript` function *callee* names, or `None` when it is not one. A bare identifier
names the timer directly (`setTimeout(...)`); a member access on a global-object alias
(`window.setTimeout(...)`, `globalThis['setInterval'](...)`) names the same global timer through
the global object. Parentheses are transparent to the reference. Any other base designates a
property of one specific object and is not the global timer. The base is not shadow-checked — a
local `window` yielding a match only over-reports a reflection surface, the safe direction for the
whole-program detector this feeds.
A `this` is not read as the global object here even where it stands for one, which is the one
place the two readings of a base part company. Over-reporting is safe for a surface and this
detector accepts it, but a `this` reaches every method of every object in the file, so reading
one as the global object would report a surface for `this.setTimeout(f)` on any receiver at all
- and a surface freezes every removal in the file it is found in.
"""
callee = strip_parens(callee)
if isinstance(callee, JsIdentifier):
return callee.name if callee.name in STRING_EVAL_NAMES else None
if isinstance(callee, JsMemberExpression) and _is_a_named_global_object_base(callee.object):
name = _member_property_name(callee)
return name if name in STRING_EVAL_NAMES else None
return None
def _is_string_timer(call: JsCallExpression) -> bool:
"""
Whether *call* is a timer/`execScript` invocation whose first argument is not a function literal,
so it may evaluate a string of code. The callee may name the timer directly (`setTimeout(...)`) or
through a global-object alias (`window.setTimeout(...)`), both of which reach the same evaluating
global (see `_timer_callee_name`).
"""
if _timer_callee_name(call.callee) is None:
return False
if not call.arguments:
return False
return not isinstance(call.arguments[0], (JsFunctionExpression, JsArrowFunctionExpression))
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 parameter_scope(self, func: Node) -> Scope | None:
"""
The scope holding *func*'s parameters and the `arguments` object a call gives it, which is
its body's scope but for a function whose parameter list holds an expression: that one binds
them in a scope of its own standing between the body and what encloses the function.
A consumer reading a parameter binding out of a scope's own `bindings` asks for this one.
`function_scope` answers the body's, which for such a function holds neither.
"""
scope = self.function_scope(func)
if scope is None:
return None
parent = scope.parent
if parent is not None and parent.kind is ScopeKind.PARAMS and parent.node is scope.node:
return parent
return scope
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[ReferenceNode]:
"""
Every reference (read or write) bound to *binding*, optionally omitting those that lie within
the subtree of *exclude*. Each is a referencing identifier except where an object aliasing the
binding stands in for one (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 read_may_throw(self, node: JsIdentifier) -> bool:
"""
Whether evaluating *node* as a read may throw a `ReferenceError` because the name it spells
is not certain to denote a binding. The companion to `read_has_dynamic_effect`, which asks
what else a read may do; this asks whether it may not happen at all. A caller that treats an
unresolved read as free is asserting the host defines the name, which for a name the program
neither declares nor assigns is an assertion about someone else's global object.
A name resolves for certain when a declaration binds it, or when the specification mandates
it on the global object (`GUARANTEED_GLOBALS`) — the same existence allowlist that decides
whether a global-alias member read may be collapsed to a bare name. A
`GLOBAL_OBJECT_ALIASES` spelling resolves too, which is a *host* assumption rather than a
language one: no host defines all of them, so a bare `window` throws under Node exactly as a
bare `global` throws in a browser. It is admitted because the effect analysis already rests
on it — `_base_is_safe` clears a property access on an alias — and answering otherwise here
would leave that clause standing with nothing left for it to decide. Everything else may not
be there:
- a free name, which reaches the host and may simply not exist
- a name whose only binding is an `IMPLICIT_GLOBAL`, which the assignment that creates it
brings into existence, so a read that runs first — or whose creating assignment sits in a
function nobody calls — throws exactly as a free name does
- a name resolved through a `with` body whose object may not carry it and which has no
lexical binding to fall through to, which the `cross_dynamic` lookup is what distinguishes
A reference that is written and not read answers `False`, as do the two operator positions
`tolerates_unresolvable` names. The write case is a scope boundary, not a claim that
writing is safe: sloppy code assigning to a name nothing binds creates a property of the
global object, while strict code throws the same `ReferenceError` a read does, which is a
separate defect with its own pin
(`test_unfixed_defects.A_STRICT_REGION_ASSIGNING_TO_NO_BINDING`).
"""
if not self.is_reference(node) or reference_role(node) is Role.WRITE:
return False
if node.name in GUARANTEED_GLOBALS or node.name in GLOBAL_OBJECT_ALIASES:
return False
if tolerates_unresolvable(node):
return False
scope = self._node_scope.get(id(node))
binding = self.lookup(node.name, scope, cross_dynamic=True)
return binding is None or binding.kind is BindingKind.IMPLICIT_GLOBAL
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 — the whole of it, so a write that leaves no `writes`
entry because nothing says what it stored (`has_indefinite_write`) unpins the name as much
as one that does.
"""
parent = function.parent
establishing = None
if (
isinstance(parent, JsAssignmentExpression)
and parent.operator == '='
and parent.right is function
):
establishing = strip_parens(parent.left)
if binding.has_indefinite_write:
return False
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 binding.written_at_entry:
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 — which is also the answer where a write leaves no `writes`
entry because nothing says what it stored, since an empty `writes` would otherwise read as a
value hoisted into place before any statement runs. 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.has_indefinite_write:
return None
if binding.writes:
return list(binding.writes)
declaration = binding.declarations[0]
parent = declaration.parent
if isinstance(parent, JsFunctionDeclaration):
return [parent] if annex_b_copies_into(binding) else []
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 free_name_reachable_by_direct_eval(self, node: Node) -> bool:
"""
Whether a direct `eval` could have installed a binding that a free name at *node* reads instead
of the global one. `resolve` answering `None` means this model saw no declaration of the name,
which is not the same as there being none: `eval('var undefined = 4')` declares one that no
reference here records, and a read of that name afterwards is the binding, not the global.
Only `var` and function declarations escape an `eval` — a `let` inside one lives in a scope
discarded with the call — so a binding it installs lands in the var scope the call itself stands
in, and is visible at *node* exactly when that var scope contains *node*'s scope. This is the
mirror of `local_reachable_by_direct_eval`, which asks whether an `eval` can name a binding that
already exists and therefore counts one nested *below* the binding's owner; a nested `eval`
declares into its own function and so is not counted here.
An `eval` whose own argument contains *node* is excluded, and that exclusion is about order
rather than scope: the arguments of a call are evaluated before the call runs, so the code the
`eval` is about to execute cannot have declared anything the argument reads. Without it,
`eval(atob('...'))` — the shape most of this tool's corpus is written in — would refuse to read
`atob` on the strength of the very `eval` it is decoding the body of.
"""
scope = self.scope_of(node)
if scope is None:
return True
enclosing = {id(node)}
cursor = node.parent
while cursor is not None:
enclosing.add(id(cursor))
cursor = cursor.parent
for site in self._direct_eval_sites(self.root):
if any(id(argument) in enclosing for argument in getattr(site, 'arguments', ())):
continue
site_scope = self.scope_of(site)
owner = site_scope.var_scope if site_scope is not None else None
if owner is None or owner.contains(scope):
return True
return False
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 write through an object that aliases the binding — `indefinite_writes` — is
counted here too: it replaces the value under the name while leaving no entry that says with
what. A consumer that judges a binding's value stable from `writes` alone must also consult
this, since none of these reassignments 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 binding.has_indefinite_write:
return True
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()
self._record_def_use_references()
self._record_arguments_alias_references()
self._record_global_object_alias_references()
self._record_exports()
def _record_exports(self):
"""
Flag every binding an `export` ties to the outside as `Binding.exported`. A declaration
written under an export (`export var a`, `export function`/`class`, and `export default` of a
named function or class) exports the binding it declares; a sourceless list (`export { a }`,
`export { a as q }`) exports the binding each specifier's local half names. A list carrying a
`from` clause and a re-export name a binding of the module the clause spells, nothing local,
and are passed over here.
"""
for node in self.root.walk():
if isinstance(node, JsExportNamedDeclaration):
if node.declaration is not None:
self._mark_declaration_exported(node.declaration)
elif node.source is None:
for specifier in node.specifiers:
if isinstance(specifier.local, JsIdentifier):
self._mark_binding_exported(self.resolve(specifier.local))
elif isinstance(node, JsExportDefaultDeclaration):
self._mark_declaration_exported(node.declaration)
def _mark_declaration_exported(self, declaration: Node | None):
"""
Flag the bindings a declaration written under an export declares. A `var`/`let`/`const`
exports every name its declarators bind, descending through destructuring; a function or
class declaration exports its own name. An expression under `export default` declares no
binding and is read like any other value.
"""
if isinstance(declaration, JsVariableDeclaration):
for declarator in declaration.declarations:
if isinstance(declarator, JsVariableDeclarator):
for ident in pattern_identifiers(declarator.id):
self._mark_binding_exported(self.binding_of(ident))
elif isinstance(declaration, (JsFunctionDeclaration, JsClassDeclaration)):
if isinstance(declaration.id, JsIdentifier):
self._mark_binding_exported(self.binding_of(declaration.id))
@staticmethod
def _mark_binding_exported(binding: Binding | None):
if binding is not None:
binding.exported = True
def _record_def_use_references(self):
"""
One record per reference node: the walk reaches a node once per slot holding it, and the
one identifier of `{ a }` or of `export { a };` fills two, so without the dedup a read's
multiplicity would follow its spelling rather than the program.
"""
seen: set[int] = set()
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 id(node) in seen:
continue
seen.add(id(node))
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)
binding.note_reference_from(ref_scope)
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, *, module_scope: bool = False,
) -> 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.
*module_scope* is the one thing about the file this query cannot read off the access. A
`this` written where a classic script's top level holds one denotes the global object; the
same `this` in a module denotes nothing, and in a CommonJS file it denotes that file's
exports. So a caller rewriting a program for a host answers under the model it runs, and the
default is the script model, which is the model this class records under: recording a
reference the module model would not have is what keeps a declaration a reader may reach,
and refusing to record it is what removes one.
"""
return self._global_member_name(
member, self._base_is_the_global_object, module_scope=module_scope)
def may_name_a_global(self, member: JsMemberExpression) -> str | None:
"""
The name of the global that a member access *may* reference once the program runs, read
through `may_be_global_object_base` rather than through the spelling alone, or `None`.
The reading half of `global_alias_member_name`, and separate from it because the two answers
are spent on opposite things. This one is recorded as a reference, where admitting an access
whose receiver turns out to be another object keeps a declaration nothing reaches. That one
drives a rewrite, where the same admission renames a method's own property to a global:
`refinery.lib.scripts.js.deobfuscation.reflection` resolves a member callee through it, and
a `this.eval(...)` answered as the global `eval` rewrites a call to an ordinary method.
No binding is minted from this answer. `_ensure_implicit_global_from_alias_write` keeps the
spelling question, because a minted global is a name every intrinsic-trust and reflection
reader then sees, and one minted from a receiver that was some other object withdraws trust
the file never gave up.
"""
return self._global_member_name(member, self._base_may_be_the_global_object)
def _global_member_name(
self,
member: JsMemberExpression,
base_is_the_global_object: Callable[[Node | None], bool],
*,
module_scope: bool = False,
) -> str | None:
base = strip_parens(member.object)
if not base_is_the_global_object(base):
return None
if module_scope and isinstance(base, JsThisExpression):
return None
name = _member_property_name(member)
if name is None:
return None
if crosses_dynamic_scope(self._node_scope.get(id(member))):
return None
return name
def _base_is_the_global_object(self, base: Node | None) -> bool:
"""
Whether *base* is the global object under the narrow reading: the spelling says so, and the
name it is spelled with is not bound to anything else. A local `window` names an ordinary
object, so the two questions are one answer here, and every reader that drives a rewrite
gets that answer.
"""
return is_global_object_base(base) and not self._is_bound_here(base)
def _holds_the_global_object(self, node: Node | None) -> bool:
"""
Whether *node* is the global object: spelled as one, or a name the file gives it to. A
program meant to run in a browser and in something else names it once — `var w = window ||
{}` — and every read through that name afterwards reads a global property, which
`_base_is_the_global_object` cannot see, because the name it is asked about is `w`.
"""
return self._base_is_the_global_object(node) or self.names_the_global_object(node)
def _base_may_be_the_global_object(self, base: Node | None) -> bool:
"""
Whether *base* may be the global object once the program runs: `_holds_the_global_object`
widened by the receiver a call supplies, which `may_be_global_object_base` states. Only a
reader recording a reference asks this, and the argument for admitting a receiver that turns
out to be another object is written there.
"""
return (
may_be_global_object_base(base) and not self._is_bound_here(base)
) or self.names_the_global_object(base)
def _is_bound_here(self, node: Node | None) -> bool:
return (
isinstance(node, JsIdentifier)
and self.lookup(node.name, self._node_scope.get(id(node))) is not None
)
def names_the_global_object(self, node: Node | None, *, depth: int = 0) -> bool:
"""
Whether *node* is a name whose one value is the global object, so a property read on it is a
read of a global. The value comes from `singular_value`, so a name written more than once,
redeclared, or reachable by a dynamic rebinding has none and is refused.
A name the file only ever assigns has none either: `_ensure_implicit_global_from_alias_write`
mints its binding without a declaration and both value queries decline for it. That is what
keeps this answer out of the walk which is still recording those very writes — a read
admitted or refused by how far that walk had got would depend on nothing the program says.
The value holds wherever the name is not in its temporal dead zone, and nothing here orders
the establishing definition before the read. A caller driving a rewrite has to; the callers
here record a reference, where one admission too many keeps a declaration and one refusal
too many deletes one.
"""
if depth >= _GLOBAL_ALIAS_CHAIN_LIMIT or not isinstance(node, JsIdentifier):
return False
return self._value_is_the_global_object(
self.singular_value(self.resolve(node)), depth + 1)
def _value_is_the_global_object(self, value: Node | None, depth: int) -> bool:
"""
Whether *value*, the one value a name holds, is the global object. `A || B` is it whenever
`A` is: every spelling of the object is truthy, so the guard a program writes to survive a
host lacking the name it prefers evaluates to the object wherever that name exists.
"""
value = strip_parens(value)
if value is None:
return False
if self._base_is_the_global_object(value):
return True
if isinstance(value, JsLogicalExpression) and value.operator == '||':
return self._value_is_the_global_object(value.left, depth)
return self.names_the_global_object(value, depth=depth)
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.
The binding minted here is one nothing reads a value out of: it carries no declaration, so
both value queries decline for it, and all it does is give a reference somewhere to resolve
to instead of standing free. That is why a write through the `this` of a top level mints one
too, although whether such a write creates a global at all is decided by the host - a
CommonJS file writes its own exports there. Under the model where it creates nothing, the
binding this mints answers no question differently; the one rewrite that reads such a write
as a property having been created asks for the execution model itself.
"""
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.
Read through `may_name_a_global`, so a receiver a call may supply the global object for is
recorded too. Nothing is created from that answer, so the widest it can be wrong is to keep
a declaration a reader never reaches.
"""
name = self.may_name_a_global(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)
binding.note_reference_from(self._node_scope.get(id(member)))
def _record_arguments_alias_references(self):
"""
Record, against each parameter binding, the references made through an `arguments` object whose
elements alias the parameters, exactly as a reference through a global-object alias is recorded
by `_record_global_alias_member_reference`. The two are the same situation: a binding reached
through an object rather than by its own name, which the identifier walk therefore does not see.
Without this a body that only ever reads `arguments[0]` leaves its first parameter looking
unreferenced, and a remover drops the write whose value that read answers with.
`has_mapped_arguments` decides which functions have such an object at all, so a strict body, an
arrow, and any list holding a default, a rest element or a destructuring pattern contribute
nothing. It is asked of the sloppy case first and the mode only afterwards, which is the same
conjunction it states — no function has such an object in strict mode — asked in the order that
pays for it: the mode is a climb to the root per function, while everything else is local, and
almost no function is a candidate.
Where the object is reached is `walk_receiver_scope`: an arrow reads the enclosing `arguments`
and is descended, a nested function has its own and is not.
An element access is attributed to the parameter it names. The read half of that access is a
definite read of the parameter, and the write half never is: §10.2.11 maps an element onto a
parameter only at a position the call supplied an argument for, so `arguments[0] = 9` writes
the first parameter when the call passed one and creates an ordinary property when it passed
none. Nothing in the text of the function says which, so the write is recorded as an
`indefinite_writes` entry — a kill that names no value — and not as a definition a fold
could answer with. A bare use of the object is asked what its governing construct can do with
it: one that observes identity alone — a `typeof`, a truth test, a `for-in` head — is
recorded as nothing, and one that reads every element and nothing else — a spread, a
`for-of` head — as a read of each parameter. `_observes_identity_alone` and
`_reads_every_element_alone` carry the argument for every admitted position, and an
indefinite write recorded at one of them would refuse every fold in the function for a use
that cannot write anything. Every use those two decline — the object handed to a call, the
object bound to a second name — is recorded as a read of every parameter and an indefinite
write of every one of them: reading is what makes a write to a parameter observable, which
is the fact a remover needs, and the object may reach code that writes any element.
`arguments[i] = v` for an `i` the model cannot read is recorded the same way, since it may
write any single one and recording a definition of each would let a fold answer with a value
only one of them can hold.
The name is resolved rather than matched, because a body may bind `arguments` itself — as a
parameter, a lexical declaration, a `var` given a value, or a catch parameter — and may also
assign over the one it was given. In either case the name denotes something whose elements
alias nothing, so attributing an access to a parameter would credit the parameter with a
write the program never makes. Such a function is left alone entirely rather than up to the
point of the rebinding, because which accesses run before it is a question about flow that a
walk over the text does not answer. `_displaces_arguments` decides it.
A function expression whose own name is `arguments` is not one of those: that name is bound
in an environment the object's own shadows, so the body still reads the mapped object. The
scope model records the two as one binding, which is why the binding's kind is admitted as
well as `ARGUMENTS` here rather than only it.
A name that resolves to nothing is still taken for the object where it stands: resolution
answers `None` for a free name and across a `with`, neither of which is evidence that something
else was bound.
"""
for fn in self.root.walk():
if not isinstance(fn, (JsFunctionExpression, JsFunctionDeclaration)):
continue
if not has_mapped_arguments(fn, strict=False) or strict_mode_at(fn):
continue
own = self.lookup('arguments', self._node_scope.get(id(fn.body)))
if own is None or own.kind not in (BindingKind.ARGUMENTS, BindingKind.FUNC_NAME):
continue
if _displaces_arguments(own, fn):
continue
params = _last_positions([
self.binding_of(param) if isinstance(param, JsIdentifier) else None
for param in fn.params
])
for node in walk_receiver_scope(fn):
if not isinstance(node, JsIdentifier) or node.name != 'arguments':
continue
if not self.is_reference(node):
continue
denotes = self.resolve(node)
if denotes is not None and denotes is not own:
continue
access = _enclosing_member_access(node)
if access is not None and denotes is not None:
named = _aliased_parameter_positions(access, len(params))
if named is not None:
role = reference_role(access)
for index in named:
self._record_alias_reference(params[index], access, role)
continue
if access is None:
governor = enclosing_operator(node)
if _observes_identity_alone(governor, node):
continue
if _reads_every_element_alone(governor, node):
for binding in params:
self._record_alias_reference(binding, node, Role.READ)
continue
site: JsIdentifier | JsMemberExpression = node if access is None else access
may_write = access is None or reference_role(access) is not Role.READ
for binding in params:
self._record_alias_reference(binding, node, Role.READ)
if may_write:
self._record_alias_reference(binding, site, Role.WRITE)
def _record_global_object_alias_references(self):
"""
Record, against every binding a classic script's global object carries, the references a
call may make through the object once it is handed one, but only for a hand-over the callee
could read a property through. `a(globalThis, 'q')` and `a(this, 'q')` both give `a` an
object whose properties are the script's top-level declarations, and a body that writes one
of them writes the declaration — which no identifier in the text names, so the identifier
walk sees nothing. A call that never reads a property of the object it is handed reaches no
declaration through it, and admitting one there freezes every fold in the file for a
reference the program never makes.
`global_object_argument_is_observed` decides, per hand-over, whether the callee could read a
property. An observed hand-over records the same way `_record_arguments_alias_references`
does — a read of every binding, so a declaration reached only through the object is not
removed, and an indefinite write of every one, so a fold does not carry a value across a
write the callee made. Which properties the callee touches is not decided, and every binding
is admitted, for the reason the argument list is admitted whole: a value only some of them
can hold is not a definition of any of them. An unobserved hand-over records nothing, and
the union is taken across hand-overs — a binding stays reachable if any one is observed.
The gate runs in two phases because it reads `singular_value` to resolve a callee, and the
record it is about to make is an indefinite write that would make that query decline. Every
hand-over is judged first, against the model as it stands before this method writes anything,
and only then are the observed ones recorded. This is the order `names_the_global_object`
keeps for the same reason — an answer read out of the walk still recording those very writes
would depend on how far the walk had got, not on what the program says.
The object is recognized by `_holds_the_global_object`, so only the `this` a script's top
level holds is one. A `this` inside a function is the receiver its call supplied, and
admitting it costs every fold in a file that hands one to anything: obfuscator.io's
self-defending wrapper passes its own `this` to a call, and a run that took it for the
global object leaves that sample twenty times its deobfuscated size. `may_be_global_object_base`
admits every `this` for the opposite reason — there the wrong answer only keeps a
declaration alive, and here it freezes the file.
Only an argument is read. A `return` of the object hands it to a caller the text still
shows, and taking that for an escape refuses `refinery.lib.scripts.js.deobfuscation
.globalfinder` the very function whose removal makes the object nameable, leaving the two
obfuscated samples that use a finder at their original size.
"""
bindings = list(self.root_scope.bindings.values())
if not bindings:
return
observed: list[ReferenceNode] = []
for node in self.root.walk():
if not isinstance(node, (JsIdentifier, JsThisExpression)):
continue
if not self._holds_the_global_object(node) or not _is_call_argument(node):
continue
if self.global_object_argument_is_observed(node):
observed.append(node)
for node in observed:
for binding in bindings:
binding.reachable_through_a_handed_object = True
self._record_alias_reference(binding, node, Role.READWRITE)
def global_object_argument_is_observed(self, node: Node) -> bool:
"""
Whether the call *node* is handed to could read a property of the global object *node*
stands for. An unobserved hand-over lets the globals the object carries stay foldable; an
observed one, or one the model cannot resolve, is admitted whole the way it always has been.
The callee is resolved to the function it runs: a function written in place, a name whose
one value is a function, or a name whose one value is the zero-argument IIFE a self-defending
wrapper's factory is, whose single returned function is the one that runs. A callee resolving
to none of these is not read, so its object is observed. A function that reaches its own
`arguments` is observed too, because an element of that object is the handed argument under
another name, which the parameter walk does not follow.
The argument is matched to the parameter it binds by position; a list with a rest, default,
or destructuring element is not matched and its object is observed. An argument past the last
parameter binds nothing the callee can name and is not observed. A parameter reflection can
reach is observed. Otherwise `_parameter_is_observed` asks the body.
"""
call = _enclosing_call(node)
if call is None:
return True
function = self._target_function_of_call(call)
if function is None:
return True
if references_own_arguments(function):
return True
mapping = self._argument_parameter_map(call, function)
if mapping is None:
return True
parameter = next((b for b, argument in mapping.items() if argument is node), None)
if parameter is None:
return False
if self.reflection_can_reach(parameter):
return True
return self._parameter_is_observed(function, parameter, mapping, {id(function)}, 0)
def _target_function_of_call(self, call: JsCallExpression | JsNewExpression) -> JsFunctionNode | None:
"""
The function *call* runs, as far as it resolves without leaving the text: the callee written
as a function, a name whose one value is a function, or a name whose one value is a
zero-argument IIFE returning a single function — the shape the self-defending wrapper's
factory takes. `None` when the callee resolves to none of these.
"""
callee = strip_parens(call.callee)
if isinstance(callee, FUNCTION_NODES):
return callee
if not isinstance(callee, JsIdentifier):
return None
value = self.singular_value(self.resolve(callee))
if value is None:
return None
value = strip_parens(value)
if isinstance(value, FUNCTION_NODES):
return value
if isinstance(value, JsCallExpression) and not value.arguments:
inner = strip_parens(value.callee)
if isinstance(inner, FUNCTION_NODES):
return _sole_returned_function(inner)
return None
def _argument_parameter_map(
self,
call: JsCallExpression | JsNewExpression,
function: JsFunctionNode,
) -> dict[Binding | None, Node | None] | None:
"""
A map from each parameter binding of *function* to the argument *call* supplies for it by
position, or `None` when a parameter is not a plain name — a rest, default, or destructuring
element the model cannot bind by position. A parameter the call gives no argument for maps
to `None`.
"""
if any(not isinstance(parameter, JsIdentifier) for parameter in function.params):
return None
mapping: dict[Binding | None, Node | None] = {}
for index, parameter in enumerate(function.params):
if not isinstance(parameter, JsIdentifier):
continue
argument = strip_parens(call.arguments[index]) if index < len(call.arguments) else None
mapping[self.binding_of(parameter)] = argument
return mapping
def _parameter_is_observed(
self,
function: JsFunctionNode,
parameter: Binding | None,
mapping: dict[Binding | None, Node | None],
visiting: set[int],
depth: int,
) -> bool:
"""
Whether *function*'s body reads a property of the object bound to *parameter*. The parameter
as the base of a member access reads one; the receiver an `apply`/`call` hands to a function
that reads its own `this` does; a receiver handed to a `this`-free function does not, because
that function never reads it. Every other use — returned, aliased, enumerated, handed on as
a plain argument — is taken for an observation. The whole subtree is walked and each
identifier resolved, so a use inside a nested closure that captures the parameter counts, and
a shadowing binding of the same name does not.
"""
if depth > _HANDED_OBJECT_OBSERVATION_DEPTH:
return True
for reference in function.walk():
if not isinstance(reference, JsIdentifier) or not self.is_reference(reference):
continue
if self.resolve(reference) is not parameter:
continue
access = _enclosing_member_access(reference)
if access is not None and strip_parens(access.object) is reference:
return True
if self._apply_receiver_is_safe(reference, mapping, visiting, depth) is not True:
return True
return False
def _apply_receiver_is_safe(
self,
node: Node,
mapping: dict[Binding | None, Node | None],
visiting: set[int],
depth: int,
) -> bool | None:
"""
For a *node* that denotes the handed object: `True` when it is the `thisArg` of an
`apply`/`call` whose target provably does not read its own `this`, so the object is not read
there; `False` when it is such a receiver but the target reads `this` or does not resolve to
a function; and `None` when *node* is not used as such a receiver at all, the case the caller
reads as an observation. The target is resolved through the argument map when it is another
parameter of the same call, and otherwise by its one value.
"""
parent = enclosing_operator(node)
if not isinstance(parent, JsCallExpression):
return None
callee = strip_parens(parent.callee)
if not isinstance(callee, JsMemberExpression):
return None
if _member_property_name(callee) not in ('apply', 'call'):
return None
if not parent.arguments or strip_parens(parent.arguments[0]) is not node:
return None
target: Node | None = strip_parens(callee.object)
if isinstance(target, JsIdentifier):
binding = self.resolve(target)
if binding is None:
target = None
elif binding in mapping:
target = mapping[binding]
else:
value = self.singular_value(binding)
target = strip_parens(value) if value is not None else None
if not isinstance(target, FUNCTION_NODES):
return False
return not self._function_observes_its_this(target, visiting, depth + 1)
def _function_observes_its_this(
self,
function: JsFunctionNode,
visiting: set[int],
depth: int,
) -> bool:
"""
Whether *function* reads the `this` its caller supplies. An arrow has none of its own and
reads the enclosing one, so a receiver handed to it is never read; a regular function that
names `this` anywhere in its own receiver scope, or runs a direct `eval` that could, reads
it. The bound and the *visiting* set take a function that hands `this` on to itself, or a
chain too deep to follow, for a reader.
"""
if depth > _HANDED_OBJECT_OBSERVATION_DEPTH or id(function) in visiting:
return True
if isinstance(function, JsArrowFunctionExpression):
return False
if self._function_has_direct_eval(function):
return True
return any(isinstance(node, JsThisExpression) for node in walk_receiver_scope(function))
def _record_alias_reference(
self,
binding: Binding | None,
node: ReferenceNode,
role: Role,
) -> None:
"""
Record against a binding one reference made through an object that aliases it — a mapped
`arguments` reaching a parameter, or the global object reaching a global. The read half is a
definite read — the access observes whatever the binding holds — while the write half never
is: what an object handed to a call writes through is decided by code the walk does not
read, so it lands in `indefinite_writes` as a kill that names no value rather than in
`writes` as a definition.
"""
if binding is None:
return
if role is not Role.WRITE:
binding.reads.append(node)
if role is not Role.READ:
binding.indefinite_writes.append(node)
binding.note_reference_from(self._node_scope.get(id(node)))
class _ScopeBuilder:
"""
Single-pass scope and binding construction. Bindings are collected when a scope is created
(parameters and hoisted `var`/function names for function scopes, lexical `let`/`const`/`class`
for block scopes); the recursive walk only records which scope each node belongs to.
"""
def __init__(self, model: SemanticModel):
self.model = model
self._lexical_names = LexicalNameCache()
def build(self, root: JsScript) -> Scope:
scope = Scope(kind=ScopeKind.SCRIPT, node=root)
self.model._node_scope[id(root)] = scope
self._hoist(root.body, scope)
self._collect_imports(root.body, scope)
self._collect_lexical(root.body, scope)
for stmt in root.body:
self._visit(stmt, scope)
return scope
def _new_scope(self, kind: ScopeKind, node: Node, parent: Scope) -> Scope:
scope = Scope(kind=kind, node=node, parent=parent)
parent.children.append(scope)
return scope
def _declare(
self, scope: Scope, name: str, kind: BindingKind, decl_id: JsIdentifier | None,
) -> Binding:
binding = scope.bindings.get(name)
if binding is None:
binding = Binding(name=name, kind=kind, scope=scope)
scope.bindings[name] = binding
if decl_id is not None:
binding.declarations.append(decl_id)
self.model._binding_of[id(decl_id)] = binding
return binding
def _hoist(self, stmts: list, func_scope: Scope):
"""
Declare in *func_scope* the names the statements of *stmts* bind with a `var` or with a
function declaration, which is what runs before any of them does.
A function declared inside a block and given a `var` outside it by Annex B is declared here
too. What is different about it is not where the name is but when it holds the function, and
that is `binding_establishment_sites`' answer rather than this one's.
"""
for node in _walk_skipping_functions(stmts):
if isinstance(node, JsVariableDeclaration) and node.kind is JsVarKind.VAR:
for decl in node.declarations:
if isinstance(decl, JsVariableDeclarator):
for ident in pattern_identifiers(decl.id):
self._declare(func_scope, ident.name, BindingKind.VAR, ident)
elif isinstance(node, JsFunctionDeclaration) and node.id is not None:
if annex_b_var_home(node, self._lexical_names) is func_scope.node:
self._declare(func_scope, node.id.name, BindingKind.FUNCTION, node.id)
def _collect_imports(self, stmts: list, scope: Scope):
for stmt in stmts:
if not isinstance(stmt, JsImportDeclaration):
continue
for spec in stmt.specifiers:
local = spec.local
if isinstance(local, JsIdentifier):
self._declare(scope, local.name, BindingKind.IMPORT, local)
def _collect_lexical(self, stmts: list, scope: Scope):
"""
Declare in *scope* what the statements of *stmts* bind lexically: a `let`, a `const`, a
class, and a function no `var` outside the block is created for.
That last one is the whole of what a mode decides about a block-declared function. Strict
code binds it in the block and nowhere else, and so does sloppy code wherever §B.3.3.1 stops
the copy; where the copy runs, the name outside the block is what every reference reads and
`_hoist` has already declared it, so nothing is declared here and the block holds no binding
of its own - which is what makes a read inside the block and one after it read one name, as
they do.
A declaration written under an `export` is read through it: the export names what the
declaration binds and binds nothing of its own.
"""
for stmt in stmts:
stmt = declaration_under_export(stmt)
if isinstance(stmt, JsVariableDeclaration) and stmt.kind in (
JsVarKind.LET, JsVarKind.CONST,
):
kind = BindingKind.LET if stmt.kind is JsVarKind.LET else BindingKind.CONST
for decl in stmt.declarations:
if isinstance(decl, JsVariableDeclarator):
for ident in pattern_identifiers(decl.id):
self._declare(scope, ident.name, kind, ident)
elif isinstance(stmt, JsClassDeclaration) and stmt.id is not None:
self._declare(scope, stmt.id.name, BindingKind.CLASS, stmt.id)
elif isinstance(stmt, JsFunctionDeclaration) and stmt.id is not None:
if annex_b_var_home(stmt, self._lexical_names) is None:
self._declare(scope, stmt.id.name, BindingKind.FUNCTION, stmt.id)
def _visit(self, node: Node, scope: Scope):
self.model._node_scope[id(node)] = scope
if isinstance(node, (
JsFunctionDeclaration, JsFunctionExpression, JsArrowFunctionExpression,
)):
self._visit_function(node, scope)
elif isinstance(node, JsBlockStatement):
self._visit_block(node, scope)
elif isinstance(node, JsForStatement):
self._visit_for(node, scope)
elif isinstance(node, (JsForInStatement, JsForOfStatement)):
self._visit_for_in_of(node, scope)
elif isinstance(node, JsSwitchStatement):
self._visit_switch(node, scope)
elif isinstance(node, JsCatchClause):
self._visit_catch(node, scope)
elif isinstance(node, JsWithStatement):
self._visit_with(node, scope)
elif isinstance(node, (JsClassDeclaration, JsClassExpression)):
self._visit_class(node, scope)
elif isinstance(node, JsStaticBlock):
self._visit_static_block(node, scope)
else:
for child in node.children():
self._visit(child, scope)
def _visit_function(self, node: JsFunctionNode, enclosing: Scope):
"""
Build the scopes of *node*. A function whose parameter list holds no expression gets one
scope for its name, its parameters and its body together, which nothing in such a function
can tell from the three the specification gives it: no parameter runs, so none of them can
read a name, and no reference is made before the body's declarations exist.
A function whose parameter list does hold an expression gets all three, nested the way §10.2
nests them - the name outside the parameters, so a parameter spelling it wins, and the
parameters outside the body, so a default reads what encloses the function rather than what
the body declares. A `var` of a parameter's name is then a second binding, which is what the
entry copy is: the body's name starts out holding the argument, and only the declarator that
follows says anything about what it holds after that.
"""
split = has_parameter_expressions(node)
outer = enclosing
if split and isinstance(node, JsFunctionExpression) and node.id is not None:
outer = self._new_scope(ScopeKind.NAME, node, outer)
self._declare(outer, node.id.name, BindingKind.FUNC_NAME, node.id)
pscope = self._new_scope(ScopeKind.PARAMS, node, outer) if split else None
fscope = self._new_scope(ScopeKind.FUNCTION, node, pscope or outer)
params = pscope or fscope
if pscope is not None:
pscope.function_body = fscope
if outer is not enclosing:
outer.function_body = fscope
is_arrow = isinstance(node, JsArrowFunctionExpression)
if not split and isinstance(node, JsFunctionExpression) and node.id is not None:
self._declare(fscope, node.id.name, BindingKind.FUNC_NAME, node.id)
for param in node.params:
for ident in pattern_identifiers(param):
self._declare(params, ident.name, BindingKind.PARAM, ident)
if not is_arrow:
self._declare(params, 'arguments', BindingKind.ARGUMENTS, None)
body = node.body
if isinstance(body, JsBlockStatement):
self._hoist(body.body, fscope)
self._collect_lexical(body.body, fscope)
self._note_entry_copies(fscope, params)
for param in node.params:
self._visit(param, params)
if isinstance(body, JsBlockStatement):
self.model._node_scope[id(body)] = fscope
for stmt in body.body:
self._visit(stmt, fscope)
elif body is not None:
self._visit(body, fscope)
@staticmethod
def _note_entry_copies(fscope: Scope, params: Scope):
"""
Record, on every body binding repeating a parameter's name, that the call writes it before
any statement runs and says nothing about what it wrote.
The name starts the body holding the argument. A `var` declarator for it is a write that
happens later, so neither the value it installs nor its own position is what the name holds
throughout - and a `var` with no declarator at all installs nothing, leaving the argument
standing. Entering the declarators as writes that name no value is what says both.
"""
if params is fscope:
return
for name, binding in fscope.bindings.items():
if binding.kind is BindingKind.VAR and name in params.bindings:
binding.written_at_entry = True
def _visit_block(self, node: JsBlockStatement, enclosing: Scope):
bscope = self._new_scope(ScopeKind.BLOCK, node, enclosing)
self._collect_lexical(node.body, bscope)
for stmt in node.body:
self._visit(stmt, bscope)
def _visit_for(self, node: JsForStatement, enclosing: Scope):
init = node.init
if isinstance(init, JsVariableDeclaration) and init.kind in (JsVarKind.LET, JsVarKind.CONST):
scope = self._new_scope(ScopeKind.BLOCK, node, enclosing)
self._collect_lexical([init], scope)
else:
scope = enclosing
for part in (node.init, node.test, node.update, node.body):
if part is not None:
self._visit(part, scope)
def _visit_for_in_of(self, node: JsForInStatement | JsForOfStatement, enclosing: Scope):
left = node.left
if isinstance(left, JsVariableDeclaration) and left.kind in (JsVarKind.LET, JsVarKind.CONST):
scope = self._new_scope(ScopeKind.BLOCK, node, enclosing)
self._collect_lexical([left], scope)
else:
scope = enclosing
if node.right is not None:
self._visit(node.right, enclosing)
if left is not None:
self._visit(left, scope)
if node.body is not None:
self._visit(node.body, scope)
def _visit_switch(self, node: JsSwitchStatement, enclosing: Scope):
if node.discriminant is not None:
self._visit(node.discriminant, enclosing)
sscope = self._new_scope(ScopeKind.BLOCK, node, enclosing)
for case in node.cases:
self._collect_lexical(case.body, sscope)
for case in node.cases:
self.model._node_scope[id(case)] = sscope
if case.test is not None:
self._visit(case.test, sscope)
for stmt in case.body:
self._visit(stmt, sscope)
def _visit_catch(self, node: JsCatchClause, enclosing: Scope):
cscope = self._new_scope(ScopeKind.CATCH, node, enclosing)
if node.param is not None:
for ident in pattern_identifiers(node.param):
self._declare(cscope, ident.name, BindingKind.CATCH, ident)
self._visit(node.param, cscope)
if node.body is not None:
self._visit(node.body, cscope)
def _visit_with(self, node: JsWithStatement, enclosing: Scope):
if node.object is not None:
self._visit(node.object, enclosing)
wscope = self._new_scope(ScopeKind.WITH, node, enclosing)
wscope.is_dynamic = True
if node.body is not None:
self._visit(node.body, wscope)
def _visit_class(self, node: JsClassDeclaration | JsClassExpression, enclosing: Scope):
for decorator in node.decorators:
self._visit(decorator, enclosing)
if node.super_class is not None:
self._visit(node.super_class, enclosing)
cscope = self._new_scope(ScopeKind.CLASS, node, enclosing)
if isinstance(node, JsClassExpression) and node.id is not None:
self._declare(cscope, node.id.name, BindingKind.CLASS, node.id)
body = node.body
if body is not None:
self.model._node_scope[id(body)] = cscope
for member in body.body:
self._visit(member, cscope)
def _visit_static_block(self, node: JsStaticBlock, enclosing: Scope):
sscope = self._new_scope(ScopeKind.STATIC_BLOCK, node, enclosing)
self._hoist(node.body, sscope)
self._collect_lexical(node.body, sscope)
for stmt in node.body:
self._visit(stmt, sscope)
def build_semantic_model(root: JsScript) -> SemanticModel:
"""
Build the `SemanticModel` for a parsed script.
"""
return SemanticModel(root)
Global variables
var GUARANTEED_GLOBALS-
Names the ECMAScript specification mandates as properties of the global object and that every mainstream engine exposes unconditionally, so a bare read of one is guaranteed to resolve rather than throw a
ReferenceError. This is an existence allowlist — distinct from the getter-purity and host-presence sets inrefinery.lib.scripts.js.analysis.effects— used to decide whether<global-alias>.namemay be collapsed to the barenamewithout turning the member read'sundefinedinto a throw. It excludes host and alias names (window,self,global,top,frames,console, timers,Buffer, …) that are not universal, andSharedArrayBuffer/Atomics, which a conformant host may withhold outside a cross-origin-isolated context. var ReferenceNode-
What a binding's reference lists hold. A reference is ordinarily the identifier naming the binding; where an object aliases it there is no such identifier, and the node the program reached the object through stands in — a member access for
globalThis.gand forarguments[0], and thethisof a script's top level where the object itself is handed to a call.
Functions
def crosses_dynamic_scope(scope)-
Whether resolving a name from scope outward passes through a dynamically-scoped region.
Expand source code Browse git
def crosses_dynamic_scope(scope: Scope | None) -> bool: """ Whether resolving a name from *scope* outward passes through a dynamically-scoped region. """ while scope is not None: if scope.is_dynamic: return True scope = scope.parent return False def is_use_position(node)-
Whether an identifier occupies a position where it reads or writes a value, as opposed to naming a property, a key, a label, or something across a module boundary.
names_a_propertyanswers for the four positions that name a property; what is added here is the positions that name something else the program cannot refer to — the name a module is re-exported under, a label, either side of an import specifier, and an export specifier that names nothing local. The local half of an export list without afromclause reads the binding it names, which is why an engine refuses to linkexport { a };where nothing declaresa; with the clause the same half names a binding of the module the clause spells and nothing local at all. Where nothing renames, one node fills both halves of the specifier, and that node is the local half and reads. 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, a key, a label, or something across a module boundary. `names_a_property` answers for the four positions that name a property; what is added here is the positions that name something else the program cannot refer to — the name a module is re-exported under, a label, either side of an import specifier, and an export specifier that names nothing local. The local half of an export list without a `from` clause reads the binding it names, which is why an engine refuses to link `export { a };` where nothing declares `a`; with the clause the same half names a binding of the module the clause spells and nothing local at all. Where nothing renames, one node fills both halves of the specifier, and that node is the local half and reads. 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 names_a_property(node): return False if isinstance(p, JsExportAllDeclaration) and p.exported is node: return False if isinstance(p, (JsBreakStatement, JsContinueStatement, JsLabeledStatement)) and p.label is node: return False if isinstance(p, ( JsImportSpecifier, JsImportDefaultSpecifier, JsImportNamespaceSpecifier, )): return False if isinstance(p, JsExportSpecifier): declaration = p.parent return ( p.local is node and isinstance(declaration, JsExportNamedDeclaration) and declaration.source is None ) return True def name_uses_in_scope(names, scope)-
Every use-position identifier within scope (descending into nested functions) whose name is one of names — the shared walk behind the capture check and the reflection dominance gate, which both enumerate the live occurrences of a set of names across a region.
Expand source code Browse git
def name_uses_in_scope(names: set[str], scope: Scope) -> Iterator[JsIdentifier]: """ Every use-position identifier within *scope* (descending into nested functions) whose name is one of *names* — the shared walk behind the capture check and the reflection dominance gate, which both enumerate the live occurrences of a set of names across a region. """ for node in scope.node.walk(): if isinstance(node, JsIdentifier) and node.name in names and is_use_position(node): yield node 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 afor-in/for-ofhead), or a read-and-write (compound assignment,++/--, or adelete, each of which keeps the name live as a read rather than overwriting it outright). The shared_governing_targetclimb 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 the node an object aliasing the binding was reached through — a member access on a global-object alias (globalThis.g,globalThis.g = ...), and the global object itself where a call is handed it — so the def-use pass records each as the read or write it is.Expand source code Browse git
def reference_role(node: ReferenceNode) -> 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 the node an object aliasing the binding was reached through — a member access on a global-object alias (`globalThis.g`, `globalThis.g = ...`), and the global object itself where a call is handed it — so the def-use pass records each 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 def enclosing_operator(node)-
The nearest ancestor of node that is not merely a parenthesization of it — the construct whose operator actually governs node.
Expand source code Browse git
def enclosing_operator(node: Node) -> Node | None: """ The nearest ancestor of *node* that is not merely a parenthesization of it — the construct whose operator actually governs *node*. """ parent = node.parent while isinstance(parent, JsParenthesizedExpression): parent = parent.parent return parent def tolerates_unresolvable(node)-
Whether the operator governing node reads it without demanding that the name resolve, so a free name standing there names nothing and still yields a value rather than throwing.
Expand source code Browse git
def tolerates_unresolvable(node: Node) -> bool: """ Whether the operator governing *node* reads it without demanding that the name resolve, so a free name standing there names nothing and still yields a value rather than throwing. """ return _is_unary_operand(enclosing_operator(node), node, _UNRESOLVABLE_TOLERANT_OPERATORS) def container_reference_role(node)-
Classify how the reference node touches the container value (object or array) its binding holds. A member access based on node is a
MEMBER_READunless the outermost member of the chain it begins is being written — the left of an assignment, the operand of++/--ordelete, or a target of afor-in/for-ofhead or a destructuring pattern — which makes it aMEMBER_WRITE(a write througha.b.c = vmutates the objectaholds), or is invoked as a method (a.m(…), also as a template taga.m), which makes it a...MEMBER_CALLsince the call may mutate the receiver. A plainnode = ...reassignment is aREBIND; anything else — passed as an argument, aliased to another binding, returned, used as an operand or a computed key — is anESCAPE, through which an alias could mutate the container. Parentheses are looked through throughout, so a grouped write or call ((a.b) = v,(a.sort)()) is classified by the operator that applies, not as a bare read. This is the per-reference primitive the EffectModel composes over a binding's whole reference set (with alias-following and callee summaries) to decide container immutability.Expand source code Browse git
def container_reference_role(node: ReferenceNode) -> ContainerRole: """ Classify how the reference *node* touches the container value (object or array) its binding holds. A member access based on *node* is a `MEMBER_READ` unless the outermost member of the chain it begins is being written — the left of an assignment, the operand of `++`/`--` or `delete`, or a target of a `for-in`/`for-of` head or a destructuring pattern — which makes it a `MEMBER_WRITE` (a write through `a.b.c = v` mutates the object `a` holds), or is invoked as a method (`a.m(...)`, also as a template tag `` a.m`...` ``), which makes it a `MEMBER_CALL` since the call may mutate the receiver. A plain `node = ...` reassignment is a `REBIND`; anything else — passed as an argument, aliased to another binding, returned, used as an operand or a computed key — is an `ESCAPE`, through which an alias could mutate the container. Parentheses are looked through throughout, so a grouped write or call (`(a.b) = v`, `(a.sort)()`) is classified by the operator that applies, not as a bare read. This is the per-reference primitive the EffectModel composes over a binding's whole reference set (with alias-following and callee summaries) to decide container immutability. """ parent = enclosing_operator(node) if isinstance(parent, JsMemberExpression) and strip_parens(parent.object) is node: member: Node = parent while True: outer = enclosing_operator(member) if isinstance(outer, JsMemberExpression) and strip_parens(outer.object) is member: member = outer continue break if _is_invocation_of(enclosing_operator(member), member): return ContainerRole.MEMBER_CALL return ContainerRole.MEMBER_WRITE if is_member_write_target(member) else ContainerRole.MEMBER_READ if isinstance(parent, JsAssignmentExpression) and strip_parens(parent.left) is node and parent.operator == '=': return ContainerRole.REBIND return ContainerRole.ESCAPE def is_invocation_target(node)-
Whether node is the callee a call invokes or the tag a tagged template applies —
node(…)ornode— looking through parentheses around both node and the operator that governs it. The shared primitive for "is this reference actually being called", replacing the hand-rolled...parent.callee is nodechecks that a parenthesized or tagged callee slips past.A
newis not one of them, because what makes these two positions special is the receiver a call reads off a member and the scope a directevalruns in, and a construction has neither. A caller that asks instead whether the value it is about to write down will be invoked at all wantsis_constructed_or_invoked().Expand source code Browse git
def is_invocation_target(node: Node) -> bool: """ Whether *node* is the callee a call invokes or the tag a tagged template applies — `node(...)` or `` node`...` `` — looking through parentheses around both *node* and the operator that governs it. The shared primitive for "is this reference actually being called", replacing the hand-rolled `parent.callee is node` checks that a parenthesized or tagged callee slips past. A `new` is not one of them, because what makes these two positions special is the receiver a call reads off a member and the scope a direct `eval` runs in, and a construction has neither. A caller that asks instead whether the value it is about to write down will be invoked at all wants `is_constructed_or_invoked`. """ return _is_invocation_of(enclosing_operator(node), node) def is_constructed_or_invoked(node)-
Whether the value node produces is immediately applied — called, tagged, or constructed with
new— looking through parentheses around node and around the operator that governs it.This is the question a fold asks before writing a constant in place of an expression. A constant is not callable and not a constructor, so the application throws either way; but a
TypeErrornames the thing that could not be applied, andnew (3)()reports the3a file never wrote wherenew ('abc'.length)()reported the access it did. Nothing is gained by folding a value into the one position where it can only fail, so the access is left as the file spelled it.Expand source code Browse git
def is_constructed_or_invoked(node: Node) -> bool: """ Whether the value *node* produces is immediately applied — called, tagged, or constructed with `new` — looking through parentheses around *node* and around the operator that governs it. This is the question a fold asks before writing a constant in place of an expression. A constant is not callable and not a constructor, so the application throws either way; but a `TypeError` names the thing that could not be applied, and `new (3)()` reports the `3` a file never wrote where `new ('abc'.length)()` reported the access it did. Nothing is gained by folding a value into the one position where it can only fail, so the access is left as the file spelled it. """ if is_invocation_target(node): return True operator = enclosing_operator(node) return isinstance(operator, JsNewExpression) and strip_parens(operator.callee) is node def is_member_write_target(member)-
Whether the outermost member of a container's access chain is being written rather than read: the left of an assignment, the operand of
++/--ordelete, or a target of afor-in/for-ofhead or a destructuring pattern (including a destructuring default,[a.b = d] = ...). The shared_governing_targetclimb looks through destructuring containers and parentheses ((a.b) = v), so a member nested in a pattern or a grouping is still recognized as a write, mirroringreference_role()and the binding-target climb in the liveness model.Expand source code Browse git
def is_member_write_target(member: Node) -> bool: """ Whether the outermost *member* of a container's access chain is being written rather than read: the left of an assignment, the operand of `++`/`--` or `delete`, or a target of a `for-in`/`for-of` head or a destructuring pattern (including a destructuring default, `[a.b = d] = ...`). The shared `_governing_target` climb looks through destructuring containers and parentheses (`(a.b) = v`), so a member nested in a pattern or a grouping is still recognized as a write, mirroring `reference_role` and the binding-target climb in the liveness model. """ governor, target = _governing_target(member) if isinstance(governor, JsAssignmentExpression): return strip_parens(governor.left) is target if isinstance(governor, JsUpdateExpression): return strip_parens(governor.argument) is target if isinstance(governor, JsUnaryExpression): return governor.operator == 'delete' and strip_parens(governor.operand) is target if isinstance(governor, (JsForInStatement, JsForOfStatement)): return strip_parens(governor.left) is target return False def is_simple_assignment_target(node)-
Whether node is the write-only target of a simple (
=) assignment — the left of=, looking through destructuring patterns, destructuring defaults, and parentheses — but not a compound assignment (+=,++), adelete, or afor-in/for-ofhead, each of which keeps the name live as a read instead of overwriting it outright. Built on the shared_governing_targetclimb, so the pattern, default, and parenthesis handling matches every other write-target query rather than a hand-rolled copy that a later case could drift away from.Expand source code Browse git
def is_simple_assignment_target(node: Node) -> bool: """ Whether *node* is the write-only target of a simple (`=`) assignment — the left of `=`, looking through destructuring patterns, destructuring defaults, and parentheses — but not a compound assignment (`+=`, `++`), a `delete`, or a `for-in`/`for-of` head, each of which keeps the name live as a read instead of overwriting it outright. Built on the shared `_governing_target` climb, so the pattern, default, and parenthesis handling matches every other write-target query rather than a hand-rolled copy that a later case could drift away from. """ governor, target = _governing_target(node) return ( isinstance(governor, JsAssignmentExpression) and governor.operator == '=' and strip_parens(governor.left) is target ) def statement_list_of(node)-
The list of statements node holds, or
Nonewhere it holds none. A script and a block hold theirs directly, a function holds its body's, and aswitchcase holds the statements written under it - the four places a declaration may stand in a list at all.A
switchholds every case's statements as one list, freshly built, because that is what its one scope binds: aletwritten under one case is visible under all of them, so a reader asking what a switch declares has to be given the cases together and not one at a time.Expand source code Browse git
def statement_list_of(node: Node) -> list[Statement] | None: """ The list of statements *node* holds, or `None` where it holds none. A script and a block hold theirs directly, a function holds its body's, and a `switch` case holds the statements written under it - the four places a declaration may stand in a list at all. A `switch` holds every case's statements as one list, freshly built, because that is what its one scope binds: a `let` written under one case is visible under all of them, so a reader asking what a switch declares has to be given the cases together and not one at a time. """ if isinstance(node, JsScript): return node.body if isinstance(node, JsSwitchStatement): return [statement for case in node.cases for statement in case.body] if isinstance(node, JsBlockStatement): return node.body if isinstance(node, JsStaticBlock): return node.body if isinstance(node, JsSwitchCase): return node.body if isinstance(node, FUNCTION_NODES): body = node.body return body.body if isinstance(body, JsBlockStatement) else None return None def is_a_var_home(node)-
Whether node is where a
varand a function declaration written in it are bound: a function, a class static block, or the script. The same setHOIST_BOUNDARYbounds a hoist walk by, asked of one node rather than used to stop one.Expand source code Browse git
def is_a_var_home(node: Node) -> bool: """ Whether *node* is where a `var` and a function declaration written in it are bound: a function, a class static block, or the script. The same set `HOIST_BOUNDARY` bounds a hoist walk by, asked of one node rather than used to stop one. """ return isinstance(node, (JsScript, HOIST_BOUNDARY)) def declaration_under_export(statement)-
The declaration statement is an
exportof, or statement itself where it is not one. An export names what the declaration written under it declares and declares nothing of its own, so a reader asking what a statement list binds reads through it.Expand source code Browse git
def declaration_under_export(statement: Statement | None) -> Statement | None: """ The declaration *statement* is an `export` of, or *statement* itself where it is not one. An export names what the declaration written under it declares and declares nothing of its own, so a reader asking what a statement list binds reads through it. """ while isinstance(statement, (JsExportNamedDeclaration, JsExportDefaultDeclaration)): inner = statement.declaration if not isinstance(inner, Statement): break statement = inner return statement def statement_list_holding(node)-
The nearest node holding node in a statement list, looking through the labels and the
exporta statement may be written under, and through theswitchcase a statement stands in. A labelled declaration stands in the list its label stands in - Annex B reads a label as transparent at every level, and so does the placement of avar- an exported one stands where the export stands, and every case of aswitchstands in the one list its scope binds.Expand source code Browse git
def statement_list_holding(node: Node) -> Node | None: """ The nearest node holding *node* in a statement list, looking through the labels and the `export` a statement may be written under, and through the `switch` case a statement stands in. A labelled declaration stands in the list its label stands in - Annex B reads a label as transparent at every level, and so does the placement of a `var` - an exported one stands where the export stands, and every case of a `switch` stands in the one list its scope binds. """ cursor: Node = node parent: Node | None = cursor.parent while parent is not None and _writes_the_statement_of(parent, cursor): cursor, parent = parent, parent.parent if isinstance(parent, JsSwitchCase): cursor, parent = parent, parent.parent if parent is None: return None return parent if statement_list_of(parent) is not None else None def lexically_declared_names(statements)-
The names statements declares with a
let, aconstor a class directly, read through the labels and theexporta declaration may be written under.Expand source code Browse git
def lexically_declared_names(statements: list[Statement]) -> frozenset[str]: """ The names *statements* declares with a `let`, a `const` or a class directly, read through the labels and the `export` a declaration may be written under. """ names: set[str] = set() for statement in statements: statement = declaration_under_export(statement) while isinstance(statement, JsLabeledStatement): statement = declaration_under_export(statement.body) if isinstance(statement, JsVariableDeclaration) and statement.kind in ( JsVarKind.LET, JsVarKind.CONST, ): for declarator in statement.declarations: if isinstance(declarator, JsVariableDeclarator): names.update(ident.name for ident in pattern_identifiers(declarator.id)) elif isinstance(statement, JsClassDeclaration) and statement.id is not None: names.add(statement.id.name) return frozenset(names) def annex_b_var_home(declaration, cache=None)-
The function, script or static block whose
varnames the function declaration declares, orNonewhere nothing outside the block holding it ever does.A declaration written directly in such a body names something there and is answered with that body. A declaration written inside a block is a lexical binding of that block, and only Annex B.3.3 puts the name outside it: the enclosing body gets a
varof the name, which the copy the declaration makes writes to when it runs. That happens in sloppy code only, and §B.3.3.1 names three things that stop it:- a
let,constorclassof the same name between the block and the body, the body's own list included, which thevarwould conflict with; - a parameter of the function, which already binds the name;
- the name
arguments, whose binding the function already has, which is a condition about a function and not about a script or a static block, neither of which has one.
A catch parameter is not one of them, and the mismatch is deliberate: a simple catch parameter does not stop the copy, so the enclosing name still ends up holding the function, while a destructuring one does, being a lexical declaration the
varwould conflict with. Both were read from an engine rather than from the text of the specification.cache holds the lexical names of the lists the climb passes, for a caller asking this of many declarations over a tree it is not rewriting.
Expand source code Browse git
def annex_b_var_home( declaration: JsFunctionDeclaration, cache: LexicalNameCache | None = None, ) -> Node | None: """ The function, script or static block whose `var` names the function *declaration* declares, or `None` where nothing outside the block holding it ever does. A declaration written directly in such a body names something there and is answered with that body. A declaration written inside a block is a lexical binding of that block, and only Annex B.3.3 puts the name outside it: the enclosing body gets a `var` of the name, which the copy the declaration makes writes to when it runs. That happens in sloppy code only, and §B.3.3.1 names three things that stop it: - a `let`, `const` or `class` of the same name between the block and the body, the body's own list included, which the `var` would conflict with; - a parameter of the function, which already binds the name; - the name `arguments`, whose binding the function already has, which is a condition about a function and not about a script or a static block, neither of which has one. A catch parameter is not one of them, and the mismatch is deliberate: a simple catch parameter does not stop the copy, so the enclosing name still ends up holding the function, while a destructuring one does, being a lexical declaration the `var` would conflict with. Both were read from an engine rather than from the text of the specification. *cache* holds the lexical names of the lists the climb passes, for a caller asking this of many declarations over a tree it is not rewriting. """ cache = cache if cache is not None else LexicalNameCache() home = statement_list_holding(declaration) if home is not None and is_a_var_home(home): return home if strict_mode_at(declaration): return None name = declaration.id.name if declaration.id is not None else None if name is None: return None cursor: Node | None = declaration.parent while cursor is not None: if cache.declares(cursor, name): return None if ( isinstance(cursor, JsCatchClause) and cursor.param is not None and not isinstance(cursor.param, JsIdentifier) and any(ident.name == name for ident in pattern_identifiers(cursor.param)) ): return None if is_a_var_home(cursor): if ( name == 'arguments' and isinstance(cursor, FUNCTION_NODES) and not isinstance(cursor, JsArrowFunctionExpression) ): return None if any( ident.name == name for param in getattr(cursor, 'params', ()) for ident in pattern_identifiers(param) ): return None return cursor cursor = cursor.parent return None - a
def annex_b_copies_into(binding)-
Whether binding holds a function Annex B copies into its scope rather than declares there.
The difference the copy makes is one of time. A function declared in the scope it names holds its value before any statement of that scope runs, so nothing has to be ordered against it; one Annex B copies holds it only from the point the declaration is reached, so a read before that point - or in a run in which the block is never entered - finds whatever was there instead.
binding_establishment_sitesis where that is answered, by naming the declaration as the node the value waits on rather than by answering with the empty list a hoisted value gets.Spelled over the scope the binding is in rather than over its variable scope, because a block function that is not copied is declared in the block it stands in, and that one is a plain lexical binding: it is initialized before any statement of the block runs and the declaration is exactly what it holds.
Expand source code Browse git
def annex_b_copies_into(binding: Binding) -> bool: """ Whether *binding* holds a function Annex B copies into its scope rather than declares there. The difference the copy makes is one of time. A function declared in the scope it names holds its value before any statement of that scope runs, so nothing has to be ordered against it; one Annex B copies holds it only from the point the declaration is reached, so a read before that point - or in a run in which the block is never entered - finds whatever was there instead. `binding_establishment_sites` is where that is answered, by naming the declaration as the node the value waits on rather than by answering with the empty list a hoisted value gets. Spelled over the scope the binding is in rather than over its variable scope, because a block function that is *not* copied is declared in the block it stands in, and that one is a plain lexical binding: it is initialized before any statement of the block runs and the declaration is exactly what it holds. """ if binding.kind is not BindingKind.FUNCTION or len(binding.declarations) != 1: return False declaration = binding.declarations[0].parent if not isinstance(declaration, JsFunctionDeclaration): return False return statement_list_holding(declaration) is not _statement_list_holder_of(binding.scope) def enclosing_function(node)-
The nearest function node — declaration, expression, or arrow — that lexically encloses node, or
Nonewhen node sits at the top level below no function.Expand source code Browse git
def enclosing_function(node: Node) -> Node | None: """ The nearest function node — declaration, expression, or arrow — that lexically encloses *node*, or `None` when *node* sits at the top level below no function. """ cursor = node.parent while cursor is not None: if isinstance(cursor, FUNCTION_NODES): return cursor cursor = cursor.parent return None def walk_receiver_scope(root)-
Yield every node in the subtree at root that shares root's
this/superreceiver, without descending into a nested regular or generator function, which rebindsthis. Arrow functions are descended, since they inherit the receiver lexically. A class rebindsthisfor its method bodies and field initializers, but itsextendsclause and any computed member keys are evaluated in the enclosing receiver context, so only those parts of a class are descended. root itself is always yielded and descended, so a method reached directly through root is included.The receiver scope is also the argument scope: an arrow has no
argumentsobject of its own and reads the enclosing one, a nested function has its own. Soreferences_own_arguments()asks the same boundary, and both live here rather than beside either caller.Expand source code Browse git
def walk_receiver_scope(root: Node) -> Iterator[Node]: """ Yield every node in the subtree at *root* that shares *root*'s `this`/`super` receiver, without descending into a nested regular or generator function, which rebinds `this`. Arrow functions are descended, since they inherit the receiver lexically. A class rebinds `this` for its method bodies and field initializers, but its `extends` clause and any computed member keys are evaluated in the enclosing receiver context, so only those parts of a class are descended. *root* itself is always yielded and descended, so a method reached directly through *root* is included. The receiver scope is also the *argument* scope: an arrow has no `arguments` object of its own and reads the enclosing one, a nested function has its own. So `references_own_arguments` asks the same boundary, and both live here rather than beside either caller. """ stack: list[Node] = [root] while stack: node = stack.pop() yield node if isinstance(node, (JsFunctionExpression, JsFunctionDeclaration)) and node is not root: continue if isinstance(node, (JsClassDeclaration, JsClassExpression)): if node.super_class is not None: stack.append(node.super_class) if node.body is not None: for member in node.body.body: if isinstance(member, JsStaticBlock): continue if member.computed and member.key is not None: stack.append(member.key) continue stack.extend(node.children()) def is_the_this_of_a_script(node)-
Whether node is a
thisdenoting what the top level of a classic script does, which is the global object: the position it stands in is reached from the top of the file without crossing anything that makes a receiver of its own.Written as a climb rather than as a walk from the root so that it answers for a node whose tree is being rewritten: a climb that runs out of parents has reached a root and answers
True, so a node lifted out of the tree reads as the top level, which is the direction that keeps a declaration rather than removing one.An arrow crosses nothing - it has no
thisof its own - so athisinside any number of them is still the top level's, and so is one in theextendsclause or a computed key of a class, which are evaluated where the class is written. A method, an accessor, a field initializer and a static block each hold one of their own. A decorator is not answered for, because no engine this is checked against parses one.Expand source code Browse git
def is_the_this_of_a_script(node: Node) -> bool: """ Whether *node* is a `this` denoting what the top level of a classic script does, which is the global object: the position it stands in is reached from the top of the file without crossing anything that makes a receiver of its own. Written as a climb rather than as a walk from the root so that it answers for a node whose tree is being rewritten: a climb that runs out of parents has reached a root and answers `True`, so a node lifted out of the tree reads as the top level, which is the direction that keeps a declaration rather than removing one. An arrow crosses nothing - it has no `this` of its own - so a `this` inside any number of them is still the top level's, and so is one in the `extends` clause or a computed key of a class, which are evaluated where the class is written. A method, an accessor, a field initializer and a static block each hold one of their own. A decorator is not answered for, because no engine this is checked against parses one. """ if not isinstance(node, JsThisExpression): return False child: Node = node cursor = node.parent while cursor is not None: if not _shares_the_receiver_of(cursor, child): return False child, cursor = cursor, cursor.parent return True def references_own_arguments(fn)-
Whether fn reads its own
argumentsobject, rather than one belonging to a function around it or inside it.walk_receiver_scope()draws that boundary: an arrow inherits the enclosingargumentsand is descended, a nested regular or generator function has its own and is not.Expand source code Browse git
def references_own_arguments(fn: Node) -> bool: """ Whether *fn* reads its own `arguments` object, rather than one belonging to a function around it or inside it. `walk_receiver_scope` draws that boundary: an arrow inherits the enclosing `arguments` and is descended, a nested regular or generator function has its own and is not. """ return any( isinstance(node, JsIdentifier) and node.name == 'arguments' and is_use_position(node) for node in walk_receiver_scope(fn) ) def has_mapped_arguments(fn, *, strict)-
Whether fn observes an
argumentsobject whose elements alias its parameters. Aliasing holds for a regular or generator function, in sloppy mode, with a simple parameter list: writing a parameter then writesarguments[i], and writingarguments[i]writes the parameter. Strict mode gives an independent copy, and so does any default, rest or destructuring parameter; an arrow has noargumentsof its own at all.strict is the mode fn's body runs in and is supplied by the caller rather than derived here, because a payload being examined out of place has no tree above it to derive it from, and because module code is strict for a reason no node records.
The parameter list must also be non-empty:
has_simple_parametersanswers §15.1.3, which an empty list satisfies, but with no parameters there is nothing for an element to alias.Expand source code Browse git
def has_mapped_arguments(fn: Node, *, strict: bool) -> bool: """ Whether *fn* observes an `arguments` object whose elements alias its parameters. Aliasing holds for a regular or generator function, in sloppy mode, with a simple parameter list: writing a parameter then writes `arguments[i]`, and writing `arguments[i]` writes the parameter. Strict mode gives an independent copy, and so does any default, rest or destructuring parameter; an arrow has no `arguments` of its own at all. *strict* is the mode *fn*'s body runs in and is supplied by the caller rather than derived here, because a payload being examined out of place has no tree above it to derive it from, and because module code is strict for a reason no node records. The parameter list must also be non-empty: `has_simple_parameters` answers §15.1.3, which an empty list satisfies, but with no parameters there is nothing for an element to alias. """ if strict or not isinstance(fn, (JsFunctionExpression, JsFunctionDeclaration)): return False if not fn.params or not has_simple_parameters(fn): return False return references_own_arguments(fn) def is_global_object_base(node)-
Whether node, written as the base of a member access, denotes the global object - so that a property named on it is a global, and a property named on it at run time could be any global.
Only the spelling is read. Parentheses around the base are looked through, because they change what a base is written as and nothing about what it denotes:
(window).evalobtains the same intrinsicwindow.evaldoes, and a reader that saw the two differently would refuse a fold for one of them and not the other.Whether the name is bound to something else is a separate question, and the two callers want opposite answers to it, which is why it is not asked here.
SemanticModel.global_alias_member_name()asks it, because a reference it records has to be a reference to the global it names, and a localwindownames an ordinary object._is_reflective_memberdeliberately does not: a surface it reports where there is none only refuses a fold, and one it misses removes code that runs.Expand source code Browse git
def is_global_object_base(node: Node | None) -> bool: """ Whether *node*, written as the base of a member access, denotes the global object - so that a property named on it is a global, and a property named on it at run time could be any global. Only the spelling is read. Parentheses around the base are looked through, because they change what a base is written as and nothing about what it denotes: `(window).eval` obtains the same intrinsic `window.eval` does, and a reader that saw the two differently would refuse a fold for one of them and not the other. Whether the name is bound to something else is a separate question, and the two callers want opposite answers to it, which is why it is not asked here. `SemanticModel.global_alias_member_name` asks it, because a reference it records has to be a reference to the global it names, and a local `window` names an ordinary object. `_is_reflective_member` deliberately does not: a surface it reports where there is none only refuses a fold, and one it misses removes code that runs. """ base = strip_parens(node) if node is not None else None if base is None: return False if isinstance(base, JsThisExpression): return is_the_this_of_a_script(base) return isinstance(base, JsIdentifier) and base.name in GLOBAL_OBJECT_ALIASES def may_be_global_object_base(node)-
Whether node, written as the base of a member access, may denote the global object once the program runs.
is_global_object_base()widened by the receiver a call supplies: a function called with no receiver is givenundefined, and sloppy code replaces that with the global object before the body runs, so athisin such a body reads the same properties the top level does.Which calls reach a body is not decided here, and every
thisis admitted rather than only the ones a bare call can reach. The two directions cost different things: admitting a receiver that is some other object keeps a declaration a reader may never reach, while missing one removes a declaration the program still reads.The readers that decide a rewrite keep the narrow question.
_is_reflective_memberdoes because athisreaches every method of every object in a file, and reporting a surface for one freezes every removal in it;_timer_callee_namebecause it names a callee it will act on.Expand source code Browse git
def may_be_global_object_base(node: Node | None) -> bool: """ Whether *node*, written as the base of a member access, may denote the global object once the program runs. `is_global_object_base` widened by the receiver a call supplies: a function called with no receiver is given `undefined`, and sloppy code replaces that with the global object before the body runs, so a `this` in such a body reads the same properties the top level does. Which calls reach a body is not decided here, and every `this` is admitted rather than only the ones a bare call can reach. The two directions cost different things: admitting a receiver that is some other object keeps a declaration a reader may never reach, while missing one removes a declaration the program still reads. The readers that decide a *rewrite* keep the narrow question. `_is_reflective_member` does because a `this` reaches every method of every object in a file, and reporting a surface for one freezes every removal in it; `_timer_callee_name` because it names a callee it will act on. """ base = strip_parens(node) if node is not None else None if isinstance(base, JsThisExpression): return True return is_global_object_base(base) def is_direct_eval_call(node)-
Whether node is a direct
evalcall — a call whose callee, once parentheses are stripped, is the bare identifiereval. Parentheses are transparent to the reference, so(eval)(…)is a direct eval exactly aseval(…); a callee that instead only yields the function as a value — the comma sequence(0, eval)(…)that strips to a sequence expression, or a membero.eval(…)— is indirect, runs in the global scope, and is excluded. Direct eval is the one reflective surface that runs in the caller's own scope and can therefore name its locals; the excluded indirect forms name only globals, andhas_reflection_surfaceaccounts for them whole-program.Expand source code Browse git
def is_direct_eval_call(node: Node) -> bool: """ Whether *node* is a direct `eval` call — a call whose callee, once parentheses are stripped, is the bare identifier `eval`. Parentheses are transparent to the reference, so `(eval)(...)` is a direct eval exactly as `eval(...)`; a callee that instead only yields the function as a value — the comma sequence `(0, eval)(...)` that strips to a sequence expression, or a member `o.eval(...)` — is indirect, runs in the global scope, and is excluded. Direct eval is the one reflective surface that runs in the caller's own scope and can therefore name its locals; the excluded indirect forms name only globals, and `has_reflection_surface` accounts for them whole-program. """ if not isinstance(node, JsCallExpression): return False callee = strip_parens(node.callee) return isinstance(callee, JsIdentifier) and callee.name == 'eval' def build_semantic_model(root)-
Build the
SemanticModelfor 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)
Classes
class ScopeKind (*args, **kwds)-
Create a collection of name/value pairs.
Example enumeration:
>>> class Color(Enum): ... RED = 1 ... BLUE = 2 ... GREEN = 3Access 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 NAME = 'name' # noqa the own name of a named function expression PARAMS = 'params' # noqa a parameter list holding an expression BLOCK = 'block' # noqa CATCH = 'catch' # noqa CLASS = 'class' # noqa WITH = 'with' # noqa STATIC_BLOCK = 'static-block' # noqaAncestors
- enum.Enum
Class variables
var SCRIPT-
The type of the None singleton.
var FUNCTION-
The type of the None singleton.
var NAME-
The type of the None singleton.
var PARAMS-
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 BindingKind (*args, **kwds)-
Create a collection of name/value pairs.
Example enumeration:
>>> class Color(Enum): ... RED = 1 ... BLUE = 2 ... GREEN = 3Access 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 declaredAncestors
- 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 Role (*args, **kwds)-
Create a collection of name/value pairs.
Example enumeration:
>>> class Color(Enum): ... RED = 1 ... BLUE = 2 ... GREEN = 3Access 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' # noqaAncestors
- 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 ContainerRole (*args, **kwds)-
How a reference touches the container value (object or array) its binding holds — a finer distinction than
Role, which describes how a reference touches the binding itself.obj.k = vreads the bindingobj(soreference_role()reportsREAD) yet writes the container it holds, so here it is aMEMBER_WRITE.Expand source code Browse git
class ContainerRole(enum.Enum): """ How a reference touches the container value (object or array) its binding holds — a finer distinction than `Role`, which describes how a reference touches the *binding* itself. `obj.k = v` reads the binding `obj` (so `reference_role` reports `READ`) yet writes the container it holds, so here it is a `MEMBER_WRITE`. """ MEMBER_READ = 'member_read' # noqa read through the container: `obj.k`, `obj[i]` MEMBER_WRITE = 'member_write' # noqa write through it: `obj.k = v`, `obj[i]++`, `delete obj[i]` MEMBER_CALL = 'member_call' # noqa method invoked on it: `obj.m(...)`, which may mutate it REBIND = 'rebind' # noqa plain reassignment of the name: `obj = ...` ESCAPE = 'escape' # noqa any other use, through which the container could be aliasedAncestors
- enum.Enum
Class variables
var MEMBER_READ-
The type of the None singleton.
var MEMBER_WRITE-
The type of the None singleton.
var MEMBER_CALL-
The type of the None singleton.
var REBIND-
The type of the None singleton.
var ESCAPE-
The type of the None singleton.
class Binding (name, kind, scope, declarations=<factory>, reads=<factory>, writes=<factory>, dynamic_refs=<factory>, indefinite_writes=<factory>, captured=False, written_at_entry=False, reachable_through_a_handed_object=False, exported=False)-
A single declared name within one scope.
declarationsholds the binding-site identifier nodes that introduce the name;readsandwriteshold the referencing identifiers that read and write it (a compound assignment or update appears in both).capturedis set when the name is referenced from a function nested below the one that owns it. A read or write performed through an object that aliases the binding has no referencing identifier for the name it targets, so theJsMemberExpressionstands in for that reference; every otherreads/writesentry is an identifier. Two objects alias this way — a global-object alias (globalThis.g) reaching a global, and a mappedargumentsreaching a parameter — and they are told apart by what the access is on, never by the entry being a member access at all.dynamic_refsholds referencing identifiers a dynamic scope resolves at runtime — a name inside awithbody that could denote this binding — whichreads/writesomit 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 an object that aliases the binding has no referencing identifier for the name it targets, so the `JsMemberExpression` stands in for that reference; every other `reads`/`writes` entry is an identifier. Two objects alias this way — a global-object alias (`globalThis.g`) reaching a global, and a mapped `arguments` reaching a parameter — and they are told apart by what the access is on, never by the entry being a member access at all. `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[ReferenceNode] = field(default_factory=list) writes: list[ReferenceNode] = field(default_factory=list) dynamic_refs: list[JsIdentifier] = field(default_factory=list) indefinite_writes: list[ReferenceNode] = field(default_factory=list) captured: bool = False #: Whether the call writes this binding before any statement of its scope runs. A `var` of a #: parameter's name is the one shape that does: the body's name starts out holding the argument, #: and only a declarator that runs later says anything about what it holds after that. There is #: no node for that write - the call makes it, not anything in the text - so it can be neither a #: `writes` nor an `indefinite_writes` entry, both of which every consumer orders by position. written_at_entry: bool = False #: Whether the program hands a call the object that carries this binding, so a body no reading of #: the text follows can name it. The references such a call may make are recorded like any other, #: and this says the one thing they cannot: that a walk which finds a name only where the text #: spells it is looking at less than the whole program. Only a global is ever carried this way. reachable_through_a_handed_object: bool = False #: Whether the binding is exported, so an importer observes its value across the module boundary #: after the module runs. Like the two flags above, it names an observer no reading of the text #: reaches: its declaration must be kept and never relocated out of module scope, and a write to #: it is never a dead store, because the final value is read from outside. `export var a`, #: `export function`/`class`, and the local half of a sourceless `export { a }` all set it; a #: `from`-clause list and a re-export name a binding of another module and set nothing here. exported: bool = False def note_reference_from(self, scope: Scope | None) -> None: """ Mark this binding captured where *scope* is on the far side of a closure boundary from the scope declaring it, which is what a reference made from a scope with a different variable scope is. A reference whose own scope is not known is counted as a capture, since nothing about it says that it is not one. Three walks record a reference and each of them asks this: the identifier walk, the one reading a binding through an alias of the global object, and the one reading a parameter through a mapped `arguments`. They have to agree, and one of them being written differently from the others is not a difference anything downstream could act on. """ if scope is None or scope.closure_home is not self.scope.closure_home: self.captured = True @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, named inside no dynamic scope, and not exported. 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 — nor is an exported one, whose value an importer reads across the module boundary, so removers need not rely on a separate reflection gate to keep such a binding. """ return not self.reads and not self.dynamic_refs and not self.exported @property def has_indefinite_write(self) -> bool: """ Whether some access writes the binding at a point where what it stores, or whether it stores at all, is decided only at run time, so that its value stops holding there and no definition says what replaced it. Every write through a mapped `arguments` object is one. `arguments[k] = v` for a `k` no reading of the text computes writes exactly one parameter of the function and which one is not decidable, so it is a kill of each with a value for none; `arguments[0] = v` names its parameter but still lands only where the call supplied that argument (§10.2.11 maps an element onto a parameter only for a position `index < len`), so it is a kill of that one with a value for none. The object handed to a call or bound to a second name is another, and the entry is then the identifier the object escaped through rather than an access on it. It is kept apart from `writes` for the same reason `dynamic_refs` is kept apart: a `writes` entry is a definition, and a consumer reading one expects to find the value it stored. Recording this as a definition of every parameter would let a fold answer with a value only one of them can hold; recording it nowhere lets a fold carry a value across it that the write destroyed. The write a call makes on entry is one of these too, and it is the one with no node at all, so it is carried by `written_at_entry` and read here beside the rest. """ return bool(self.indefinite_writes) or self.written_at_entry @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. The access is tested by what it is on and not by its being a member access, because a parameter of a sloppy function carries member-access writes too — through the `arguments` object that aliases it — and those reach one function's own parameter rather than the global object. """ return any(_is_global_alias_access(write) 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. As with `has_global_member_write`, an access through a mapped `arguments` object is not one of these: it reaches a parameter, which no other function can name. """ return any(_is_global_alias_access(ref) 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 indefinite_writes-
The type of the None singleton.
var captured-
The type of the None singleton.
var written_at_entry-
Whether the call writes this binding before any statement of its scope runs. A
varof a parameter's name is the one shape that does: the body's name starts out holding the argument, and only a declarator that runs later says anything about what it holds after that. There is no node for that write - the call makes it, not anything in the text - so it can be neither awritesnor anindefinite_writesentry, both of which every consumer orders by position. var reachable_through_a_handed_object-
Whether the program hands a call the object that carries this binding, so a body no reading of the text follows can name it. The references such a call may make are recorded like any other, and this says the one thing they cannot: that a walk which finds a name only where the text spells it is looking at less than the whole program. Only a global is ever carried this way.
var exported-
Whether the binding is exported, so an importer observes its value across the module boundary after the module runs. Like the two flags above, it names an observer no reading of the text reaches: its declaration must be kept and never relocated out of module scope, and a write to it is never a dead store, because the final value is read from outside.
export var a,export function/class, and the local half of a sourcelessexport { a }all set it; afrom-clause list and a re-export name a binding of another module and set nothing here. 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
varor a function declaration — and so is visible (asundefined, 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, orclass. 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, named inside no dynamic scope, and not exported. Definitions of a dead binding can be removed if they carry no other side effect (which the caller decides). A name a
withbody could read is not dead even thoughreadsis empty — the dynamic reference may observe it at runtime — nor is an exported one, whose value an importer reads across the module boundary, 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, named inside no dynamic scope, and not exported. 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 — nor is an exported one, whose value an importer reads across the module boundary, so removers need not rely on a separate reflection gate to keep such a binding. """ return not self.reads and not self.dynamic_refs and not self.exported var has_indefinite_write-
Whether some access writes the binding at a point where what it stores, or whether it stores at all, is decided only at run time, so that its value stops holding there and no definition says what replaced it. Every write through a mapped
argumentsobject is one.arguments[k] = v<code> for a </code>kno reading of the text computes writes exactly one parameter of the function and which one is not decidable, so it is a kill of each with a value for none;arguments[0] = vnames its parameter but still lands only where the call supplied that argument (§10.2.11 maps an element onto a parameter only for a positionindex < len), so it is a kill of that one with a value for none. The object handed to a call or bound to a second name is another, and the entry is then the identifier the object escaped through rather than an access on it.It is kept apart from
writesfor the same reasondynamic_refsis kept apart: awritesentry is a definition, and a consumer reading one expects to find the value it stored. Recording this as a definition of every parameter would let a fold answer with a value only one of them can hold; recording it nowhere lets a fold carry a value across it that the write destroyed.The write a call makes on entry is one of these too, and it is the one with no node at all, so it is carried by
written_at_entryand read here beside the rest.Expand source code Browse git
@property def has_indefinite_write(self) -> bool: """ Whether some access writes the binding at a point where what it stores, or whether it stores at all, is decided only at run time, so that its value stops holding there and no definition says what replaced it. Every write through a mapped `arguments` object is one. `arguments[k] = v` for a `k` no reading of the text computes writes exactly one parameter of the function and which one is not decidable, so it is a kill of each with a value for none; `arguments[0] = v` names its parameter but still lands only where the call supplied that argument (§10.2.11 maps an element onto a parameter only for a position `index < len`), so it is a kill of that one with a value for none. The object handed to a call or bound to a second name is another, and the entry is then the identifier the object escaped through rather than an access on it. It is kept apart from `writes` for the same reason `dynamic_refs` is kept apart: a `writes` entry is a definition, and a consumer reading one expects to find the value it stored. Recording this as a definition of every parameter would let a fold answer with a value only one of them can hold; recording it nowhere lets a fold carry a value across it that the write destroyed. The write a call makes on entry is one of these too, and it is the one with no node at all, so it is carried by `written_at_entry` and read here beside the rest. """ return bool(self.indefinite_writes) or self.written_at_entry var has_global_member_write-
Whether the binding is written through a member access on a global-object alias (
globalThis.x = ...), recorded as aJsMemberExpressionwrite 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.The access is tested by what it is on and not by its being a member access, because a parameter of a sloppy function carries member-access writes too — through the
argumentsobject that aliases it — and those reach one function's own parameter rather than the global object.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. The access is tested by what it is on and not by its being a member access, because a parameter of a sloppy function carries member-access writes too — through the `arguments` object that aliases it — and those reach one function's own parameter rather than the global object. """ return any(_is_global_alias_access(write) 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 aJsMemberExpressionreference 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.As with
has_global_member_write, an access through a mappedargumentsobject is not one of these: it reaches a parameter, which no other function can name.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. As with `has_global_member_write`, an access through a mapped `arguments` object is not one of these: it reaches a parameter, which no other function can name. """ return any(_is_global_alias_access(ref) for ref in (*self.reads, *self.writes))
Methods
def note_reference_from(self, scope)-
Mark this binding captured where scope is on the far side of a closure boundary from the scope declaring it, which is what a reference made from a scope with a different variable scope is. A reference whose own scope is not known is counted as a capture, since nothing about it says that it is not one.
Three walks record a reference and each of them asks this: the identifier walk, the one reading a binding through an alias of the global object, and the one reading a parameter through a mapped
arguments. They have to agree, and one of them being written differently from the others is not a difference anything downstream could act on.Expand source code Browse git
def note_reference_from(self, scope: Scope | None) -> None: """ Mark this binding captured where *scope* is on the far side of a closure boundary from the scope declaring it, which is what a reference made from a scope with a different variable scope is. A reference whose own scope is not known is counted as a capture, since nothing about it says that it is not one. Three walks record a reference and each of them asks this: the identifier walk, the one reading a binding through an alias of the global object, and the one reading a parameter through a mapped `arguments`. They have to agree, and one of them being written differently from the others is not a difference anything downstream could act on. """ if scope is None or scope.closure_home is not self.scope.closure_home: self.captured = True
class Scope (kind, node, parent=None, children=<factory>, bindings=<factory>, is_dynamic=False, function_body=None)-
A lexical scope.
nodeis the AST node that introduces it (the script, a function, a block, a catch clause, a class, or awith).is_dynamicmarks awithbody, whose bindings cannot be resolved statically because the object supplies them at run time.A direct
evalis not marked here even though it too can inject a name. It would have to mark the whole enclosing function, which would make every name in a function containing one unresolvable, where what anevalactually does is narrower and is answered by the two queries written for it:local_reachable_by_direct_evalfor a binding that already exists, andfree_name_reachable_by_direct_evalfor one theevalmay have declared.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 `with` body, whose bindings cannot be resolved statically because the object supplies them at run time. A direct `eval` is not marked here even though it too can inject a name. It would have to mark the whole enclosing function, which would make every name in a function containing one unresolvable, where what an `eval` actually does is narrower and is answered by the two queries written for it: `local_reachable_by_direct_eval` for a binding that already exists, and `free_name_reachable_by_direct_eval` for one the `eval` may have declared. """ 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 #: For one of the two scopes a function introduces around its body - the one holding its own #: name and the one holding its parameters - the scope holding that body. It is what says the #: three are one call rather than three, which `closure_home` reads and nothing else does. function_body: Scope | None = None @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 or self.kind is ScopeKind.PARAMS ) @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 @property def closure_home(self) -> Scope | None: """ The scope that decides whether a reference made from this one crosses a closure boundary: a name read from a scope with a different one is read by a function other than the one that declares it, and is a capture. This is the variable scope for every scope but the two a function introduces around its body. A parameter default and the body it belongs to are run by one call and share every binding either of them makes, and so does the name a function expression answers to inside itself, so no closure boundary runs between the three: all of them answer the body's scope. """ if self.function_body is not None: return self.function_body home = self.var_scope if home is not None and home.function_body is not None: return home.function_body return home 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 FalseInstance 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 function_body-
For one of the two scopes a function introduces around its body - the one holding its own name and the one holding its parameters - the scope holding that body. It is what says the three are one call rather than three, which
closure_homereads and nothing else does. 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 or self.kind is ScopeKind.PARAMS ) 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 var closure_home-
The scope that decides whether a reference made from this one crosses a closure boundary: a name read from a scope with a different one is read by a function other than the one that declares it, and is a capture.
This is the variable scope for every scope but the two a function introduces around its body. A parameter default and the body it belongs to are run by one call and share every binding either of them makes, and so does the name a function expression answers to inside itself, so no closure boundary runs between the three: all of them answer the body's scope.
Expand source code Browse git
@property def closure_home(self) -> Scope | None: """ The scope that decides whether a reference made from this one crosses a closure boundary: a name read from a scope with a different one is read by a function other than the one that declares it, and is a capture. This is the variable scope for every scope but the two a function introduces around its body. A parameter default and the body it belongs to are run by one call and share every binding either of them makes, and so does the name a function expression answers to inside itself, so no closure boundary runs between the three: all of them answer the body's scope. """ if self.function_body is not None: return self.function_body home = self.var_scope if home is not None and home.function_body is not None: return home.function_body return home
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 LexicalNameCache-
The lexically declared names of each statement list a walk passes, held for as long as the tree is not being rewritten.
annex_b_var_home()climbs from a declaration to the body that gives it avar, asking every list on the way whether it declares the name lexically. A body holding many function declarations is asked the same question about the same list once per declaration, and the scope builder asks it twice over, so the answer is quadratic in the size of the body without this. A cache is only ever right where nothing moves, which is why it is passed in rather than kept: the caller is the one that knows its rewrites have not started.Expand source code Browse git
class LexicalNameCache: """ The lexically declared names of each statement list a walk passes, held for as long as the tree is not being rewritten. `annex_b_var_home` climbs from a declaration to the body that gives it a `var`, asking every list on the way whether it declares the name lexically. A body holding many function declarations is asked the same question about the same list once per declaration, and the scope builder asks it twice over, so the answer is quadratic in the size of the body without this. A cache is only ever right where nothing moves, which is why it is passed in rather than kept: the caller is the one that knows its rewrites have not started. """ def __init__(self): self._names: dict[int, frozenset[str]] = {} def declares(self, holder: Node, name: str) -> bool: """ Whether the statement list *holder* holds declares *name* lexically, false where it holds none. """ names = self._names.get(id(holder)) if names is None: statements = statement_list_of(holder) names = frozenset() if statements is None else lexically_declared_names(statements) self._names[id(holder)] = names return name in namesMethods
def declares(self, holder, name)-
Whether the statement list holder holds declares name lexically, false where it holds none.
Expand source code Browse git
def declares(self, holder: Node, name: str) -> bool: """ Whether the statement list *holder* holds declares *name* lexically, false where it holds none. """ names = self._names.get(id(holder)) if names is None: statements = statement_list_of(holder) names = frozenset() if statements is None else lexically_declared_names(statements) self._names[id(holder)] = names return name in names
class SemanticModel (root)-
The resolved scope/binding/def-use model for one script. Build it with
build_semantic_model()and query it throughresolve,scope_of,binding_of,references,is_shadowed,would_capture, andhas_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 parameter_scope(self, func: Node) -> Scope | None: """ The scope holding *func*'s parameters and the `arguments` object a call gives it, which is its body's scope but for a function whose parameter list holds an expression: that one binds them in a scope of its own standing between the body and what encloses the function. A consumer reading a parameter binding out of a scope's own `bindings` asks for this one. `function_scope` answers the body's, which for such a function holds neither. """ scope = self.function_scope(func) if scope is None: return None parent = scope.parent if parent is not None and parent.kind is ScopeKind.PARAMS and parent.node is scope.node: return parent return scope 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[ReferenceNode]: """ Every reference (read or write) bound to *binding*, optionally omitting those that lie within the subtree of *exclude*. Each is a referencing identifier except where an object aliasing the binding stands in for one (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 read_may_throw(self, node: JsIdentifier) -> bool: """ Whether evaluating *node* as a read may throw a `ReferenceError` because the name it spells is not certain to denote a binding. The companion to `read_has_dynamic_effect`, which asks what else a read may do; this asks whether it may not happen at all. A caller that treats an unresolved read as free is asserting the host defines the name, which for a name the program neither declares nor assigns is an assertion about someone else's global object. A name resolves for certain when a declaration binds it, or when the specification mandates it on the global object (`GUARANTEED_GLOBALS`) — the same existence allowlist that decides whether a global-alias member read may be collapsed to a bare name. A `GLOBAL_OBJECT_ALIASES` spelling resolves too, which is a *host* assumption rather than a language one: no host defines all of them, so a bare `window` throws under Node exactly as a bare `global` throws in a browser. It is admitted because the effect analysis already rests on it — `_base_is_safe` clears a property access on an alias — and answering otherwise here would leave that clause standing with nothing left for it to decide. Everything else may not be there: - a free name, which reaches the host and may simply not exist - a name whose only binding is an `IMPLICIT_GLOBAL`, which the assignment that creates it brings into existence, so a read that runs first — or whose creating assignment sits in a function nobody calls — throws exactly as a free name does - a name resolved through a `with` body whose object may not carry it and which has no lexical binding to fall through to, which the `cross_dynamic` lookup is what distinguishes A reference that is written and not read answers `False`, as do the two operator positions `tolerates_unresolvable` names. The write case is a scope boundary, not a claim that writing is safe: sloppy code assigning to a name nothing binds creates a property of the global object, while strict code throws the same `ReferenceError` a read does, which is a separate defect with its own pin (`test_unfixed_defects.A_STRICT_REGION_ASSIGNING_TO_NO_BINDING`). """ if not self.is_reference(node) or reference_role(node) is Role.WRITE: return False if node.name in GUARANTEED_GLOBALS or node.name in GLOBAL_OBJECT_ALIASES: return False if tolerates_unresolvable(node): return False scope = self._node_scope.get(id(node)) binding = self.lookup(node.name, scope, cross_dynamic=True) return binding is None or binding.kind is BindingKind.IMPLICIT_GLOBAL 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 — the whole of it, so a write that leaves no `writes` entry because nothing says what it stored (`has_indefinite_write`) unpins the name as much as one that does. """ parent = function.parent establishing = None if ( isinstance(parent, JsAssignmentExpression) and parent.operator == '=' and parent.right is function ): establishing = strip_parens(parent.left) if binding.has_indefinite_write: return False 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 binding.written_at_entry: 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 — which is also the answer where a write leaves no `writes` entry because nothing says what it stored, since an empty `writes` would otherwise read as a value hoisted into place before any statement runs. 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.has_indefinite_write: return None if binding.writes: return list(binding.writes) declaration = binding.declarations[0] parent = declaration.parent if isinstance(parent, JsFunctionDeclaration): return [parent] if annex_b_copies_into(binding) else [] 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 free_name_reachable_by_direct_eval(self, node: Node) -> bool: """ Whether a direct `eval` could have installed a binding that a free name at *node* reads instead of the global one. `resolve` answering `None` means this model saw no declaration of the name, which is not the same as there being none: `eval('var undefined = 4')` declares one that no reference here records, and a read of that name afterwards is the binding, not the global. Only `var` and function declarations escape an `eval` — a `let` inside one lives in a scope discarded with the call — so a binding it installs lands in the var scope the call itself stands in, and is visible at *node* exactly when that var scope contains *node*'s scope. This is the mirror of `local_reachable_by_direct_eval`, which asks whether an `eval` can name a binding that already exists and therefore counts one nested *below* the binding's owner; a nested `eval` declares into its own function and so is not counted here. An `eval` whose own argument contains *node* is excluded, and that exclusion is about order rather than scope: the arguments of a call are evaluated before the call runs, so the code the `eval` is about to execute cannot have declared anything the argument reads. Without it, `eval(atob('...'))` — the shape most of this tool's corpus is written in — would refuse to read `atob` on the strength of the very `eval` it is decoding the body of. """ scope = self.scope_of(node) if scope is None: return True enclosing = {id(node)} cursor = node.parent while cursor is not None: enclosing.add(id(cursor)) cursor = cursor.parent for site in self._direct_eval_sites(self.root): if any(id(argument) in enclosing for argument in getattr(site, 'arguments', ())): continue site_scope = self.scope_of(site) owner = site_scope.var_scope if site_scope is not None else None if owner is None or owner.contains(scope): return True return False 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 write through an object that aliases the binding — `indefinite_writes` — is counted here too: it replaces the value under the name while leaving no entry that says with what. A consumer that judges a binding's value stable from `writes` alone must also consult this, since none of these reassignments 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 binding.has_indefinite_write: return True 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() self._record_def_use_references() self._record_arguments_alias_references() self._record_global_object_alias_references() self._record_exports() def _record_exports(self): """ Flag every binding an `export` ties to the outside as `Binding.exported`. A declaration written under an export (`export var a`, `export function`/`class`, and `export default` of a named function or class) exports the binding it declares; a sourceless list (`export { a }`, `export { a as q }`) exports the binding each specifier's local half names. A list carrying a `from` clause and a re-export name a binding of the module the clause spells, nothing local, and are passed over here. """ for node in self.root.walk(): if isinstance(node, JsExportNamedDeclaration): if node.declaration is not None: self._mark_declaration_exported(node.declaration) elif node.source is None: for specifier in node.specifiers: if isinstance(specifier.local, JsIdentifier): self._mark_binding_exported(self.resolve(specifier.local)) elif isinstance(node, JsExportDefaultDeclaration): self._mark_declaration_exported(node.declaration) def _mark_declaration_exported(self, declaration: Node | None): """ Flag the bindings a declaration written under an export declares. A `var`/`let`/`const` exports every name its declarators bind, descending through destructuring; a function or class declaration exports its own name. An expression under `export default` declares no binding and is read like any other value. """ if isinstance(declaration, JsVariableDeclaration): for declarator in declaration.declarations: if isinstance(declarator, JsVariableDeclarator): for ident in pattern_identifiers(declarator.id): self._mark_binding_exported(self.binding_of(ident)) elif isinstance(declaration, (JsFunctionDeclaration, JsClassDeclaration)): if isinstance(declaration.id, JsIdentifier): self._mark_binding_exported(self.binding_of(declaration.id)) @staticmethod def _mark_binding_exported(binding: Binding | None): if binding is not None: binding.exported = True def _record_def_use_references(self): """ One record per reference node: the walk reaches a node once per slot holding it, and the one identifier of `{ a }` or of `export { a };` fills two, so without the dedup a read's multiplicity would follow its spelling rather than the program. """ seen: set[int] = set() 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 id(node) in seen: continue seen.add(id(node)) 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) binding.note_reference_from(ref_scope) 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, *, module_scope: bool = False, ) -> 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. *module_scope* is the one thing about the file this query cannot read off the access. A `this` written where a classic script's top level holds one denotes the global object; the same `this` in a module denotes nothing, and in a CommonJS file it denotes that file's exports. So a caller rewriting a program for a host answers under the model it runs, and the default is the script model, which is the model this class records under: recording a reference the module model would not have is what keeps a declaration a reader may reach, and refusing to record it is what removes one. """ return self._global_member_name( member, self._base_is_the_global_object, module_scope=module_scope) def may_name_a_global(self, member: JsMemberExpression) -> str | None: """ The name of the global that a member access *may* reference once the program runs, read through `may_be_global_object_base` rather than through the spelling alone, or `None`. The reading half of `global_alias_member_name`, and separate from it because the two answers are spent on opposite things. This one is recorded as a reference, where admitting an access whose receiver turns out to be another object keeps a declaration nothing reaches. That one drives a rewrite, where the same admission renames a method's own property to a global: `refinery.lib.scripts.js.deobfuscation.reflection` resolves a member callee through it, and a `this.eval(...)` answered as the global `eval` rewrites a call to an ordinary method. No binding is minted from this answer. `_ensure_implicit_global_from_alias_write` keeps the spelling question, because a minted global is a name every intrinsic-trust and reflection reader then sees, and one minted from a receiver that was some other object withdraws trust the file never gave up. """ return self._global_member_name(member, self._base_may_be_the_global_object) def _global_member_name( self, member: JsMemberExpression, base_is_the_global_object: Callable[[Node | None], bool], *, module_scope: bool = False, ) -> str | None: base = strip_parens(member.object) if not base_is_the_global_object(base): return None if module_scope and isinstance(base, JsThisExpression): return None name = _member_property_name(member) if name is None: return None if crosses_dynamic_scope(self._node_scope.get(id(member))): return None return name def _base_is_the_global_object(self, base: Node | None) -> bool: """ Whether *base* is the global object under the narrow reading: the spelling says so, and the name it is spelled with is not bound to anything else. A local `window` names an ordinary object, so the two questions are one answer here, and every reader that drives a rewrite gets that answer. """ return is_global_object_base(base) and not self._is_bound_here(base) def _holds_the_global_object(self, node: Node | None) -> bool: """ Whether *node* is the global object: spelled as one, or a name the file gives it to. A program meant to run in a browser and in something else names it once — `var w = window || {}` — and every read through that name afterwards reads a global property, which `_base_is_the_global_object` cannot see, because the name it is asked about is `w`. """ return self._base_is_the_global_object(node) or self.names_the_global_object(node) def _base_may_be_the_global_object(self, base: Node | None) -> bool: """ Whether *base* may be the global object once the program runs: `_holds_the_global_object` widened by the receiver a call supplies, which `may_be_global_object_base` states. Only a reader recording a reference asks this, and the argument for admitting a receiver that turns out to be another object is written there. """ return ( may_be_global_object_base(base) and not self._is_bound_here(base) ) or self.names_the_global_object(base) def _is_bound_here(self, node: Node | None) -> bool: return ( isinstance(node, JsIdentifier) and self.lookup(node.name, self._node_scope.get(id(node))) is not None ) def names_the_global_object(self, node: Node | None, *, depth: int = 0) -> bool: """ Whether *node* is a name whose one value is the global object, so a property read on it is a read of a global. The value comes from `singular_value`, so a name written more than once, redeclared, or reachable by a dynamic rebinding has none and is refused. A name the file only ever assigns has none either: `_ensure_implicit_global_from_alias_write` mints its binding without a declaration and both value queries decline for it. That is what keeps this answer out of the walk which is still recording those very writes — a read admitted or refused by how far that walk had got would depend on nothing the program says. The value holds wherever the name is not in its temporal dead zone, and nothing here orders the establishing definition before the read. A caller driving a rewrite has to; the callers here record a reference, where one admission too many keeps a declaration and one refusal too many deletes one. """ if depth >= _GLOBAL_ALIAS_CHAIN_LIMIT or not isinstance(node, JsIdentifier): return False return self._value_is_the_global_object( self.singular_value(self.resolve(node)), depth + 1) def _value_is_the_global_object(self, value: Node | None, depth: int) -> bool: """ Whether *value*, the one value a name holds, is the global object. `A || B` is it whenever `A` is: every spelling of the object is truthy, so the guard a program writes to survive a host lacking the name it prefers evaluates to the object wherever that name exists. """ value = strip_parens(value) if value is None: return False if self._base_is_the_global_object(value): return True if isinstance(value, JsLogicalExpression) and value.operator == '||': return self._value_is_the_global_object(value.left, depth) return self.names_the_global_object(value, depth=depth) 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. The binding minted here is one nothing reads a value out of: it carries no declaration, so both value queries decline for it, and all it does is give a reference somewhere to resolve to instead of standing free. That is why a write through the `this` of a top level mints one too, although whether such a write creates a global at all is decided by the host - a CommonJS file writes its own exports there. Under the model where it creates nothing, the binding this mints answers no question differently; the one rewrite that reads such a write as a property having been created asks for the execution model itself. """ 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. Read through `may_name_a_global`, so a receiver a call may supply the global object for is recorded too. Nothing is created from that answer, so the widest it can be wrong is to keep a declaration a reader never reaches. """ name = self.may_name_a_global(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) binding.note_reference_from(self._node_scope.get(id(member))) def _record_arguments_alias_references(self): """ Record, against each parameter binding, the references made through an `arguments` object whose elements alias the parameters, exactly as a reference through a global-object alias is recorded by `_record_global_alias_member_reference`. The two are the same situation: a binding reached through an object rather than by its own name, which the identifier walk therefore does not see. Without this a body that only ever reads `arguments[0]` leaves its first parameter looking unreferenced, and a remover drops the write whose value that read answers with. `has_mapped_arguments` decides which functions have such an object at all, so a strict body, an arrow, and any list holding a default, a rest element or a destructuring pattern contribute nothing. It is asked of the sloppy case first and the mode only afterwards, which is the same conjunction it states — no function has such an object in strict mode — asked in the order that pays for it: the mode is a climb to the root per function, while everything else is local, and almost no function is a candidate. Where the object is reached is `walk_receiver_scope`: an arrow reads the enclosing `arguments` and is descended, a nested function has its own and is not. An element access is attributed to the parameter it names. The read half of that access is a definite read of the parameter, and the write half never is: §10.2.11 maps an element onto a parameter only at a position the call supplied an argument for, so `arguments[0] = 9` writes the first parameter when the call passed one and creates an ordinary property when it passed none. Nothing in the text of the function says which, so the write is recorded as an `indefinite_writes` entry — a kill that names no value — and not as a definition a fold could answer with. A bare use of the object is asked what its governing construct can do with it: one that observes identity alone — a `typeof`, a truth test, a `for-in` head — is recorded as nothing, and one that reads every element and nothing else — a spread, a `for-of` head — as a read of each parameter. `_observes_identity_alone` and `_reads_every_element_alone` carry the argument for every admitted position, and an indefinite write recorded at one of them would refuse every fold in the function for a use that cannot write anything. Every use those two decline — the object handed to a call, the object bound to a second name — is recorded as a read of every parameter and an indefinite write of every one of them: reading is what makes a write to a parameter observable, which is the fact a remover needs, and the object may reach code that writes any element. `arguments[i] = v` for an `i` the model cannot read is recorded the same way, since it may write any single one and recording a definition of each would let a fold answer with a value only one of them can hold. The name is resolved rather than matched, because a body may bind `arguments` itself — as a parameter, a lexical declaration, a `var` given a value, or a catch parameter — and may also assign over the one it was given. In either case the name denotes something whose elements alias nothing, so attributing an access to a parameter would credit the parameter with a write the program never makes. Such a function is left alone entirely rather than up to the point of the rebinding, because which accesses run before it is a question about flow that a walk over the text does not answer. `_displaces_arguments` decides it. A function expression whose own name is `arguments` is not one of those: that name is bound in an environment the object's own shadows, so the body still reads the mapped object. The scope model records the two as one binding, which is why the binding's kind is admitted as well as `ARGUMENTS` here rather than only it. A name that resolves to nothing is still taken for the object where it stands: resolution answers `None` for a free name and across a `with`, neither of which is evidence that something else was bound. """ for fn in self.root.walk(): if not isinstance(fn, (JsFunctionExpression, JsFunctionDeclaration)): continue if not has_mapped_arguments(fn, strict=False) or strict_mode_at(fn): continue own = self.lookup('arguments', self._node_scope.get(id(fn.body))) if own is None or own.kind not in (BindingKind.ARGUMENTS, BindingKind.FUNC_NAME): continue if _displaces_arguments(own, fn): continue params = _last_positions([ self.binding_of(param) if isinstance(param, JsIdentifier) else None for param in fn.params ]) for node in walk_receiver_scope(fn): if not isinstance(node, JsIdentifier) or node.name != 'arguments': continue if not self.is_reference(node): continue denotes = self.resolve(node) if denotes is not None and denotes is not own: continue access = _enclosing_member_access(node) if access is not None and denotes is not None: named = _aliased_parameter_positions(access, len(params)) if named is not None: role = reference_role(access) for index in named: self._record_alias_reference(params[index], access, role) continue if access is None: governor = enclosing_operator(node) if _observes_identity_alone(governor, node): continue if _reads_every_element_alone(governor, node): for binding in params: self._record_alias_reference(binding, node, Role.READ) continue site: JsIdentifier | JsMemberExpression = node if access is None else access may_write = access is None or reference_role(access) is not Role.READ for binding in params: self._record_alias_reference(binding, node, Role.READ) if may_write: self._record_alias_reference(binding, site, Role.WRITE) def _record_global_object_alias_references(self): """ Record, against every binding a classic script's global object carries, the references a call may make through the object once it is handed one, but only for a hand-over the callee could read a property through. `a(globalThis, 'q')` and `a(this, 'q')` both give `a` an object whose properties are the script's top-level declarations, and a body that writes one of them writes the declaration — which no identifier in the text names, so the identifier walk sees nothing. A call that never reads a property of the object it is handed reaches no declaration through it, and admitting one there freezes every fold in the file for a reference the program never makes. `global_object_argument_is_observed` decides, per hand-over, whether the callee could read a property. An observed hand-over records the same way `_record_arguments_alias_references` does — a read of every binding, so a declaration reached only through the object is not removed, and an indefinite write of every one, so a fold does not carry a value across a write the callee made. Which properties the callee touches is not decided, and every binding is admitted, for the reason the argument list is admitted whole: a value only some of them can hold is not a definition of any of them. An unobserved hand-over records nothing, and the union is taken across hand-overs — a binding stays reachable if any one is observed. The gate runs in two phases because it reads `singular_value` to resolve a callee, and the record it is about to make is an indefinite write that would make that query decline. Every hand-over is judged first, against the model as it stands before this method writes anything, and only then are the observed ones recorded. This is the order `names_the_global_object` keeps for the same reason — an answer read out of the walk still recording those very writes would depend on how far the walk had got, not on what the program says. The object is recognized by `_holds_the_global_object`, so only the `this` a script's top level holds is one. A `this` inside a function is the receiver its call supplied, and admitting it costs every fold in a file that hands one to anything: obfuscator.io's self-defending wrapper passes its own `this` to a call, and a run that took it for the global object leaves that sample twenty times its deobfuscated size. `may_be_global_object_base` admits every `this` for the opposite reason — there the wrong answer only keeps a declaration alive, and here it freezes the file. Only an argument is read. A `return` of the object hands it to a caller the text still shows, and taking that for an escape refuses `refinery.lib.scripts.js.deobfuscation .globalfinder` the very function whose removal makes the object nameable, leaving the two obfuscated samples that use a finder at their original size. """ bindings = list(self.root_scope.bindings.values()) if not bindings: return observed: list[ReferenceNode] = [] for node in self.root.walk(): if not isinstance(node, (JsIdentifier, JsThisExpression)): continue if not self._holds_the_global_object(node) or not _is_call_argument(node): continue if self.global_object_argument_is_observed(node): observed.append(node) for node in observed: for binding in bindings: binding.reachable_through_a_handed_object = True self._record_alias_reference(binding, node, Role.READWRITE) def global_object_argument_is_observed(self, node: Node) -> bool: """ Whether the call *node* is handed to could read a property of the global object *node* stands for. An unobserved hand-over lets the globals the object carries stay foldable; an observed one, or one the model cannot resolve, is admitted whole the way it always has been. The callee is resolved to the function it runs: a function written in place, a name whose one value is a function, or a name whose one value is the zero-argument IIFE a self-defending wrapper's factory is, whose single returned function is the one that runs. A callee resolving to none of these is not read, so its object is observed. A function that reaches its own `arguments` is observed too, because an element of that object is the handed argument under another name, which the parameter walk does not follow. The argument is matched to the parameter it binds by position; a list with a rest, default, or destructuring element is not matched and its object is observed. An argument past the last parameter binds nothing the callee can name and is not observed. A parameter reflection can reach is observed. Otherwise `_parameter_is_observed` asks the body. """ call = _enclosing_call(node) if call is None: return True function = self._target_function_of_call(call) if function is None: return True if references_own_arguments(function): return True mapping = self._argument_parameter_map(call, function) if mapping is None: return True parameter = next((b for b, argument in mapping.items() if argument is node), None) if parameter is None: return False if self.reflection_can_reach(parameter): return True return self._parameter_is_observed(function, parameter, mapping, {id(function)}, 0) def _target_function_of_call(self, call: JsCallExpression | JsNewExpression) -> JsFunctionNode | None: """ The function *call* runs, as far as it resolves without leaving the text: the callee written as a function, a name whose one value is a function, or a name whose one value is a zero-argument IIFE returning a single function — the shape the self-defending wrapper's factory takes. `None` when the callee resolves to none of these. """ callee = strip_parens(call.callee) if isinstance(callee, FUNCTION_NODES): return callee if not isinstance(callee, JsIdentifier): return None value = self.singular_value(self.resolve(callee)) if value is None: return None value = strip_parens(value) if isinstance(value, FUNCTION_NODES): return value if isinstance(value, JsCallExpression) and not value.arguments: inner = strip_parens(value.callee) if isinstance(inner, FUNCTION_NODES): return _sole_returned_function(inner) return None def _argument_parameter_map( self, call: JsCallExpression | JsNewExpression, function: JsFunctionNode, ) -> dict[Binding | None, Node | None] | None: """ A map from each parameter binding of *function* to the argument *call* supplies for it by position, or `None` when a parameter is not a plain name — a rest, default, or destructuring element the model cannot bind by position. A parameter the call gives no argument for maps to `None`. """ if any(not isinstance(parameter, JsIdentifier) for parameter in function.params): return None mapping: dict[Binding | None, Node | None] = {} for index, parameter in enumerate(function.params): if not isinstance(parameter, JsIdentifier): continue argument = strip_parens(call.arguments[index]) if index < len(call.arguments) else None mapping[self.binding_of(parameter)] = argument return mapping def _parameter_is_observed( self, function: JsFunctionNode, parameter: Binding | None, mapping: dict[Binding | None, Node | None], visiting: set[int], depth: int, ) -> bool: """ Whether *function*'s body reads a property of the object bound to *parameter*. The parameter as the base of a member access reads one; the receiver an `apply`/`call` hands to a function that reads its own `this` does; a receiver handed to a `this`-free function does not, because that function never reads it. Every other use — returned, aliased, enumerated, handed on as a plain argument — is taken for an observation. The whole subtree is walked and each identifier resolved, so a use inside a nested closure that captures the parameter counts, and a shadowing binding of the same name does not. """ if depth > _HANDED_OBJECT_OBSERVATION_DEPTH: return True for reference in function.walk(): if not isinstance(reference, JsIdentifier) or not self.is_reference(reference): continue if self.resolve(reference) is not parameter: continue access = _enclosing_member_access(reference) if access is not None and strip_parens(access.object) is reference: return True if self._apply_receiver_is_safe(reference, mapping, visiting, depth) is not True: return True return False def _apply_receiver_is_safe( self, node: Node, mapping: dict[Binding | None, Node | None], visiting: set[int], depth: int, ) -> bool | None: """ For a *node* that denotes the handed object: `True` when it is the `thisArg` of an `apply`/`call` whose target provably does not read its own `this`, so the object is not read there; `False` when it is such a receiver but the target reads `this` or does not resolve to a function; and `None` when *node* is not used as such a receiver at all, the case the caller reads as an observation. The target is resolved through the argument map when it is another parameter of the same call, and otherwise by its one value. """ parent = enclosing_operator(node) if not isinstance(parent, JsCallExpression): return None callee = strip_parens(parent.callee) if not isinstance(callee, JsMemberExpression): return None if _member_property_name(callee) not in ('apply', 'call'): return None if not parent.arguments or strip_parens(parent.arguments[0]) is not node: return None target: Node | None = strip_parens(callee.object) if isinstance(target, JsIdentifier): binding = self.resolve(target) if binding is None: target = None elif binding in mapping: target = mapping[binding] else: value = self.singular_value(binding) target = strip_parens(value) if value is not None else None if not isinstance(target, FUNCTION_NODES): return False return not self._function_observes_its_this(target, visiting, depth + 1) def _function_observes_its_this( self, function: JsFunctionNode, visiting: set[int], depth: int, ) -> bool: """ Whether *function* reads the `this` its caller supplies. An arrow has none of its own and reads the enclosing one, so a receiver handed to it is never read; a regular function that names `this` anywhere in its own receiver scope, or runs a direct `eval` that could, reads it. The bound and the *visiting* set take a function that hands `this` on to itself, or a chain too deep to follow, for a reader. """ if depth > _HANDED_OBJECT_OBSERVATION_DEPTH or id(function) in visiting: return True if isinstance(function, JsArrowFunctionExpression): return False if self._function_has_direct_eval(function): return True return any(isinstance(node, JsThisExpression) for node in walk_receiver_scope(function)) def _record_alias_reference( self, binding: Binding | None, node: ReferenceNode, role: Role, ) -> None: """ Record against a binding one reference made through an object that aliases it — a mapped `arguments` reaching a parameter, or the global object reaching a global. The read half is a definite read — the access observes whatever the binding holds — while the write half never is: what an object handed to a call writes through is decided by code the walk does not read, so it lands in `indefinite_writes` as a kill that names no value rather than in `writes` as a definition. """ if binding is None: return if role is not Role.WRITE: binding.reads.append(node) if role is not Role.READ: binding.indefinite_writes.append(node) binding.note_reference_from(self._node_scope.get(id(node)))Methods
def scope_of(self, node)-
The innermost scope that lexically contains node, or
Noneif 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, andNonewhen 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 parameter_scope(self, func)-
The scope holding func's parameters and the
argumentsobject a call gives it, which is its body's scope but for a function whose parameter list holds an expression: that one binds them in a scope of its own standing between the body and what encloses the function.A consumer reading a parameter binding out of a scope's own
bindingsasks for this one.function_scopeanswers the body's, which for such a function holds neither.Expand source code Browse git
def parameter_scope(self, func: Node) -> Scope | None: """ The scope holding *func*'s parameters and the `arguments` object a call gives it, which is its body's scope but for a function whose parameter list holds an expression: that one binds them in a scope of its own standing between the body and what encloses the function. A consumer reading a parameter binding out of a scope's own `bindings` asks for this one. `function_scope` answers the body's, which for such a function holds neither. """ scope = self.function_scope(func) if scope is None: return None parent = scope.parent if parent is not None and parent.kind is ScopeKind.PARAMS and parent.node is scope.node: return parent return scope 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
Noneif 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
Nonefor 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 thewithobject lacked the property — the lexical binding a dynamic scope could still reach at runtime — which is how awith-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();resolveresolves 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
Nonewhen 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 where an object aliasing the binding stands in for one (see
Binding).Expand source code Browse git
def references( self, binding: Binding, *, exclude: Node | None = None, ) -> list[ReferenceNode]: """ Every reference (read or write) bound to *binding*, optionally omitting those that lie within the subtree of *exclude*. Each is a referencing identifier except where an object aliasing the binding stands in for one (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
withbody that could denote binding (it may instead denote a property of thewithobject, which is why the staticreferencesset omits it) — optionally omitting those within the subtree of exclude. Each is classified on demand byreference_role()orcontainer_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
withbody — so that evaluating it is not a pure, droppable, or reorderable operand. Reading the bare name consults thewithobject 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 aReferenceError. 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 read_may_throw(self, node)-
Whether evaluating node as a read may throw a
ReferenceErrorbecause the name it spells is not certain to denote a binding. The companion toread_has_dynamic_effect, which asks what else a read may do; this asks whether it may not happen at all. A caller that treats an unresolved read as free is asserting the host defines the name, which for a name the program neither declares nor assigns is an assertion about someone else's global object.A name resolves for certain when a declaration binds it, or when the specification mandates it on the global object (
GUARANTEED_GLOBALS) — the same existence allowlist that decides whether a global-alias member read may be collapsed to a bare name. AGLOBAL_OBJECT_ALIASESspelling resolves too, which is a host assumption rather than a language one: no host defines all of them, so a barewindowthrows under Node exactly as a bareglobalthrows in a browser. It is admitted because the effect analysis already rests on it —_base_is_safeclears a property access on an alias — and answering otherwise here would leave that clause standing with nothing left for it to decide. Everything else may not be there:- a free name, which reaches the host and may simply not exist
- a name whose only binding is an
IMPLICIT_GLOBAL, which the assignment that creates it brings into existence, so a read that runs first — or whose creating assignment sits in a function nobody calls — throws exactly as a free name does - a name resolved through a
withbody whose object may not carry it and which has no lexical binding to fall through to, which thecross_dynamiclookup is what distinguishes
A reference that is written and not read answers
False, as do the two operator positionstolerates_unresolvable()names. The write case is a scope boundary, not a claim that writing is safe: sloppy code assigning to a name nothing binds creates a property of the global object, while strict code throws the sameReferenceErrora read does, which is a separate defect with its own pin (test_unfixed_defects.A_STRICT_REGION_ASSIGNING_TO_NO_BINDING).Expand source code Browse git
def read_may_throw(self, node: JsIdentifier) -> bool: """ Whether evaluating *node* as a read may throw a `ReferenceError` because the name it spells is not certain to denote a binding. The companion to `read_has_dynamic_effect`, which asks what else a read may do; this asks whether it may not happen at all. A caller that treats an unresolved read as free is asserting the host defines the name, which for a name the program neither declares nor assigns is an assertion about someone else's global object. A name resolves for certain when a declaration binds it, or when the specification mandates it on the global object (`GUARANTEED_GLOBALS`) — the same existence allowlist that decides whether a global-alias member read may be collapsed to a bare name. A `GLOBAL_OBJECT_ALIASES` spelling resolves too, which is a *host* assumption rather than a language one: no host defines all of them, so a bare `window` throws under Node exactly as a bare `global` throws in a browser. It is admitted because the effect analysis already rests on it — `_base_is_safe` clears a property access on an alias — and answering otherwise here would leave that clause standing with nothing left for it to decide. Everything else may not be there: - a free name, which reaches the host and may simply not exist - a name whose only binding is an `IMPLICIT_GLOBAL`, which the assignment that creates it brings into existence, so a read that runs first — or whose creating assignment sits in a function nobody calls — throws exactly as a free name does - a name resolved through a `with` body whose object may not carry it and which has no lexical binding to fall through to, which the `cross_dynamic` lookup is what distinguishes A reference that is written and not read answers `False`, as do the two operator positions `tolerates_unresolvable` names. The write case is a scope boundary, not a claim that writing is safe: sloppy code assigning to a name nothing binds creates a property of the global object, while strict code throws the same `ReferenceError` a read does, which is a separate defect with its own pin (`test_unfixed_defects.A_STRICT_REGION_ASSIGNING_TO_NO_BINDING`). """ if not self.is_reference(node) or reference_role(node) is Role.WRITE: return False if node.name in GUARANTEED_GLOBALS or node.name in GLOBAL_OBJECT_ALIASES: return False if tolerates_unresolvable(node): return False scope = self._node_scope.get(id(node)) binding = self.lookup(node.name, scope, cross_dynamic=True) return binding is None or binding.kind is BindingKind.IMPLICIT_GLOBAL 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/constdeclarator a function or arrow expression is the initializer of.Nonefor 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.Nonefor 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. Unlikenaming_bindingthis also recognizes the bare-assignment form, so a function held in a hoistedvarassigned once is ordered by its calls rather than by its creation; a caller confirms the binding is singly declared,binding_pinned_tofunction, 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 — the whole of it, so a write that leaves nowritesentry because nothing says what it stored (has_indefinite_write) unpins the name as much as one that does.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 — the whole of it, so a write that leaves no `writes` entry because nothing says what it stored (`has_indefinite_write`) unpins the name as much as one that does. """ parent = function.parent establishing = None if ( isinstance(parent, JsAssignmentExpression) and parent.operator == '=' and parent.right is function ): establishing = strip_parens(parent.left) if binding.has_indefinite_write: return False 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 = functionassignment whoseBASEidentifier resolves to a local binding that holds one object value (singular_valueis aJsObjectExpression) 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 readBASE.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 awiththat could rename the base (adynamic_refsentry) makes the ordering unknowable and yieldsNone, 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_bindingcannot 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/constdeclarator, 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).Nonewhen 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 readsundefinedbefore its write.EffectModel.function_ofis 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 binding.written_at_entry: 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:Nonewhen 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_valueis 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 avar/let/constinitializer, 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).Nonewhen the binding holds no single such value, so its presence cannot be ordered and the caller declines — which is also the answer where a write leaves nowritesentry because nothing says what it stored, since an emptywriteswould otherwise read as a value hoisted into place before any statement runs. This mirrorssingular_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 — which is also the answer where a write leaves no `writes` entry because nothing says what it stored, since an empty `writes` would otherwise read as a value hoisted into place before any statement runs. 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.has_indefinite_write: return None if binding.writes: return list(binding.writes) declaration = binding.declarations[0] parent = declaration.parent if isinstance(parent, JsFunctionDeclaration): return [parent] if annex_b_copies_into(binding) else [] 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
evalorFunctionintrinsic 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 awithstatement. 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-programhas_reflection_surface. A function-local is reachable only from within its own function and only by name: awithbody that names it (adynamic_referencesentry) or a directevalin the function (local_reachable_by_direct_eval). Awiththat 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
evalorFunction, a string timer, or a dynamic access on the global object — could name binding at runtime with no reference this model records. Unlikereflection_can_reach, awithbody is not counted: awiththat names the binding is attributed precisely as adynamic_referencesentry, so a caller that already consultsdynamic_refsneeds 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 directevalin its own function, since a surface running in the global scope cannot name a local. The boolean companion ofreflection_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 directevalsites 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. Awithsurface is not included — awiththat names the binding is attributed as adynamic_referencesentry 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
evalpositioned 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 directeval, 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-programreflection_can_reachanswers, and freezing every global on it is an over-approximation the caller must choose to accept, not a fact this query asserts. Thewithsurface is not counted — awithbody's accesses are attributed precisely asdynamic_references, so only the opaqueevalcase 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 free_name_reachable_by_direct_eval(self, node)-
Whether a direct
evalcould have installed a binding that a free name at node reads instead of the global one.resolveansweringNonemeans this model saw no declaration of the name, which is not the same as there being none:eval('var undefined = 4')declares one that no reference here records, and a read of that name afterwards is the binding, not the global.Only
varand function declarations escape aneval— aletinside one lives in a scope discarded with the call — so a binding it installs lands in the var scope the call itself stands in, and is visible at node exactly when that var scope contains node's scope. This is the mirror oflocal_reachable_by_direct_eval, which asks whether anevalcan name a binding that already exists and therefore counts one nested below the binding's owner; a nestedevaldeclares into its own function and so is not counted here.An
evalwhose own argument contains node is excluded, and that exclusion is about order rather than scope: the arguments of a call are evaluated before the call runs, so the code theevalis about to execute cannot have declared anything the argument reads. Without it,eval(atob('...'))— the shape most of this tool's corpus is written in — would refuse to readatobon the strength of the veryevalit is decoding the body of.Expand source code Browse git
def free_name_reachable_by_direct_eval(self, node: Node) -> bool: """ Whether a direct `eval` could have installed a binding that a free name at *node* reads instead of the global one. `resolve` answering `None` means this model saw no declaration of the name, which is not the same as there being none: `eval('var undefined = 4')` declares one that no reference here records, and a read of that name afterwards is the binding, not the global. Only `var` and function declarations escape an `eval` — a `let` inside one lives in a scope discarded with the call — so a binding it installs lands in the var scope the call itself stands in, and is visible at *node* exactly when that var scope contains *node*'s scope. This is the mirror of `local_reachable_by_direct_eval`, which asks whether an `eval` can name a binding that already exists and therefore counts one nested *below* the binding's owner; a nested `eval` declares into its own function and so is not counted here. An `eval` whose own argument contains *node* is excluded, and that exclusion is about order rather than scope: the arguments of a call are evaluated before the call runs, so the code the `eval` is about to execute cannot have declared anything the argument reads. Without it, `eval(atob('...'))` — the shape most of this tool's corpus is written in — would refuse to read `atob` on the strength of the very `eval` it is decoding the body of. """ scope = self.scope_of(node) if scope is None: return True enclosing = {id(node)} cursor = node.parent while cursor is not None: enclosing.add(id(cursor)) cursor = cursor.parent for site in self._direct_eval_sites(self.root): if any(id(argument) in enclosing for argument in getattr(site, 'arguments', ())): continue site_scope = self.scope_of(site) owner = site_scope.var_scope if site_scope is not None else None if owner is None or owner.contains(scope): return True return False 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
writesset does not record. Awithbody that names it as an assignment target may rebind it (the target may instead be a property of thewithobject, but may equally be this binding, so it is treated as a possible rebind), and a directevalin 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 write through an object that aliases the binding —indefinite_writes— is counted here too: it replaces the value under the name while leaving no entry that says with what. A consumer that judges a binding's value stable fromwritesalone must also consult this, since none of these reassignments leaves awritesentry; a script-scope binding reassigned only through an opaqueevalstays the documented residual, aslocal_reachable_by_direct_evalreports 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 write through an object that aliases the binding — `indefinite_writes` — is counted here too: it replaces the value under the name while leaving no entry that says with what. A consumer that judges a binding's value stable from `writes` alone must also consult this, since none of these reassignments 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 binding.has_indefinite_write: return True 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 contractdynamic_refsexpresses (whether every reference can be ranked), which awith-body read violates while a stable value does not. It does not itself require a single declaration; a caller that needs one checksdeclarationsalongside.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
Functionbody, an indirecteval, a string timer) resolves to. An implicit global always is. A top-levelvar/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-levellet/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, *, module_scope=False)-
The name of the global that a member access on a global-object alias references (
globalThis.g,window['g']→g), orNonewhen member is not such an access. The alias must be an unshadowedGLOBAL_OBJECT_ALIASESidentifier (a localwindownames 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 awith-object property — in either case the model cannot claim the reference denotes a global.module_scope is the one thing about the file this query cannot read off the access. A
thiswritten where a classic script's top level holds one denotes the global object; the samethisin a module denotes nothing, and in a CommonJS file it denotes that file's exports. So a caller rewriting a program for a host answers under the model it runs, and the default is the script model, which is the model this class records under: recording a reference the module model would not have is what keeps a declaration a reader may reach, and refusing to record it is what removes one.Expand source code Browse git
def global_alias_member_name( self, member: JsMemberExpression, *, module_scope: bool = False, ) -> 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. *module_scope* is the one thing about the file this query cannot read off the access. A `this` written where a classic script's top level holds one denotes the global object; the same `this` in a module denotes nothing, and in a CommonJS file it denotes that file's exports. So a caller rewriting a program for a host answers under the model it runs, and the default is the script model, which is the model this class records under: recording a reference the module model would not have is what keeps a declaration a reader may reach, and refusing to record it is what removes one. """ return self._global_member_name( member, self._base_is_the_global_object, module_scope=module_scope) def may_name_a_global(self, member)-
The name of the global that a member access may reference once the program runs, read through
may_be_global_object_base()rather than through the spelling alone, orNone.The reading half of
global_alias_member_name, and separate from it because the two answers are spent on opposite things. This one is recorded as a reference, where admitting an access whose receiver turns out to be another object keeps a declaration nothing reaches. That one drives a rewrite, where the same admission renames a method's own property to a global:refinery.lib.scripts.js.deobfuscation.reflectionresolves a member callee through it, and athis.eval(…)answered as the globalevalrewrites a call to an ordinary method.No binding is minted from this answer.
_ensure_implicit_global_from_alias_writekeeps the spelling question, because a minted global is a name every intrinsic-trust and reflection reader then sees, and one minted from a receiver that was some other object withdraws trust the file never gave up.Expand source code Browse git
def may_name_a_global(self, member: JsMemberExpression) -> str | None: """ The name of the global that a member access *may* reference once the program runs, read through `may_be_global_object_base` rather than through the spelling alone, or `None`. The reading half of `global_alias_member_name`, and separate from it because the two answers are spent on opposite things. This one is recorded as a reference, where admitting an access whose receiver turns out to be another object keeps a declaration nothing reaches. That one drives a rewrite, where the same admission renames a method's own property to a global: `refinery.lib.scripts.js.deobfuscation.reflection` resolves a member callee through it, and a `this.eval(...)` answered as the global `eval` rewrites a call to an ordinary method. No binding is minted from this answer. `_ensure_implicit_global_from_alias_write` keeps the spelling question, because a minted global is a name every intrinsic-trust and reflection reader then sees, and one minted from a receiver that was some other object withdraws trust the file never gave up. """ return self._global_member_name(member, self._base_may_be_the_global_object) def names_the_global_object(self, node, *, depth=0)-
Whether node is a name whose one value is the global object, so a property read on it is a read of a global. The value comes from
singular_value, so a name written more than once, redeclared, or reachable by a dynamic rebinding has none and is refused.A name the file only ever assigns has none either:
_ensure_implicit_global_from_alias_writemints its binding without a declaration and both value queries decline for it. That is what keeps this answer out of the walk which is still recording those very writes — a read admitted or refused by how far that walk had got would depend on nothing the program says.The value holds wherever the name is not in its temporal dead zone, and nothing here orders the establishing definition before the read. A caller driving a rewrite has to; the callers here record a reference, where one admission too many keeps a declaration and one refusal too many deletes one.
Expand source code Browse git
def names_the_global_object(self, node: Node | None, *, depth: int = 0) -> bool: """ Whether *node* is a name whose one value is the global object, so a property read on it is a read of a global. The value comes from `singular_value`, so a name written more than once, redeclared, or reachable by a dynamic rebinding has none and is refused. A name the file only ever assigns has none either: `_ensure_implicit_global_from_alias_write` mints its binding without a declaration and both value queries decline for it. That is what keeps this answer out of the walk which is still recording those very writes — a read admitted or refused by how far that walk had got would depend on nothing the program says. The value holds wherever the name is not in its temporal dead zone, and nothing here orders the establishing definition before the read. A caller driving a rewrite has to; the callers here record a reference, where one admission too many keeps a declaration and one refusal too many deletes one. """ if depth >= _GLOBAL_ALIAS_CHAIN_LIMIT or not isinstance(node, JsIdentifier): return False return self._value_is_the_global_object( self.singular_value(self.resolve(node)), depth + 1) def global_object_argument_is_observed(self, node)-
Whether the call node is handed to could read a property of the global object node stands for. An unobserved hand-over lets the globals the object carries stay foldable; an observed one, or one the model cannot resolve, is admitted whole the way it always has been.
The callee is resolved to the function it runs: a function written in place, a name whose one value is a function, or a name whose one value is the zero-argument IIFE a self-defending wrapper's factory is, whose single returned function is the one that runs. A callee resolving to none of these is not read, so its object is observed. A function that reaches its own
argumentsis observed too, because an element of that object is the handed argument under another name, which the parameter walk does not follow.The argument is matched to the parameter it binds by position; a list with a rest, default, or destructuring element is not matched and its object is observed. An argument past the last parameter binds nothing the callee can name and is not observed. A parameter reflection can reach is observed. Otherwise
_parameter_is_observedasks the body.Expand source code Browse git
def global_object_argument_is_observed(self, node: Node) -> bool: """ Whether the call *node* is handed to could read a property of the global object *node* stands for. An unobserved hand-over lets the globals the object carries stay foldable; an observed one, or one the model cannot resolve, is admitted whole the way it always has been. The callee is resolved to the function it runs: a function written in place, a name whose one value is a function, or a name whose one value is the zero-argument IIFE a self-defending wrapper's factory is, whose single returned function is the one that runs. A callee resolving to none of these is not read, so its object is observed. A function that reaches its own `arguments` is observed too, because an element of that object is the handed argument under another name, which the parameter walk does not follow. The argument is matched to the parameter it binds by position; a list with a rest, default, or destructuring element is not matched and its object is observed. An argument past the last parameter binds nothing the callee can name and is not observed. A parameter reflection can reach is observed. Otherwise `_parameter_is_observed` asks the body. """ call = _enclosing_call(node) if call is None: return True function = self._target_function_of_call(call) if function is None: return True if references_own_arguments(function): return True mapping = self._argument_parameter_map(call, function) if mapping is None: return True parameter = next((b for b, argument in mapping.items() if argument is node), None) if parameter is None: return False if self.reflection_can_reach(parameter): return True return self._parameter_is_observed(function, parameter, mapping, {id(function)}, 0)