Module refinery.lib.scripts.js.analysis
Static-analysis substrate for JavaScript deobfuscation. Transforms query a shared, computed model of the program here instead of each re-deriving scope, binding, and dataflow facts on their own.
The foundation is refinery.lib.scripts.js.analysis.model, a flow-insensitive lexical model of scopes
and resolved bindings. Later layers (control-flow graphs, effect summaries) attach behind the same
representation-agnostic surface.
Expand source code Browse git
"""
Static-analysis substrate for JavaScript deobfuscation. Transforms query a shared, computed model of
the program here instead of each re-deriving scope, binding, and dataflow facts on their own.
The foundation is `model`, a flow-insensitive lexical model of scopes
and resolved bindings. Later layers (control-flow graphs, effect summaries) attach behind the same
representation-agnostic surface.
"""
from __future__ import annotations
from refinery.lib.scripts.js.analysis.cfg import (
CfgNode,
ControlFlowGraph,
ControlFlowModel,
build_cfg,
build_control_flow,
)
from refinery.lib.scripts.js.analysis.effects import (
EffectModel,
EffectSummary,
build_effects,
)
from refinery.lib.scripts.js.analysis.liveness import (
LivenessModel,
build_liveness,
)
from refinery.lib.scripts.js.analysis.model import (
Binding,
BindingKind,
Role,
Scope,
ScopeKind,
SemanticModel,
build_semantic_model,
is_use_position,
pattern_identifiers,
reference_role,
)
__all__ = [
'Binding',
'BindingKind',
'CfgNode',
'ControlFlowGraph',
'ControlFlowModel',
'EffectModel',
'EffectSummary',
'LivenessModel',
'Role',
'Scope',
'ScopeKind',
'SemanticModel',
'build_cfg',
'build_control_flow',
'build_effects',
'build_liveness',
'build_semantic_model',
'is_use_position',
'pattern_identifiers',
'reference_role',
]
Sub-modules
refinery.lib.scripts.js.analysis.cache-
A per-run cache of the JavaScript analysis models. The deobfuscation pipeline builds one cache over the script being transformed and shares it across …
refinery.lib.scripts.js.analysis.cfg-
JavaScript's contribution to the shared control-flow substrate: which node types are which control-flow shape, and where the parts of each are stored …
refinery.lib.scripts.js.analysis.dominance-
Dominance over the per-function control-flow graphs of the
SemanticModel. One node dominates another when … refinery.lib.scripts.js.analysis.effects-
Per-function effect summaries for JavaScript, computed over the
SemanticModel's resolved bindings and call … refinery.lib.scripts.js.analysis.liveness-
Flow-sensitive live-variable analysis for JavaScript, computed over the per-function control-flow graphs and the resolved bindings of the …
refinery.lib.scripts.js.analysis.model-
A lexical semantic model for JavaScript: a tree of scopes with resolved bindings and def/use sets, computed once over an AST and then queried by …
refinery.lib.scripts.js.analysis.reaching-
Reaching-value queries for JavaScript inlining, over the per-function control-flow graphs of the …
Functions
def build_cfg(owner)-
Build the control-flow graph of owner, a
JsScriptor a function node, over its own body without descending into nested function bodies.Expand source code Browse git
def build_cfg(owner: Node) -> ControlFlowGraph: """ Build the control-flow graph of *owner*, a `refinery.lib.scripts.js.model.JsScript` or a function node, over its own body without descending into nested function bodies. """ return _Builder(owner).build() def build_control_flow(root)-
Build one control-flow graph per function and for the script itself, keyed by the owner node's identity.
Expand source code Browse git
def build_control_flow(root: JsScript) -> dict[int, ControlFlowGraph]: """ Build one control-flow graph per function and for the script itself, keyed by the owner node's identity. """ return _build_control_flow(root, _Builder, FUNCTION_NODES) def build_effects(model)-
Build the
EffectModelfor a script'sSemanticModel.Expand source code Browse git
def build_effects(model: SemanticModel) -> EffectModel: """ Build the `EffectModel` for a script's `refinery.lib.scripts.js.analysis.model.SemanticModel`. """ return EffectModel(model) def build_liveness(model, control_flow=None)-
Build the
LivenessModelfor a script'sSemanticModel, reusing control_flow when the caller has one to share, or building a fresh one when it isNone.Expand source code Browse git
def build_liveness( model: SemanticModel, control_flow: ControlFlowModel | None = None, ) -> LivenessModel: """ Build the `LivenessModel` for a script's `refinery.lib.scripts.js.analysis.model.SemanticModel`, reusing *control_flow* when the caller has one to share, or building a fresh one when it is `None`. """ return LivenessModel(model, control_flow) def build_semantic_model(root)-
Build the
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) 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 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
Classes
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 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 CfgNode (graph, element, successors=<factory>, predecessors=<factory>, is_resumption_hub=False, is_hub_bound=False)-
One vertex of a control-flow graph.
elementis the AST node it stands for — a statement, or a loop-head expression whose reads and writes occur at this point — orNonefor the synthetic entry and exit.successorslists the nodes control may pass to next.graphis the body this vertex belongs to, which is what makes the questions below answerable of a node alone.is_resumption_hubmarks the synthetic fan-out standing for the over-approximate half of a resuming handler — seeCfgEdge. A walk that wants only the paths going forward declines to enter one, and declines to leave one, which makes it a projection of the graph rather than a filter on its edges. It is a fact about the node and not about the edges into it, because an edge kind is keyed by the pair of nodes it joins andCfgBuilder.add_edgebuilds a multigraph: two edges between the same pair collapse to one entry, so a plain edge drawn beside a hub edge would make the pair read as pure hub and a walk keyed on the kind would decline the plain path too. No pair of edges can disagree about what the node is.is_hub_boundmarks a node the precise, forward-only projection does not reach: it sits inside a handler body that resumes the block around it. That block's forward edges join each guarded statement to the one it resumes at; a statement of the handler itself is on neither end of one, and everything it may reach afterwards hangs off the hub. A forward edge it does carry belongs to a block further out — one whose own resuming set guards the statement this handler is written inside — and stands for that block's resumption, not this one, so it says nothing about where this handler carries on. A forward-only walk seeded here would stop dead where the real run carries on, which for a flood is the unsound direction;reachable_forward_from_anyreads this and falls back to the hub for such a source.Both are fields rather than questions asked of graph, and that is a matter of what a walk can afford as much as of where the fact lives. Every layer that had to ask them of a graph it was separately handed could be handed the wrong one — a node lifted from a nested body reads as neither against the graph around it, and the walk that asked takes the wrong branch without anything failing — and one depth-first sweep over a large script asks tens of millions of times.
graphremains because a caller naming which body it means is a contract worth checking, and becauseControlFlowGraph.hub_boundanswers the same question for a caller holding ids.eq=Falsebecause every map in every layer above is keyed byid(node), and two structurally equal statements are two distinct points in the program.repr=Falsebecause a generated repr expandssuccessorsandpredecessors, and the guard against recursion only covers the node currently being formatted, so a graph re-expands at every join: a chain of five hundred nodes — a script of five hundred statements — exhausts the interpreter's stack, and a handful of branches produces megabytes. A debugger, a failing assertion, orpytest --showlocalswould print one.Expand source code Browse git
@dataclass(eq=False, repr=False) class CfgNode: """ One vertex of a control-flow graph. `element` is the AST node it stands for — a statement, or a loop-head expression whose reads and writes occur at this point — or `None` for the synthetic entry and exit. `successors` lists the nodes control may pass to next. `graph` is the body this vertex belongs to, which is what makes the questions below answerable of a node alone. `is_resumption_hub` marks the synthetic fan-out standing for the over-approximate half of a resuming handler — see `CfgEdge`. A walk that wants only the paths going forward declines to enter one, and declines to leave one, which makes it a *projection* of the graph rather than a filter on its edges. It is a fact about the node and not about the edges into it, because an edge kind is keyed by the pair of nodes it joins and `CfgBuilder.add_edge` builds a multigraph: two edges between the same pair collapse to one entry, so a plain edge drawn beside a hub edge would make the pair read as pure hub and a walk keyed on the kind would decline the plain path too. No pair of edges can disagree about what the node is. `is_hub_bound` marks a node the precise, forward-only projection does not reach: it sits inside a handler body that resumes the block around it. That block's forward edges join each *guarded* statement to the one it resumes at; a statement of the handler itself is on neither end of one, and everything it may reach afterwards hangs off the hub. A forward edge it does carry belongs to a block further out — one whose own resuming set guards the statement this handler is written inside — and stands for that block's resumption, not this one, so it says nothing about where this handler carries on. A forward-only walk seeded here would stop dead where the real run carries on, which for a flood is the unsound direction; `reachable_forward_from_any` reads this and falls back to the hub for such a source. Both are fields rather than questions asked of *graph*, and that is a matter of what a walk can afford as much as of where the fact lives. Every layer that had to ask them of a graph it was separately handed could be handed the wrong one — a node lifted from a nested body reads as neither against the graph around it, and the walk that asked takes the wrong branch without anything failing — and one depth-first sweep over a large script asks tens of millions of times. `graph` remains because a caller naming which body it means is a contract worth checking, and because `ControlFlowGraph.hub_bound` answers the same question for a caller holding ids. `eq=False` because every map in every layer above is keyed by `id(node)`, and two structurally equal statements are two distinct points in the program. `repr=False` because a generated repr expands `successors` and `predecessors`, and the guard against recursion only covers the node currently being formatted, so a graph re-expands at every join: a chain of five hundred nodes — a script of five hundred statements — exhausts the interpreter's stack, and a handful of branches produces megabytes. A debugger, a failing assertion, or `pytest --showlocals` would print one. """ graph: ControlFlowGraph element: Node | None successors: list[CfgNode] = field(default_factory=list) predecessors: list[CfgNode] = field(default_factory=list) is_resumption_hub: bool = False is_hub_bound: bool = FalseInstance variables
var graph-
The type of the None singleton.
var element-
The type of the None singleton.
var successors-
The type of the None singleton.
var predecessors-
The type of the None singleton.
var is_resumption_hub-
The type of the None singleton.
var is_hub_bound-
The type of the None singleton.
class ControlFlowGraph (owner)-
The control-flow graph of one function or script body.
entryandexitare synthetic; every other node wraps an AST element reachable throughnode_of.Expand source code Browse git
class ControlFlowGraph: """ The control-flow graph of one function or script body. `entry` and `exit` are synthetic; every other node wraps an AST element reachable through `node_of`. """ def __init__(self, owner: Node): self.owner = owner self._node_of: dict[int, CfgNode] = {} self._edge_kinds: dict[tuple[int, int], CfgEdge] = {} self._hub_bound: set[int] = set() self._resuming = False self._fallback: dict[int, CfgNode] = {} self.entry = CfgNode(self, None) self.exit = CfgNode(self, None) self.nodes: list[CfgNode] = [self.entry, self.exit] def node_of(self, element: Node) -> CfgNode | None: """ The graph node standing for *element*, or `None` if *element* is not part of this body, or is a node the graph does not represent on its own such as a plain expression inside a statement. """ return self._node_of.get(id(element)) def fallback_of(self, handler: CfgNode) -> CfgNode | None: """ Where a throw offered to *handler* goes if *handler* does not take it, or `None` when *handler* is not a handler entry of this graph. This is recorded rather than read off the edges because the two are not the same claim. The edge is drawn only where some run may decline — a clause with a type filter, a `trap` set that may fail to match — and a handler certain to take the throw has none, which is what makes it shield whatever guards the construct. The fact holds either way, and it is what answers the *counterfactual*: not where the throw goes, but where it would go if this handler were not written at all, which is the question asked before one is deleted. """ return self._fallback.get(id(handler)) def edge_kind(self, source: CfgNode, target: CfgNode) -> CfgEdge: """ What the edge from *source* to *target* says about the run that takes it — see `CfgEdge`. An edge the builder recorded nothing for is `CfgEdge.NORMAL`, which is what an unrelated pair of nodes reads as too. """ return self._edge_kinds.get((id(source), id(target)), CfgEdge.NORMAL) def is_exceptional(self, source: CfgNode, target: CfgNode) -> bool: """ Whether the error travels along the edge from *source* to *target*: the edge into a handler offered the throw, or outward from a set that declined it. This is the question `faults` asks — where an error goes — and it is *narrower* than `raise_taken`, because a handler that resumes swallows the error and carries on along an edge no error travels. """ return bool(self.edge_kind(source, target) & CfgEdge.ERROR_CARRYING) def raise_taken(self, source: CfgNode, target: CfgNode) -> bool: """ Whether the edge from *source* to *target* is taken only when *source* throws rather than completing normally. A definition *source* makes is not guaranteed to have happened along such an edge, so a flow-sensitive analysis must not treat it as a kill there. This is the question about *completion*, and it is the one nearly every consumer means. A resumption edge answers it although no error travels along it: the statement that resumed the block is precisely the one that did not finish. """ return bool(self.edge_kind(source, target) & RAISE_TAKEN) @property def hub_bound(self) -> Set[int]: """ The ids of the nodes `CfgNode.is_hub_bound` is set on, for a caller holding node *ids* rather than nodes. Written beside the field by `CfgBuilder.mark_hub_bound`, which is the one place either is recorded. `refinery.lib.scripts.analysis.reaching.ReachabilityQuery` is one: its candidate sets are ids because the layers above memoize them that way, and turning them back into nodes to ask each one is what those caches exist to avoid. """ return self._hub_bound @property def carries_resumption(self) -> bool: """ Whether any handler of this body resumes the block it guards, which is the only shape over which the two projections of `CfgEdge` differ. A body with none — every script that writes no `trap`, and every one whose traps rethrow — draws no resumption edge and marks no node hub-bound, so a forward walk over it answers exactly what `reachable_from_any` does and may be answered by the cheaper one. """ return self._resumingInstance variables
var hub_bound-
The ids of the nodes
CfgNode.is_hub_boundis set on, for a caller holding node ids rather than nodes. Written beside the field byCfgBuilder.mark_hub_bound, which is the one place either is recorded.ReachabilityQueryis one: its candidate sets are ids because the layers above memoize them that way, and turning them back into nodes to ask each one is what those caches exist to avoid.Expand source code Browse git
@property def hub_bound(self) -> Set[int]: """ The ids of the nodes `CfgNode.is_hub_bound` is set on, for a caller holding node *ids* rather than nodes. Written beside the field by `CfgBuilder.mark_hub_bound`, which is the one place either is recorded. `refinery.lib.scripts.analysis.reaching.ReachabilityQuery` is one: its candidate sets are ids because the layers above memoize them that way, and turning them back into nodes to ask each one is what those caches exist to avoid. """ return self._hub_bound var carries_resumption-
Whether any handler of this body resumes the block it guards, which is the only shape over which the two projections of
CfgEdgediffer. A body with none — every script that writes notrap, and every one whose traps rethrow — draws no resumption edge and marks no node hub-bound, so a forward walk over it answers exactly whatreachable_from_anydoes and may be answered by the cheaper one.Expand source code Browse git
@property def carries_resumption(self) -> bool: """ Whether any handler of this body resumes the block it guards, which is the only shape over which the two projections of `CfgEdge` differ. A body with none — every script that writes no `trap`, and every one whose traps rethrow — draws no resumption edge and marks no node hub-bound, so a forward walk over it answers exactly what `reachable_from_any` does and may be answered by the cheaper one. """ return self._resuming
Methods
def node_of(self, element)-
The graph node standing for element, or
Noneif element is not part of this body, or is a node the graph does not represent on its own such as a plain expression inside a statement.Expand source code Browse git
def node_of(self, element: Node) -> CfgNode | None: """ The graph node standing for *element*, or `None` if *element* is not part of this body, or is a node the graph does not represent on its own such as a plain expression inside a statement. """ return self._node_of.get(id(element)) def fallback_of(self, handler)-
Where a throw offered to handler goes if handler does not take it, or
Nonewhen handler is not a handler entry of this graph.This is recorded rather than read off the edges because the two are not the same claim. The edge is drawn only where some run may decline — a clause with a type filter, a
trapset that may fail to match — and a handler certain to take the throw has none, which is what makes it shield whatever guards the construct. The fact holds either way, and it is what answers the counterfactual: not where the throw goes, but where it would go if this handler were not written at all, which is the question asked before one is deleted.Expand source code Browse git
def fallback_of(self, handler: CfgNode) -> CfgNode | None: """ Where a throw offered to *handler* goes if *handler* does not take it, or `None` when *handler* is not a handler entry of this graph. This is recorded rather than read off the edges because the two are not the same claim. The edge is drawn only where some run may decline — a clause with a type filter, a `trap` set that may fail to match — and a handler certain to take the throw has none, which is what makes it shield whatever guards the construct. The fact holds either way, and it is what answers the *counterfactual*: not where the throw goes, but where it would go if this handler were not written at all, which is the question asked before one is deleted. """ return self._fallback.get(id(handler)) def edge_kind(self, source, target)-
What the edge from source to target says about the run that takes it — see
CfgEdge. An edge the builder recorded nothing for isCfgEdge.NORMAL, which is what an unrelated pair of nodes reads as too.Expand source code Browse git
def edge_kind(self, source: CfgNode, target: CfgNode) -> CfgEdge: """ What the edge from *source* to *target* says about the run that takes it — see `CfgEdge`. An edge the builder recorded nothing for is `CfgEdge.NORMAL`, which is what an unrelated pair of nodes reads as too. """ return self._edge_kinds.get((id(source), id(target)), CfgEdge.NORMAL) def is_exceptional(self, source, target)-
Whether the error travels along the edge from source to target: the edge into a handler offered the throw, or outward from a set that declined it. This is the question
faultsasks — where an error goes — and it is narrower thanraise_taken, because a handler that resumes swallows the error and carries on along an edge no error travels.Expand source code Browse git
def is_exceptional(self, source: CfgNode, target: CfgNode) -> bool: """ Whether the error travels along the edge from *source* to *target*: the edge into a handler offered the throw, or outward from a set that declined it. This is the question `faults` asks — where an error goes — and it is *narrower* than `raise_taken`, because a handler that resumes swallows the error and carries on along an edge no error travels. """ return bool(self.edge_kind(source, target) & CfgEdge.ERROR_CARRYING) def raise_taken(self, source, target)-
Whether the edge from source to target is taken only when source throws rather than completing normally. A definition source makes is not guaranteed to have happened along such an edge, so a flow-sensitive analysis must not treat it as a kill there.
This is the question about completion, and it is the one nearly every consumer means. A resumption edge answers it although no error travels along it: the statement that resumed the block is precisely the one that did not finish.
Expand source code Browse git
def raise_taken(self, source: CfgNode, target: CfgNode) -> bool: """ Whether the edge from *source* to *target* is taken only when *source* throws rather than completing normally. A definition *source* makes is not guaranteed to have happened along such an edge, so a flow-sensitive analysis must not treat it as a kill there. This is the question about *completion*, and it is the one nearly every consumer means. A resumption edge answers it although no error travels along it: the statement that resumed the block is precisely the one that did not finish. """ return bool(self.edge_kind(source, target) & RAISE_TAKEN)
class ControlFlowModel (graphs)-
The per-body control-flow graphs of one script, paired with the
ElementLocatorthat maps any AST node to the graph node evaluating it. Built once over the script root — the graphs are purely syntactic and need no semantic model — and shared by every solver layered on it, which would otherwise each rebuild the whole set.Expand source code Browse git
class ControlFlowModel: """ The per-body control-flow graphs of one script, paired with the `ElementLocator` that maps any AST node to the graph node evaluating it. Built once over the script root — the graphs are purely syntactic and need no semantic model — and shared by every solver layered on it, which would otherwise each rebuild the whole set. """ def __init__(self, graphs: dict[int, ControlFlowGraph]): self.graphs = graphs self._locator = ElementLocator(graphs) def graph_of(self, owner: Node) -> ControlFlowGraph | None: """ The control-flow graph owned by *owner* — a function node or the script root — or `None` when it owns none. """ return self.graphs.get(id(owner)) def node_of(self, element: Node) -> CfgNode | None: """ The control-flow node standing for *element*, or `None` when the graphs do not represent it directly. Delegates to the shared `ElementLocator`. """ return self._locator.node_of(element) def locate(self, element: Node) -> tuple[ControlFlowGraph, CfgNode] | None: """ The graph and node that evaluate *element*, climbing out of any enclosing expression, or `None` when it has no enclosing graph node. Delegates to the shared `ElementLocator`. """ return self._locator.locate(element)Methods
def graph_of(self, owner)-
The control-flow graph owned by owner — a function node or the script root — or
Nonewhen it owns none.Expand source code Browse git
def graph_of(self, owner: Node) -> ControlFlowGraph | None: """ The control-flow graph owned by *owner* — a function node or the script root — or `None` when it owns none. """ return self.graphs.get(id(owner)) def node_of(self, element)-
The control-flow node standing for element, or
Nonewhen the graphs do not represent it directly. Delegates to the sharedElementLocator.Expand source code Browse git
def node_of(self, element: Node) -> CfgNode | None: """ The control-flow node standing for *element*, or `None` when the graphs do not represent it directly. Delegates to the shared `ElementLocator`. """ return self._locator.node_of(element) def locate(self, element)-
The graph and node that evaluate element, climbing out of any enclosing expression, or
Nonewhen it has no enclosing graph node. Delegates to the sharedElementLocator.Expand source code Browse git
def locate(self, element: Node) -> tuple[ControlFlowGraph, CfgNode] | None: """ The graph and node that evaluate *element*, climbing out of any enclosing expression, or `None` when it has no enclosing graph node. Delegates to the shared `ElementLocator`. """ return self._locator.locate(element)
class EffectModel (model)-
Per-function effect summaries for one script, built over a
SemanticModel. Query a function's summary withsummary_ofand a call expression's purity withis_pure_call. Build throughbuild_effects().Expand source code Browse git
class EffectModel: """ Per-function effect summaries for one script, built over a `refinery.lib.scripts.js.analysis.model.SemanticModel`. Query a function's summary with `summary_of` and a call expression's purity with `is_pure_call`. Build through `build_effects`. """ def __init__(self, model: SemanticModel): self.model = model self.intrinsics_pristine = _intrinsics_pristine(model) self.global_pristine = _global_pristine(model) self._globals_written, self._global_keys_written = _global_writes_by_name(model) self._summaries: dict[int, EffectSummary] = {} self._confine_cache: dict[int, Node | None] = {} self._immutable_cache: dict[tuple[int, bool], bool] = {} self._member_write_cache: dict[int, _WriteClass] = {} self._uses_arguments_cache: dict[int, bool] = {} self._mutators_escape_cache: dict[int, bool] = {} self._some_mutator_cache: dict[int, bool] = {} self._functions: list[Node] = self._collect_functions() self._compute() def summary_of(self, func: Node) -> EffectSummary: """ The effect summary of a function node (or the script). An unknown node is reported as impure. """ return self._summaries.get(id(func), EffectSummary(calls_unknown=True)) def mutated_bindings(self, func: Node) -> frozenset[Binding]: """ The outer bindings (captured locals and globals) a call to *func* may write, directly or through any function it transitively calls, each identified by its `Binding` rather than its name so a caller can ask whether one specific binding is mutated. Empty for a function with no such writes and for an unknown node alike — use `summary_of(func).calls_unknown` to tell those apart. """ return frozenset(self.summary_of(func).written_bindings) def function_can_mutate(self, func: Node, binding: Binding) -> bool: """ Whether a call to *func* may write *binding*, itself or through a transitive callee. """ return binding in self.summary_of(func).written_bindings def function_escapes(self, func: Node) -> bool: """ Whether *func* may be invoked at a point the surrounding scope cannot enumerate as a resolvable `name(...)` call site: an anonymous function (an IIFE, a callback, stored and called later), or a named function whose binding is reassigned, redeclared, or referenced anywhere other than as the callee of a direct call (aliased, passed as an argument, `f.call(...)`). A reference inside a dynamic scope — a name a `with` body resolves at runtime — counts too: the model cannot order or resolve it, so the function may be invoked or aliased there with no static call site. A call to such a function can land at a point no call site pins down; a function only ever called directly by name has all its invocations enumerated by those call sites. """ binding = self.model.naming_binding(func) if binding is None: return True if binding.writes or binding.dynamic_refs or len(binding.declarations) != 1: return True for ref in self.model.references(binding): parent = ref.parent if isinstance(parent, JsCallExpression) and parent.callee is ref: continue return True return False def mutators_escape(self, binding: Binding) -> bool: """ Whether some function that may write *binding* — itself or through a transitive callee — escapes (`function_escapes`), so a write to *binding* may occur at a point no call site enumerates. When true, the places *binding* changes cannot be pinned down, and a caller reasoning about where its value survives must treat it as volatile everywhere. Memoized per binding. """ cached = self._mutators_escape_cache.get(id(binding)) if cached is None: cached = any( func is not self.model.root and binding in self.summary_of(func).written_bindings and self.function_escapes(func) for func in self._functions ) self._mutators_escape_cache[id(binding)] = cached return cached def some_function_can_mutate(self, binding: Binding) -> bool: """ Whether any function this file writes may write *binding*, itself or through a transitive callee. The answer a caller needs where a call runs a function it cannot name: not knowing which one runs, it has to reckon with every one that could. Memoized per binding. """ cached = self._some_mutator_cache.get(id(binding)) if cached is None: cached = any( func is not self.model.root and self.function_can_mutate(func, binding) for func in self._functions ) self._some_mutator_cache[id(binding)] = cached return cached def is_pure_call(self, call: JsCallExpression | JsNewExpression) -> bool: """ Whether evaluating *call* has no observable effect: it invokes a trusted pure intrinsic (under the pristine-intrinsics precondition) or a local function whose summary is pure. """ callee = self._resolve_callee(call) if callee is _PURE: return True if isinstance(callee, Node): return self.summary_of(callee).is_pure return False def is_pure_call_discarded(self, call: JsCallExpression | JsNewExpression) -> bool: """ Whether evaluating *call* and discarding its result has no observable effect. Like `is_pure_call` but resolved through `EffectSummary.is_effect_free_when_discarded`, so a callee whose only residual effect is a write it confines to its returned value qualifies — that write is unobservable once the result is thrown away. A caller may use this only in a position it has proven discards the value. """ callee = self._resolve_callee(call) if callee is _PURE: return True if isinstance(callee, Node): return self.summary_of(callee).is_effect_free_when_discarded return False def call_clearable( self, call: JsCallExpression | JsNewExpression, callee_established: Callable[[Node], bool], ) -> bool: """ Whether *call*'s callee is established — in place before the call runs — given *callee_established*, the caller's test for a resolved named local callee. A trusted pure intrinsic and an inline function-expression callee (defined at the call site, hence always in place) qualify unconditionally; a call resolving to a single named local function qualifies when *callee_established* accepts it; an unresolved or ambiguous callee does not. The resolution, the intrinsic case, and the inline-callee case live here so callers supply only the ordering judgment their layer can make. This certifies establishment ONLY, not purity — a caller deciding whether a call may be dropped must conjoin it with `is_pure_call`, as `side_effect_free` does, since an established callee may still run an effectful body. """ resolved = self._resolve_callee(call) if resolved is _PURE: return True if isinstance(resolved, Node): if isinstance(strip_parens(call.callee), (JsFunctionExpression, JsArrowFunctionExpression)): return True return callee_established(resolved) return False def _established_call_default(self, call: JsCallExpression | JsNewExpression) -> bool: """ The ordering-free floor for `is_side_effect_free`: clears a trusted pure intrinsic, an inline function-expression callee (established at its call site), or a call to a hoisted function declaration (empty `establishment_sites`), whose value is in place before any statement runs. A non-hoisted named local callee — a `const`/`let`/`var` initializer or a bare assignment — is refused, since this model cannot order the definition against the call; a caller that can supplies its own `call_established`. """ return self.call_clearable(call, lambda func: self.model.establishment_sites(func) == []) def is_side_effect_free( self, node: Node, defunct: set[str] | None = None, member_safe: Callable[[JsMemberExpression], bool] | None = None, call_established: Callable[[JsCallExpression | JsNewExpression], bool] | None = None, discarded: bool = False, ) -> bool: """ Whether evaluating *node* can be dropped or reordered without an observable side effect, with the call leaf resolved through this model's `is_pure_call`: a call to a proven-pure function or trusted intrinsic is free, recursing into its arguments. *defunct* names bindings being removed, whose calls and property reads are treated as free. This is the model-aware form of the model-free `side_effect_free` in this module, which clears only calls to a defunct name; unlike it, an identifier read that resolves through a `with` body's dynamic scope is rejected here — reading the bare name may fire the `with` object's getter or throw (see `refinery.lib.scripts.js.analysis.model.SemanticModel.read_has_dynamic_effect`) — while a function value whose body performs such a read stays free, since defining it runs nothing. A caller with control-flow context passes *member_safe* to also clear a getter-free read through a local global-object alias it can prove established before the read; the default clears only the syntactic global case (`_is_trusted_global_read`). With *discarded* the caller asserts *node*'s own value is thrown away, so a top-level call leaf is cleared through `is_pure_call_discarded` and a callee that only mutates a local it returns is droppable — the removal contexts of `JsUnusedCodeRemoval` supply it. """ return side_effect_free( node, defunct, self.is_pure_call, self.model.read_has_dynamic_effect, member_safe or self._getter_free_read, call_established or self._established_call_default, discarded, self.is_pure_call_discarded, ) def binding_is_immutable_container( self, binding: Binding, *, member_calls_mutate: bool = True, exclude: Node | None = None, ) -> bool: """ Whether *binding* holds a container — an object or array — whose element and property values are stable after construction, so that an access into it may be soundly inlined at its read sites. Every reference must read through the container (`obj.k`, `obj[i]`) or plainly rebind the name (`obj = ...`, whose value the caller resolves by domination); a write through the container (`obj.k = v`, `obj[i]++`, `delete obj[i]`, a `for-of` or destructuring target) makes it mutable. A method invoked on the container (`obj.m(...)`) may mutate it — an array's `sort`/`push`/`splice` and so on — so by default it too counts as mutable; a caller that knows the container's methods cannot mutate it (an object literal with no `this`-bound property) may pass *member_calls_mutate* false to permit such calls. A reference that escapes is safe in two cases: it aliases another binding that is itself an immutable container (alias-following the textual predicates this replaces could not do, and the reason a reassigned-and-aliased lookup array stays inlinable), or it is passed to a statically known function as an argument whose parameter is itself an immutable container (so the callee neither mutates nor further-escapes it). Any other escape — returned, stored as a property, passed to a call that cannot be resolved — is treated conservatively as mutable. A mutation through a dynamic scope is modelled: a `with` body that names the container — a member write, method call, reassignment, or escape — is attributed to it as a dynamic reference and judged by the same role logic, so a `with` that never names it keeps it foldable, and a direct `eval` in a local container's own function makes it mutable. The one residual is a script-scope container reached by an opaque global surface — a direct `eval`, `Function`, timer, or dynamic global write whose code cannot be read — which cannot be frozen without also freezing the lookup arrays real samples fold, so it is left to the caller's reflection reasoning, the trust an unresolved external call already receives. The query is over a *resolved binding*, so it is shadowing-correct, and it descends through alias chains, callee parameters, and nested functions, so a capturing closure that mutates the container is caught. The answer is fixed for the model's lifetime — a binding's reference set does not change — so it is memoized per `(binding, member_calls_mutate)`. A caller may pass *exclude* to disregard references within that subtree — asking whether the container is stable across the rest of the program, ignoring a read site about to be relocated into it; such a query is not memoized, since the answer depends on the excluded region. """ if exclude is not None: return self._immutable_container(binding, set(), member_calls_mutate, exclude) key = (id(binding), member_calls_mutate) cached = self._immutable_cache.get(key) if cached is None: cached = self._immutable_container(binding, set(), member_calls_mutate) self._immutable_cache[key] = cached return cached def _immutable_container( self, binding: Binding, visiting: set[int], member_calls_mutate: bool, exclude: Node | None = None, ) -> bool: key = id(binding) if key in visiting: return True visiting = visiting | {key} if self._dynamic_scope_mutates(binding, member_calls_mutate, exclude): return False for ref in self.model.references(binding, exclude=exclude): role = container_reference_role(ref) if role is ContainerRole.MEMBER_WRITE: return False if role is ContainerRole.MEMBER_CALL and member_calls_mutate: return False if role is ContainerRole.ESCAPE: if not isinstance(ref, JsIdentifier) or not self._escape_keeps_container( ref, visiting, member_calls_mutate, ): return False return True def _dynamic_scope_mutates( self, binding: Binding, member_calls_mutate: bool, exclude: Node | None, ) -> bool: """ Whether a dynamic scope may change the container *binding* holds. A direct `eval` in a local container's own function can rewrite it opaquely — a global is left to the caller's reflection reasoning, since freezing every global on any surface over-blocks. A `with` body's accesses are attributed by name: a member write, a reassignment, or an escape mutates it or may alias it out, and a method call may mutate it unless the caller vouches that its methods cannot; only a plain member read leaves it intact, so a `with` that never names the container is no threat. A dynamic escape or reassignment cannot be alias-followed or ordered the way a resolved one can, so either is treated as mutating. """ if self.model.local_reachable_by_direct_eval(binding): return True for ref in self.model.dynamic_references(binding, exclude=exclude): role = container_reference_role(ref) if role is ContainerRole.MEMBER_READ: continue if role is ContainerRole.MEMBER_CALL and not member_calls_mutate: continue return True return False def _escape_keeps_container(self, ref: JsIdentifier, visiting: set[int], member_calls_mutate: bool) -> bool: """ Whether an escaping reference leaves the container unmutated. Two escapes are precise: an alias (`var x = ref` or `x = ref`) keeps it when the aliased binding is itself an immutable container, and an argument passed to a statically known function (`f(ref)`) keeps it when the parameter it binds is itself an immutable container — interprocedural Case B, the parameter's own references decide whether the callee mutates or further-escapes it. Every other escape is conservatively unsafe. """ alias = self._alias_target(ref) if alias is not None: return self._immutable_container(alias, visiting, member_calls_mutate) return self._argument_keeps_container(ref, visiting) def _argument_keeps_container(self, ref: JsIdentifier, visiting: set[int]) -> bool: """ Case B: whether an argument *ref* passed to a statically known function leaves the container it holds unmutated — true when the parameter it binds is itself an immutable container, judged recursively from that parameter's own references, so the callee neither member-writes the argument nor lets it escape mutably. The parameter is judged under the conservative `member_calls_mutate=True`: a relaxed `member_calls_mutate=False` is the *caller*'s promise that the container's own methods cannot mutate it at the original site, and does not carry to a method the callee invokes on the argument or on one of its nested containers (`x.a.push(...)`), which may mutate it. False, conservatively, when the call cannot be analysed: the callee is not a single known function, it can reach the argument through its own `arguments` object, the argument is spread, a spread precedes it (so its runtime position shifts past the textual index and the parameter it binds cannot be pinned down), the slot it lands in is a rest or destructuring parameter, or the parameter is reachable through a `with` or direct `eval` in the callee that resolves a name at runtime (an unrecorded write the parameter's reference set cannot rule out). An argument with no parameter to bind — passed beyond the declared parameters of a function with no rest collector and no `arguments` reach, textual or reflective — is safe, since the callee cannot name it. """ parent = ref.parent if not isinstance(parent, JsCallExpression) or ref not in parent.arguments: return False func = self.unambiguous_callee(parent) if func is None: return False if self._callee_uses_arguments(func): return False params = func.params if any(isinstance(param, JsRestElement) for param in params): return False index = parent.arguments.index(ref) if any(isinstance(arg, JsSpreadElement) for arg in parent.arguments[:index]): return False if index >= len(params): return True param = params[index] if not isinstance(param, JsIdentifier): return False binding = self.model.binding_of(param) if binding is None: return False if self.model.reflection_can_reach(binding): return False return self._immutable_container(binding, visiting, True) def _callee_uses_arguments(self, func: Node) -> bool: """ Whether a non-arrow callee can reach its call's arguments through its own `arguments` object, which aliases the positional arguments — including any passed beyond the declared parameters — so that `arguments[i][...] = v` mutates a container the by-position parameter reasoning in `_argument_keeps_container` would otherwise miss. It is reached either by naming `arguments` directly, or reflectively: a `with` or a direct `eval` in the callee — or in a closure nested inside it, which inherits the callee's `arguments` — can read that object with no textual reference, so a reflectively reachable `arguments` counts too. An arrow has no `arguments` of its own (a reference inside it binds the enclosing function's, unrelated to the arrow's parameters), so it is exempt. When the callee can reach `arguments`, the escape is treated as mutable. The answer is a structural property of the callee, so it is memoized per function. """ cached = self._uses_arguments_cache.get(id(func)) if cached is None: cached = self._compute_callee_uses_arguments(func) self._uses_arguments_cache[id(func)] = cached return cached def _compute_callee_uses_arguments(self, func: Node) -> bool: if isinstance(func, JsArrowFunctionExpression): return False func_scope = self.model.parameter_scope(func) if func_scope is None: return False binding = func_scope.bindings.get('arguments') if binding is None: return False if self.model.references(binding): return True return self.model.reflection_can_reach(binding) def static_callee( self, call: JsCallExpression ) -> JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression | None: """ The function a call invokes, resolved permissively through `function_of`: a direct function or arrow expression callee, or an identifier bound to a single function — a declaration, a `var`/`let`/`const` initializer, or the value a name is assigned exactly once. For a name that held a value and was then reassigned this returns the post-reassignment value, which is the running target only where that reassignment is established before the call; a consumer that cannot order the reassignment against the call must use `unambiguous_callee` instead. `None` for a method call, a parameter, a redeclared or dynamically-rebindable binding, or an unresolved name. """ callee = call.callee if isinstance(callee, (JsFunctionExpression, JsArrowFunctionExpression)): return callee if not isinstance(callee, JsIdentifier): return None return self.function_of(self.model.resolve(callee)) def a_name_this_file_binds_holds_the_callee(self, call: JsCallExpression) -> bool: """ Whether *call* names its callee with an identifier this file binds, while `static_callee` declines to say which function that binding holds. A caller reasoning about what a call may have done reads `static_callee` answering `None` in two ways, and this tells them apart. Where the callee is a method, a host function, or a name nothing here declares, `None` means the call runs something outside this file's reckoning, which is a standing condition every such caller was written under. Where it is a name this file binds, `None` means the model saw the binding and would not state its value - one Annex B copies into a block's enclosing scope is such a function - and what it runs may be any of the ones written here, one that writes the very binding being reasoned about included. """ callee = strip_parens(call.callee) if not isinstance(callee, JsIdentifier): return False if self.model.resolve(callee) is None: return False return self.static_callee(call) is None def unambiguous_callee( self, call: JsCallExpression ) -> JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression | None: """ The ordering-free twin of `static_callee`, for a consumer that reasons about a call without knowing where it sits in execution order. Identical except an identifier callee resolves through `unambiguous_function`, so a name that held a value and was then reassigned — whose running target depends on the call's position relative to the reassignment — yields `None` rather than the post-reassignment value. """ return _unambiguous_callee(self.model, call) def _alias_target(self, ref: JsIdentifier) -> Binding | None: parent = ref.parent if isinstance(parent, JsVariableDeclarator) and parent.init is ref: if isinstance(parent.id, JsIdentifier): return self.model.binding_of(parent.id) return None if ( isinstance(parent, JsAssignmentExpression) and parent.right is ref and parent.operator == '=' and isinstance(parent.left, JsIdentifier) ): return self.model.resolve(parent.left) return None def _collect_functions(self) -> list[Node]: functions: list[Node] = [self.model.root] for node in self.model.root.walk(): if isinstance(node, FUNCTION_NODES): functions.append(node) return functions def _compute(self): for func in self._functions: self._summaries[id(func)] = EffectSummary() changed = True while changed: changed = False for func in self._functions: summary = self._scan(func) if summary != self._summaries[id(func)]: self._summaries[id(func)] = summary changed = True def _scan(self, func: Node) -> EffectSummary: summary = EffectSummary() if isinstance(func, FUNCTION_NODES) and wraps_return(func): summary.wraps_return = True for node in _body_nodes(func): if isinstance(node, JsThrowStatement): summary.throws = True elif isinstance(node, JsIdentifier): if not summary.throws and self.model.read_may_throw(node): summary.throws = True if reference_role(node) is not Role.READ: self._account_write(summary, node, func) elif isinstance(node, JsMemberExpression): base = node.object if base is not None and not self._base_is_safe(base): summary.throws = True if is_member_write_target(node): write_class = self._member_write_class(node, func) if write_class is _WriteClass.OBSERVABLE: summary.writes_global = True elif write_class is _WriteClass.VIA_RESULT: summary.mutates_returned_local = True elif base is not None and not self._getter_free_read(node): summary.calls_unknown = True elif isinstance(node, (JsCallExpression, JsNewExpression)): self._account_call(summary, node) elif isinstance(node, JsImportExpression): summary.calls_unknown = True return summary def _account_write(self, summary: EffectSummary, target: JsIdentifier, func: Node): binding = self.model.resolve(target) if binding is None: summary.writes_global = True return if self._owns_binding(binding, func): return if binding.is_read: summary.written_bindings.add(binding) if self._write_unobservable(binding, func): return if binding.kind is BindingKind.IMPLICIT_GLOBAL or binding.scope is self.model.root_scope: summary.writes_global = True else: summary.writes_captured = True def _write_unobservable(self, binding: Binding, func: Node) -> bool: """ Whether assigning *binding* within *func* has no observable consumer, so the assignment is not counted as a write. The program must be `global_pristine`: it exposes no reflection surface through which the name could be read and installs no accessor that an assignment to a global property could trigger as a setter. Then the write is unobservable when either the value is read nowhere (`Binding.is_read` is false), or every reference to it is `_confined_to` *func* so no outside code can see it. This ports the evaluator's sound permissiveness for an obfuscator's scratch binding — whether a write-only global or an accumulator local to a single function. Not counting the write is not the same as answering that the function is pure. A confined accumulator whose name is an implicit global is also *read*, and `SemanticModel.read_may_throw` answers that such a read may throw, since nothing here orders the creating assignment in front of it; the summary then carries `throws` with `writes_global` clear. """ if not self.global_pristine: return False return not binding.is_read or self._confined_to(binding, func) def _confined_to(self, binding: Binding, func: Node) -> bool: """ Whether every reference to *binding* lies within *func*, which must be a function rather than the script, so the binding does not escape: no code outside *func* can read it, and a write to it is unobservable past the single call. """ if not isinstance(func, FUNCTION_NODES): return False return self._confining_function(binding) is func def _member_write_class(self, member: JsMemberExpression, func: Node) -> _WriteClass: """ How observable the container written by *member* (`base.k = v`, `base[i]++`, `delete base[i]`) is to code outside *func* — the distinction that lets a mutation of an obfuscator's scratch container be tolerated without weakening purity. The base must be a fresh value: written directly on an object/array/function literal, or resolving to a binding *func* owns whose value is always freshly built — a rest parameter, which the language guarantees is a new array, or a local initialized only to an object/array/function literal. An object literal with an own setter — or one that installs a custom prototype through `__proto__:`, which may carry an inherited setter — does NOT qualify, since the write then runs an accessor a caller can observe. A plain parameter does NOT qualify either: it aliases the caller's object, so `function modify(a){ a[0] = 9; }` mutates the argument observably — the soundness boundary this rests on. Ownership is the exact test `_account_write` uses for a plain-identifier write (`func_scope.contains(binding.scope)` and not global), so a binding captured *from an enclosing scope* is not owned and its mutation stays `OBSERVABLE`, matching that a call mutating an outer local is a visible effect. For an owned fresh container the outcome splits on how it escapes. When no reference lets it out (`_container_non_escaping`) and no nested function captures it, the write is `UNOBSERVABLE` — the container dies with the call and no caller can ever reach it, so a function whose only effect is the mutation is pure. Otherwise the container — or a closure over it — leaves *func*, but every escape route other than the return value independently sets a blocking flag on the summary (a store to a global or captured binding, a leak into an unknown callee, a throw), so the only unflagged escape is `return`, whose value the caller may discard: the write is then `VIA_RESULT`, seen only if that value is used. A write hidden behind a dynamic scope — through a name a `with` body or direct `eval` resolves at runtime — is `OBSERVABLE`: the base resolves to no binding, so the write is conservatively kept, which is sound. The residual is the opaque-surface one `binding_is_immutable_container` documents: a reflective surface whose code cannot be read could install a prototype accessor that observes a write this deems unobservable, and freezing on it would refuse the obfuscator idioms this is meant to see through, so it is left to that boundary. The judgment is structural — fixed by the binding's declarations and reference set — so it is invariant across the fixpoint passes that recompute the summaries, and is memoized per member. """ cached = self._member_write_cache.get(id(member)) if cached is None: cached = self._classify_member_write(member, func) self._member_write_cache[id(member)] = cached return cached def _classify_member_write(self, member: JsMemberExpression, func: Node) -> _WriteClass: base = member.object if isinstance(base, (JsArrayExpression, JsFunctionExpression)): return _WriteClass.UNOBSERVABLE if isinstance(base, JsObjectExpression): if object_member_access_runs_accessor(base): return _WriteClass.OBSERVABLE return _WriteClass.UNOBSERVABLE if not isinstance(base, JsIdentifier): return _WriteClass.OBSERVABLE binding = self.model.resolve(base) if binding is None or not self._owns_binding(binding, func): return _WriteClass.OBSERVABLE if not self._fresh_container_origin(binding, func): return _WriteClass.OBSERVABLE if not binding.captured and self._container_non_escaping(binding): return _WriteClass.UNOBSERVABLE return _WriteClass.VIA_RESULT def _owns_binding(self, binding: Binding, func: Node) -> bool: """ Whether *binding* is declared within *func* rather than reaching in from an enclosing scope or the global object — the exact ownership test `_account_write` applies to a plain-identifier write, so a mutation of an owned local and a mutation of its name agree on observability. A binding *func* owns has all its references inside *func*'s subtree, so the summary scan sees every one of its escapes. A function whose parameter list holds an expression introduces its parameters and its own name in scopes standing *outside* its body's, so containment alone would read a write to its own parameter as a write reaching in from elsewhere. `Scope.closure_home` says those scopes are the same call as the body, and answers for all three shapes a function is built in. """ if binding.kind is BindingKind.IMPLICIT_GLOBAL or binding.scope is self.model.root_scope: return False func_scope = self.model.function_scope(func) if func_scope is None: return False return func_scope.contains(binding.scope) or binding.scope.closure_home is func_scope def _fresh_container_origin(self, binding: Binding, func: Node) -> bool: """ Whether *binding* only ever holds a container freshly built inside *func*, so a member write through it cannot be observed anywhere else. The binding-level face of `_fresh_kind`; see that method. """ return self._binding_fresh_kind(binding, func, frozenset()).is_fresh def _binding_fresh_kind( self, binding: Binding, func: Node, visiting: frozenset[int] ) -> _FreshKind: """ The kind of container *binding* is known to hold on every path, judged from inside *func*. A rest parameter is a fresh array by language guarantee. Otherwise the binding must be one *func* owns — a name reaching in from an enclosing scope or the global object denotes a container other code can already reach, however freshly its initializer built it — and *every* value it can take must be fresh: each declaration's initializer and each later write, because a name that holds an outer container even once makes a write through it observable there. A binding written through a pattern rather than a plain assignment target has no single value expression to judge, so it fails. A binding whose `constructor` or `__proto__` is written anywhere is never an `ARRAY`, however it was built. Those two properties decide what an allocating `Array.prototype` method actually returns: `slice` and its neighbours route through ArraySpeciesCreate, which reads `constructor[Symbol.species]` off the receiver, so a program that writes either one can make the "new" array be a shared object — or make the call throw, by leaving a primitive there. The container is still fresh, so a write *into* it stays unobservable; it is only the array guarantee that is lost. The kind is the weakest of the values, since a consumer may only rely on what holds for all of them. """ if self._is_rest_param(binding): return _FreshKind.ARRAY if binding.kind not in (BindingKind.VAR, BindingKind.LET, BindingKind.CONST): return _FreshKind.NOT_FRESH if not self._owns_binding(binding, func): return _FreshKind.NOT_FRESH if not binding.declarations or id(binding) in visiting: return _FreshKind.NOT_FRESH visiting = visiting | {id(binding)} kind = _FreshKind.ARRAY if self._species_written(binding): kind = _FreshKind.CONTAINER for decl in binding.declarations: declarator = decl.parent if not isinstance(declarator, JsVariableDeclarator): return _FreshKind.NOT_FRESH kind = _weakest(kind, self._fresh_kind(declarator.init, func, visiting)) if not kind.is_fresh: return _FreshKind.NOT_FRESH for ref in self.model.references(binding): if reference_role(ref) is Role.READ: continue parent = ref.parent if not isinstance(parent, JsAssignmentExpression) or parent.left is not ref: return _FreshKind.NOT_FRESH if parent.operator != '=': return _FreshKind.NOT_FRESH kind = _weakest(kind, self._fresh_kind(parent.right, func, visiting)) if not kind.is_fresh: return _FreshKind.NOT_FRESH return kind def _species_written(self, binding: Binding) -> bool: """ Whether any reference to *binding* writes a property that decides what an allocating `Array.prototype` method returns. Only `constructor` and `__proto__` do: ArraySpeciesCreate reads `constructor[Symbol.species]` off the receiver, and `__proto__` replaces the prototype the `constructor` lookup walks. A key that is not a literal counts, because it may name either one. That conservatism is affordable here and would not be in a whole-program rule: the question is asked about the handful of references to one binding, so an ordinary `s[i] = v` element write on a *different* binding is untouched. It is also the direction that survives constant folding — a fold turns `a['const' + 'ructor']` into `a.constructor`, moving the answer from unsafe to unsafe rather than from safe to unsafe. """ for ref in self.model.references(binding): parent = ref.parent if not isinstance(parent, JsMemberExpression) or parent.object is not ref: continue if not is_member_write_target(parent): continue prop = parent.property if not parent.computed: if isinstance(prop, JsIdentifier) and prop.name in _SPECIES_KEYS: return True elif isinstance(prop, JsStringLiteral): if prop.value in _SPECIES_KEYS: return True elif not isinstance(prop, JsNumericLiteral): return True return False def _fresh_kind(self, node: Node | None, func: Node, visiting: frozenset[int]) -> _FreshKind: """ The kind of container the expression *node* is known to build when evaluated inside *func*, or `NOT_FRESH` when it may evaluate to something other code can already reach. This is a *must* analysis: it answers only for expressions whose result is provably a new object, which is what lets a member write through the result be classified as unobservable. Five forms qualify. A container literal builds its value on the spot — unless its member access runs an accessor, the shared `container_literal_access_is_plain` test. An identifier resolves through its binding, which *func* must own. An allocating `Array.prototype` method (`_FRESH_ARRAY_RESULT_METHODS`) returns a new array, but only when the prototype is undisturbed and the receiver is itself known to be an array: a fresh object literal carrying its own `slice` is not, and neither is a value of unknown type such as a plain parameter. A call the model resolves to one function qualifies when every `return` in that function yields a fresh container — and a function with a path that returns no value does not, since that path yields `undefined`. `new Array(...)` is deferred to `_pure_construct`, which owns the argument rule for that root. Deliberately *not* a may-allocate analysis. `JsObjectFold._value_allocates` asks the opposite question — whether an expression might mint an object whose identity a fold would duplicate — and answers `True` for any nested call, where this answers `NOT_FRESH` for nearly all of them. The two are not monotone in one another and must not be merged. """ node = strip_parens(node) if node is None: return _FreshKind.NOT_FRESH if isinstance(node, JsArrayExpression): return _FreshKind.ARRAY if isinstance(node, (JsObjectExpression, JsFunctionExpression, JsArrowFunctionExpression)): return _FreshKind.CONTAINER if container_literal_access_is_plain(node) else _FreshKind.NOT_FRESH if isinstance(node, JsIdentifier): binding = self.model.resolve(node) if binding is None: return _FreshKind.NOT_FRESH return self._binding_fresh_kind(binding, func, visiting) if isinstance(node, JsNewExpression): return _FreshKind.ARRAY if self._pure_construct(node) else _FreshKind.NOT_FRESH if isinstance(node, JsCallExpression): return self._call_fresh_kind(node, func, visiting) return _FreshKind.NOT_FRESH def _call_fresh_kind( self, call: JsCallExpression, func: Node, visiting: frozenset[int] ) -> _FreshKind: callee = strip_parens(call.callee) if isinstance(callee, JsMemberExpression) and not callee.computed: prop = callee.property if not isinstance(prop, JsIdentifier) or prop.name not in _FRESH_ARRAY_RESULT_METHODS: return _FreshKind.NOT_FRESH if not self.trusted_prototype(list): return _FreshKind.NOT_FRESH if self._fresh_kind(callee.object, func, visiting) is not _FreshKind.ARRAY: return _FreshKind.NOT_FRESH return _FreshKind.ARRAY if isinstance(callee, JsIdentifier): callee_func = self.unambiguous_function(self.model.resolve(callee)) if callee_func is None or id(callee_func) in visiting: return _FreshKind.NOT_FRESH visiting = visiting | {id(callee_func)} returns = [n for n in _body_nodes(callee_func) if isinstance(n, JsReturnStatement)] if not returns or not _returns_on_every_path(callee_func): return _FreshKind.NOT_FRESH kind = _FreshKind.ARRAY for statement in returns: kind = _weakest(kind, self._fresh_kind(statement.argument, callee_func, visiting)) if not kind.is_fresh: return _FreshKind.NOT_FRESH return kind return _FreshKind.NOT_FRESH @staticmethod def _is_rest_param(binding: Binding) -> bool: """ Whether *binding* is a function's rest parameter (`function f(...xs)`), whose value the language guarantees is a fresh array on every call. """ return binding.kind is BindingKind.PARAM and any( isinstance(decl.parent, JsRestElement) for decl in binding.declarations ) def _container_non_escaping(self, binding: Binding) -> bool: """ Whether every reference to *binding* keeps its container contained: each is a member read or write (`obj.k`, `obj[i] = v`), never an escape, rebinding, or method call through which the container could be aliased out, mutated by other code, or replaced. The tightest form of the escape check, since a mutation only stays unobservable while no other code can reach the object. Orthogonal to freshness, and deliberately not merged with `_binding_fresh_kind`: this asks where a container *goes*, that asks where it *came from*. Both are needed and neither implies the other — a fresh literal can escape, and a parameter that never escapes was still not built here. `_classify_member_write` is where the two compose. """ for ref in self.model.references(binding): if container_reference_role(ref) not in ( ContainerRole.MEMBER_READ, ContainerRole.MEMBER_WRITE, ): return False return True def _confining_function(self, binding: Binding) -> Node | None: """ The single function that lexically encloses every reference to *binding*, or `None` when the references do not share one — they span sibling functions or reach the top level. Cached per binding, since the binding's reference set is fixed for the lifetime of the model. """ key = id(binding) if key not in self._confine_cache: self._confine_cache[key] = self._scan_confining_function(binding) return self._confine_cache[key] def _scan_confining_function(self, binding: Binding) -> Node | None: refs = self.model.references(binding) if not refs: return None enclosing = enclosing_function(refs[0]) if enclosing is None: return None for ref in refs[1:]: if enclosing_function(ref) is not enclosing: return None return enclosing def _account_call(self, summary: EffectSummary, call: JsCallExpression | JsNewExpression): callee = self._resolve_callee(call) if callee is _PURE: return if isinstance(callee, Node): summary.absorb(self.summary_of(callee)) else: summary.calls_unknown = True def _resolve_callee(self, call: JsCallExpression | JsNewExpression) -> Node | _PureCall | None: callee = call.callee if isinstance(call, JsNewExpression) and self._pure_construct(call): return _PURE if isinstance(callee, (JsFunctionExpression, JsArrowFunctionExpression)): return callee if isinstance(callee, JsMemberExpression) and not callee.computed: base, prop = callee.object, callee.property if isinstance(base, JsIdentifier) and isinstance(prop, JsIdentifier): if F'{base.name}.{prop.name}' in _PURE_INTRINSIC_METHODS and self._is_global_intrinsic(base): return _PURE return None if isinstance(callee, JsIdentifier): if callee.name in _PURE_GLOBAL_FUNCTIONS and self._is_global_intrinsic(callee): return _PURE return self.unambiguous_function(self.model.resolve(callee)) return None def _pure_construct(self, call: JsNewExpression) -> bool: """ Whether `new <callee>(...)` is a pure allocation: the callee denotes a pristine constructor root in `_PURE_CONSTRUCTOR_ROOTS` and its arguments are safe for that root. `Array` — the only such root today — throws only on a bad single numeric length, decided by `_array_construct_is_pure`; a root added to the set needs its own argument rule wired in here rather than reusing Array's. Purity of the construction, not freshness of its result: those are separate questions, and this stays separate from `_fresh_kind` even though that predicate's `new Array(n)` form calls it. A construction can be impure and still yield a fresh object, so a caller wanting freshness must ask `_fresh_kind`. """ root = self.intrinsic_of(call.callee) if not (isinstance(root, str) and root in _PURE_CONSTRUCTOR_ROOTS): return False return _array_construct_is_pure(call.arguments) def function_of( self, binding: Binding | None ) -> JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression | None: """ The single function a *binding* stably resolves to — a sole declaration's function declaration or function/arrow initializer, or a name assigned a function exactly once (`f = function(){}`, the form namespace flattening leaves) — or `None` when the binding is absent, redeclared, reassigned to more than one value, dynamically rebindable, or not bound to a function. A lone assignment counts because the name denotes that one function wherever it is not in the value's temporal dead zone; a caller that also needs the value established before a use orders it separately. The binding-level twin of `static_callee`, and the function-typed specialization of `SemanticModel.singular_value`: it filters that value-resolution to a function node. """ value = self.model.singular_value(binding) if isinstance(value, FUNCTION_NODES): return value return None def unambiguous_function( self, binding: Binding | None ) -> JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression | None: """ The single function *binding* names for a consumer that resolves calls without execution ordering — the interpreter — or `None`. `function_of` narrowed to that ordering-free view: a pure function declaration, or a hoisted `var`/`let` assigned a function exactly once (`var f; f = function(){}`, the bare-assignment form namespace flattening leaves), qualifies; a name that already carried a value from its declaration — a function/class declaration, an initialized declarator, or a parameter — and is then reassigned holds two values across its life and is refused. This reproduces the filter the evaluator's visible-functions map applied before interpretation routed resolution through the model. """ return _unambiguous_function(self.model, binding) def _is_global_intrinsic(self, name: JsIdentifier) -> bool: """ Whether *name* denotes a trusted intrinsic root that the program leaves pristine and does not shadow with a local binding at this use site. """ if not self.intrinsics_pristine: return False return self.model.lookup(name.name, self.model.scope_of(name)) is None def intrinsic_of(self, node: Node | None) -> str | _GlobalObject | None: """ The pristine intrinsic value *node* provably denotes: `GLOBAL_OBJECT` for the global object, an intrinsic root name (`'Array'`, `'String'`, …) for a named intrinsic, or `None`. A name is returned only under `intrinsics_pristine` and where the identifier is unshadowed at this use site, so the result may be *value-trusted* — used to construct, to clear a getter-free static read, or to fold `A || B`. Every value it can return — `globalThis` and every `_PURE_INTRINSIC_ROOTS` member — is truthy, so `A || B` evaluates to `A` whenever `intrinsic_of(A)` is not `None`; a contributor extending this must preserve that truthiness invariant and never return a falsy name such as `NaN`/`undefined`. It deliberately does NOT follow a local alias through its value — `intrinsic_of` of an identifier bound to `var x = Array` is `None` — because a local's value holds only where it is established, a control-flow fact this flow-insensitive query cannot certify; a consumer that owns dominance resolves the alias itself against `singular_value`. It likewise does not treat `<global-object>.Name` as a value: that read's getter-freeness rests on `global_pristine`, a weaker premise than value trust, so it stays the concern of `_is_trusted_global_read`. """ node = strip_parens(node) if isinstance(node, JsIdentifier): if node.name == 'globalThis' and self.model.lookup(node.name, self.model.scope_of(node)) is None: return GLOBAL_OBJECT if node.name in _PURE_INTRINSIC_ROOTS and self._is_global_intrinsic(node): return node.name return None if isinstance(node, JsLogicalExpression) and node.operator == '||': return self.intrinsic_of(node.left) return None def trusted_intrinsic(self, node: Node | None) -> str | None: """ The global name *node* denotes, when that one name is provably still the built-in, or `None`. A name qualifies when the program never binds it, never assigns to it, never writes or updates a property anywhere on it, and exposes no reflection surface through which it could be replaced at runtime. This differs from `intrinsic_of` in *scope of the question*, not in strictness. `intrinsic_of` rests on `intrinsics_pristine`, one program-wide flag over a fixed root set, so a single `Object.prototype.x = 1` withdraws trust from every intrinsic in the file — including `Math`, which that line cannot affect. This query answers per name, so the same program still folds `Math.floor` while declining `Object.keys`. Its callers are the constant folds, which need to know whether *this* built-in is intact; `intrinsic_of` answers the stronger question of whether a value may be trusted for construction or `A || B` folding, and keeps its own callers and vocabulary. The name is not checked against a list of blessed intrinsics: whether a fold *knows how* to evaluate the call is the caller's question, answered by its own registry lookup. Conflating the two is what let a registry entry exist with no matching trust rule. Shadowing needs no per-site scope resolution, because a name bound *anywhere* in the program is already disqualified: `_globals_written` collects every binding in every scope. That is stricter than JavaScript requires — a shadow inside an unrelated function does not affect this use site — and deliberately so. A name the program binds at all is one an obfuscator may be routing values through, and the price of refusing is an unfolded call rather than a wrong value. """ node = strip_parens(node) if not isinstance(node, JsIdentifier): return None if self.model.has_reflection_surface(): return None if node.name in self._globals_written: return None return node.name def trusted_prototype(self, value_type: type) -> bool: """ Whether the prototype supplying *value_type*'s methods is provably unmodified, so a method call on a receiver of that type still means what the language says. A method call on a literal receiver names no global, which is exactly why it needs its own question: `trusted_intrinsic` can only judge names the expression mentions, and `'ab'.toUpperCase()` mentions none. The owning intrinsic is looked up rather than assumed, and then asked the same per-name question a named callee gets — `String.prototype.toUpperCase = f` and `Object.defineProperty(Array.prototype, ...)` are both already recorded as writes to `String` and `Array` by `_global_writes_by_name`. A type with no known owner is never trusted. """ owner = _PROTOTYPE_OWNERS.get(value_type.__name__) if owner is None: return False if self.model.has_reflection_surface(): return False return owner not in self._globals_written def global_key_written(self, name: str, key: str) -> bool: """ Whether the program writes the property *key* on a chain rooted at the global *name*: `Object.prototype.constructor = C`, `delete Object.getPrototypeOf`, or a descriptor installed for that key. The per-name question `_globals_written` answers is too coarse for a caller that cares which property was replaced — a file patching `Object.prototype.z` has written `Object`, and refusing everything about `Object` on that basis refuses the very files the question is asked about. The write is attributed to a *name* rather than to a receiver, which is what makes it answerable at all: the receiver of `Object.prototype.constructor = C` is a value no static analysis names, while the chain it is written through is rooted in one that is. A name whose written keys cannot be bounded — one the program binds, hands to code this analysis cannot read, writes a computed key on, or installs a descriptor on from a value it cannot read — answers `True` for every key, and so does a name outside `_KEYED_WRITE_ROOTS`, which the scan records nothing about at all. """ if name not in _KEYED_WRITE_ROOTS: return True keys = self._global_keys_written.get(name, frozenset()) return keys is None or key in keys def _roots_unwritten(self, owner: str, roots: frozenset[str]) -> bool: """ Whether the program writes neither *owner* nor any prototype in *roots*. A property read resolves against the whole prototype chain rather than one prototype, so each name the chain passes through has to answer the same question `trusted_prototype` asks of the owner alone. """ return all(name not in self._globals_written for name in (owner, *roots)) def _prototypes_intact(self, owner: str, roots: frozenset[str]) -> bool: """ `_roots_unwritten` with the reflection term, which is what separates the two questions `read_chain_intact` and `chain_roots_unwritten` ask. Neither is spelled out twice, so a term added to one chain question reaches both arms rather than only the one it was written into. """ if self.model.has_reflection_surface(): return False return self._roots_unwritten(owner, roots) def read_chain_intact(self, value_type: type) -> bool: """ Whether every prototype a plain property read on a value of *value_type* consults is unmodified, so the read touches a data slot and runs nothing. Strictly stronger than `trusted_prototype`, which answers the neighbouring question for a method *call*, and the two must not be merged: a method resolves on the prototype that owns it, so `Array.prototype.join` shadows anything installed on `Object.prototype` and a patch there cannot change what `[1, 2].join('-')` means. A read of an arbitrary name has no such shadow — `Object.prototype` roots every chain, so a getter installed there is reached by a read on an array literal, on a primitive, and on `Math` alike. Confirmed against Node in both directions rather than reasoned from the specification: patching `Object.prototype.join` leaves `[1, 2].join('-')` intact, while patching `Object.prototype.zz` makes `[1, 2].zz` run a getter. Two separate facts have to hold and this is their conjunction: no chain root was written, which is `chain_roots_unwritten`, and no reflective surface could have written one without saying so. A caller for which the second costs more than it buys takes the first alone — see the note there for what that trade is and where it is made. """ owner = _PROTOTYPE_OWNERS.get(value_type.__name__) if owner is None: return False return self._prototypes_intact(owner, _INHERITED_CHAIN_ROOTS) def chain_roots_unwritten(self, value_type: type) -> bool: """ Whether the program writes no prototype a plain property read on a value of *value_type* consults. This is `read_chain_intact` without its reflection term, and the difference is not a weakening of the same question but a different one: `read_chain_intact` also refuses wherever `SemanticModel.has_reflection_surface` holds, and that predicate answers whether code could reference a global *by name* — true of `new Function('return this')`, which writes no prototype at all. The distinction is what the answer costs where it is wrong, and it decides who may ask this rather than being a judgement each caller makes for itself. A caller folding one expression pays an unfolded expression for refusing, so it may as well refuse under a surface, and every one of them asks `read_chain_intact`. A caller deciding whether a whole *pass* may run pays the pass, and a reflective surface is exactly what the real obfuscated files carry: measured on the samples this project tests against, the surface is present in the input and gone from the finished output, so a pass gated on it never runs and never clears the surface that was gating it. Only those callers ask this — today namespace flattening and the dispatcher unwrapper — and they accept that an unresolvable `eval` could in principle have written a prototype, which is no worse than the nothing they asked before. The two facts separate cleanly on the evidence rather than by assumption: every program in the defect ledger that reaches a wrong answer here writes a chain root and has no reflective surface, and every sample that has a surface writes no chain root. """ owner = _PROTOTYPE_OWNERS.get(value_type.__name__) if owner is None: return False return self._roots_unwritten(owner, _INHERITED_CHAIN_ROOTS) def call_is_foldable( self, node: JsCallExpression, *, receiver_type: type | None = None, ) -> bool: """ Whether the call *node* may be evaluated to a constant and have its result replace it. This is the one admission gate every fold shares, so that a rule proven necessary at one site cannot be missing at another — the fold surface acquired its divergent hand-rolled checks precisely because each transform owned its own. Three questions must all answer yes: - The callee is still the built-in it is spelled as. A named callee (`parseInt(...)`, `String.fromCharCode(...)`) is judged by `trusted_intrinsic` on its root name; a call on a literal receiver is judged by `trusted_prototype` on the type that literal's syntax fixes; and a call on another call — a chain link — is judged by asking this whole question of that inner call. `receiver_type` covers the remaining case, a caller holding an already-evaluated receiver whose type it knows and this model does not. - Every function-valued argument writes nothing outside itself. `is_effect_free_when_discarded` is the right predicate rather than `is_pure`: a callback that mutates a fresh local and returns it — the `reduce` accumulator idiom — is not pure, but that mutation is the value being computed and an evaluator that runs the call reproduces it. Purity is not sufficient on its own either, since a callback writing a script-scope `var` reports `writes_captured=False` — that binding is not captured from its perspective — so the write would be dropped while the value folds. `written_bindings` records it by identity and catches it. - Nothing in the argument list is itself a call this gate does not also clear, so admitting an outer call cannot smuggle in an inner one. A nested built-in (`Math.floor(Math.abs(-1.7))`) is fine precisely because the same questions are asked of it. - No part of the call stores anything the residual would go on to read. The fold deletes the whole expression, so a store is lost wherever in it the store was written — an argument, the receiver, or the computed key naming the method; see `_call_stores_nothing`. Trust is not evaluability, and this answers only the first. `trusted_intrinsic` says `unknownFn` is undisturbed — true, and no help, since no such built-in exists to fold. Whether a callee can actually be evaluated is the caller's question, answered by its own registry lookup before it asks this one; conflating the two is what let a registry entry exist with no matching trust rule. """ if not self._callee_is_trusted(node, receiver_type): return False if not self._call_stores_nothing(node): return False return all(self._argument_is_admissible(arg) for arg in node.arguments) def _call_stores_nothing(self, node: JsCallExpression) -> bool: """ Whether the parts of *node* that name what is being called store anything: the receiver it is called on, and the computed key that says which method. The fold deletes the whole call expression, so a store written into either is lost exactly as one written into an argument is, and `Math[v = 'floor'](1.9)` drops the write to `v` while answering `1`. A receiver that is itself a call is not asked here. It is a link in a chain, and `_callee_is_trusted` already puts it through this same gate in full, where its own receiver, key and arguments are each accounted for. """ callee = strip_parens(node.callee) if not isinstance(callee, JsMemberExpression): return True base = strip_parens(callee.object) if not isinstance(base, JsCallExpression) and not self._stores_nothing(base): return False return not callee.computed or self._stores_nothing(callee.property) def _callee_is_trusted(self, node: JsCallExpression, receiver_type: type | None) -> bool: callee = strip_parens(node.callee) if isinstance(callee, JsIdentifier): return self.trusted_intrinsic(callee) is not None if not isinstance(callee, JsMemberExpression): return False base = strip_parens(callee.object) if isinstance(base, JsIdentifier): return self.trusted_intrinsic(base) is not None if isinstance(base, JsCallExpression): # A chained call (`Buffer.from(x).toString('hex')`, `[1, 2].map(f).join('')`) has no name at # this link. Its receiver is whatever the inner call returned, so it is trustworthy exactly # when that inner call is admissible in full — including its arguments, since an effectful # argument to the inner link is just as observable as one to the outer. return self.call_is_foldable(base) literal_type = _LITERAL_RECEIVER_TYPES.get(type(base)) if literal_type is not None: return self.trusted_prototype(literal_type) if receiver_type is None: return False return self.trusted_prototype(receiver_type) def _argument_is_admissible(self, arg: Node | None) -> bool: """ Whether *arg* may be evaluated as part of a fold that then replaces the whole call, deleting the argument's text along with it. A function-valued argument is judged by what calling it would do, because the call is what the fold performs. A call is judged by this same gate in full, so that admitting an outer call cannot smuggle in an inner one. Everything else is judged by whether evaluating it can be dropped at all, which is the question `is_side_effect_free` already answers over the whole subtree — and it is the argument's *subtree* that matters, because a store need not be the argument itself. `Math.floor(v = 4)` is only the plainest spelling of it; the same store hides in a summand, a comma operand, a template substitution, and a compound or logical assignment, each of which the fold would delete while the residual keeps reading the old value. """ node = strip_parens(arg) if isinstance(node, FUNCTION_NODES): summary = self.summary_of(node) return summary.is_effect_free_when_discarded and not summary.written_bindings if isinstance(node, JsCallExpression): return self.call_is_foldable(node) return self._stores_nothing(node) def _stores_nothing(self, node: Node | None) -> bool: """ Whether evaluating the expression *node* performs no store, so that a fold which replaces it with the value it computed loses nothing a later statement could read. A fold deletes the expression it replaces. Where that expression assigned, updated or deleted something, the store went with it and the residual program keeps reading the old value, which is what makes `Math.floor(v = 4)` answer `4` and leave `v` at `0`. The store is rarely the whole argument — it hides in a summand, a comma operand, a template substitution, a compound or a logical assignment — so the question is asked of the whole subtree and not of the shape at its root. A `yield` or an `await` is refused under the same heading: neither stores, but both hand control somewhere that can, and a fold that runs them decides when they resume. A function written down inside the expression stores nothing by being evaluated, since it is a value and only calling it could store; its body is therefore not walked. A call is refused when its callee can be named and that callee is known to write. It is admitted when the callee cannot be named, which is where this predicate stops being a proof: `s.charCodeAt(i)` on a parameter resolves to nothing this model can summarize and stores nothing, and refusing every unnameable call would decline the string decoders this tool exists to read. A `new` expression is admitted on the same terms. Admitting them is what the gate did for every call in this position before, so the rule only ever narrows what folds. """ if node is None: return True pending: list[Node] = [node] while pending: current = pending.pop() if isinstance(current, FUNCTION_NODES): continue if isinstance(current, (JsAssignmentExpression, JsUpdateExpression)): return False if isinstance(current, JsUnaryExpression) and current.operator == 'delete': return False if isinstance(current, (JsYieldExpression, JsAwaitExpression)): return False if isinstance(current, JsCallExpression): callee = self.unambiguous_callee(current) if callee is not None and self.summary_of(callee).written_bindings: return False pending.extend(current.children()) return True def _is_trusted_global_read(self, member: JsMemberExpression) -> bool: """ Whether reading *member* off the global object runs no user getter, so the read carries no observable effect: a non-computed access of a trusted intrinsic-named data property on the global object, sound only under the `global_pristine` precondition. This mirrors the intrinsic-call trust of `_resolve_callee`, lifted from methods to global data-property reads. """ if not self.global_pristine or member.computed: return False prop = member.property if not isinstance(prop, JsIdentifier) or prop.name not in _GLOBAL_DATA_PROPERTIES: return False return member.object is not None and self._base_is_global_object(member.object) def _base_is_global_object(self, node: Node) -> bool: """ Whether *node* denotes the global object itself: an unshadowed global-object alias identifier, always safe because the global object is never in a temporal dead zone. A local that only holds the global from an establishing definition is resolved separately by `_trusted_global_alias_read`, whose caller orders that definition before the read. """ if isinstance(node, JsIdentifier) and node.name in GLOBAL_OBJECT_ALIASES: return self.model.lookup(node.name, self.model.scope_of(node)) is None return False def member_read_getter_free( self, member: JsMemberExpression, established: Callable[[Binding, JsMemberExpression], bool] | None = None, ) -> bool: """ Whether reading *member* runs no user getter, so it carries no observable effect: a getter-free read off a pristine value (a fresh literal or a pristine intrinsic root) or a trusted global data-property read off a syntactic global-object alias — always, since neither is nullish — or off a local single-assigned to the global object, which holds it only from its establishing definition onward. The local case qualifies only when *established* confirms that definition reaches the read, an ordering this effect model cannot decide on its own (see `refinery.lib.scripts.js.analysis.reaching.ReachingModel.value_preserved`). """ if self._getter_free_read(member): return True if established is None: return False binding = self._trusted_global_alias_read(member) return binding is not None and established(binding, member) def _trusted_global_alias_read(self, member: JsMemberExpression) -> Binding | None: """ The local binding *member*'s base reads when *member* is a non-computed access of a trusted global data property through a single-assignment local whose value is provably the global object — a `globalThis` alias or a `globalThis || ...` guard — under `global_pristine`; `None` otherwise. The binding is returned rather than a verdict because whether it already holds the global where it is read is an ordering question for a layer that sees control flow. """ if not self.global_pristine or member.computed: return None prop = member.property if not isinstance(prop, JsIdentifier) or prop.name not in _GLOBAL_DATA_PROPERTIES: return None base = member.object if not isinstance(base, JsIdentifier) or base.name in GLOBAL_OBJECT_ALIASES: return None binding = self.model.resolve(base) if binding is None or self.model.reflection_can_reach(binding): return None return binding if self._value_is_global_object(self.model.singular_value(binding)) else None def _value_is_global_object(self, node: Node | None) -> bool: """ Whether *node*, the value a local is single-assigned, is provably the global object: the canonical `globalThis`, or a `globalThis || ...` existence guard whose truthy left is exactly it. A host alias that may be `undefined` is excluded, so a read through the local cannot throw on a nullish base. """ return self.intrinsic_of(node) is GLOBAL_OBJECT def _base_is_safe(self, node: Node) -> bool: """ Whether a property access on *node* cannot throw because *node* is known not to be nullish: a container literal whose access is plain, a primitive literal other than `null`, the global object, a pristine intrinsic root, or a never-rebound rest parameter. A rest parameter is bound to a fresh array at function entry, before any body statement runs — no temporal-dead-zone or hoisted-`undefined` window a flow-insensitive check could miss — so a member access on it is safe wherever it appears, provided the name is never reassigned to a value that could be nullish. A `var`/`let`/`const` local initialized to a literal is deliberately NOT admitted here: its initializer may not have run yet at the access (`function(){ a.x = 1; var a = []; }` throws), which this flow-insensitive predicate cannot rule out. The global object is recognized through `_base_is_global_object`, so an alias spelling a local declaration shadows names that local and is decided by the same rules as any other name: a shadowed `window` is the hoisted-`undefined` window this predicate refuses elsewhere. Non-nullishness is a *different* question from freshness and from getter-freeness, so this shares only the container-literal atom with its neighbours: a primitive qualifies here and is not fresh, while a fresh local qualifies as fresh and not here. It needs no prototype question either, unlike `_base_getter_safe`: no patch to any prototype can make `[1, 2]` nullish, and a throwing getter reached through a patched chain is that predicate's concern. There is no member-chain arm, because `root.a` may be `undefined` however safe *root* is, and the second read would then throw. """ if container_literal_access_is_plain(node): return True if isinstance(node, (JsStringLiteral, JsNumericLiteral, JsBooleanLiteral)): return True if isinstance(node, JsIdentifier): if self._base_is_global_object(node): return True if isinstance(self.intrinsic_of(node), str): return True binding = self.model.resolve(node) return binding is not None and self._is_rest_param(binding) and not binding.writes return False def _base_getter_safe(self, node: Node) -> bool: """ Whether reading a property of *node* cannot run a user-defined getter, so the read carries no hidden effect: a literal, or a pristine intrinsic root, whose entire prototype chain the program leaves alone. Unlike `_base_is_safe`, the global object does not qualify — a global property such as `location` may be an accessor — so a read through it is treated as an unknown call. Unlike freshness, a primitive and an intrinsic root do qualify: neither is a newly built container, and neither runs user code on a read of a pristine chain. Syntax alone settles the *type* of a literal base but not its behaviour, which is why every arm ends in `read_chain_intact` rather than returning on the node kind. A literal was previously cleared on syntax alone, and that deleted reads which really did run a getter installed on the corresponding prototype — Node-confirmed for array, object, string, boolean, function, and arrow bases, plus an intrinsic root reached through `Object.prototype`. A container literal must additionally declare no accessor of its own, the shared `container_literal_access_is_plain` question, since a getter written into the literal needs no prototype at all. There is no member-chain arm, for the reason given on `_base_is_safe`. """ inner = strip_parens(node) value_type = _LITERAL_READ_TYPES.get(type(inner)) if value_type is not None: if self._literal_declares_accessor(inner): return False return self.read_chain_intact(value_type) if isinstance(inner, JsIdentifier) and isinstance(self.intrinsic_of(inner), str): return self._prototypes_intact('Object', _INTRINSIC_CHAIN_ROOTS) return False def _literal_declares_accessor(self, node: Node) -> bool: """ Whether *node* is a container literal that declares an accessor of its own, so a member access on it runs user code with no prototype involved. Separate from the chain question because it needs no model: the getter is written into the expression. """ if not isinstance(node, (JsObjectExpression, JsArrayExpression)): return False return not container_literal_access_is_plain(node) def _getter_free_read(self, member: JsMemberExpression) -> bool: """ Whether reading *member* runs no user getter and cannot fire a poison-pill accessor: the base is a getter-safe value (a fresh literal or a pristine intrinsic root) or a trusted global-object data property, and the property is not one of the poison-pill names whose read may throw or run an `Object.prototype` accessor. This is the single getter-freeness gate the summary scan and `is_side_effect_free` share. """ if _is_poison_pill_property(member): return False if member.object is not None and self._base_getter_safe(member.object): return True return self._is_trusted_global_read(member)Methods
def summary_of(self, func)-
The effect summary of a function node (or the script). An unknown node is reported as impure.
Expand source code Browse git
def summary_of(self, func: Node) -> EffectSummary: """ The effect summary of a function node (or the script). An unknown node is reported as impure. """ return self._summaries.get(id(func), EffectSummary(calls_unknown=True)) def mutated_bindings(self, func)-
The outer bindings (captured locals and globals) a call to func may write, directly or through any function it transitively calls, each identified by its
Bindingrather than its name so a caller can ask whether one specific binding is mutated. Empty for a function with no such writes and for an unknown node alike — usesummary_of(func).calls_unknownto tell those apart.Expand source code Browse git
def mutated_bindings(self, func: Node) -> frozenset[Binding]: """ The outer bindings (captured locals and globals) a call to *func* may write, directly or through any function it transitively calls, each identified by its `Binding` rather than its name so a caller can ask whether one specific binding is mutated. Empty for a function with no such writes and for an unknown node alike — use `summary_of(func).calls_unknown` to tell those apart. """ return frozenset(self.summary_of(func).written_bindings) def function_can_mutate(self, func, binding)-
Whether a call to func may write binding, itself or through a transitive callee.
Expand source code Browse git
def function_can_mutate(self, func: Node, binding: Binding) -> bool: """ Whether a call to *func* may write *binding*, itself or through a transitive callee. """ return binding in self.summary_of(func).written_bindings def function_escapes(self, func)-
Whether func may be invoked at a point the surrounding scope cannot enumerate as a resolvable
name(…)call site: an anonymous function (an IIFE, a callback, stored and called later), or a named function whose binding is reassigned, redeclared, or referenced anywhere other than as the callee of a direct call (aliased, passed as an argument,f.call(…)). A reference inside a dynamic scope — a name awithbody resolves at runtime — counts too: the model cannot order or resolve it, so the function may be invoked or aliased there with no static call site. A call to such a function can land at a point no call site pins down; a function only ever called directly by name has all its invocations enumerated by those call sites.Expand source code Browse git
def function_escapes(self, func: Node) -> bool: """ Whether *func* may be invoked at a point the surrounding scope cannot enumerate as a resolvable `name(...)` call site: an anonymous function (an IIFE, a callback, stored and called later), or a named function whose binding is reassigned, redeclared, or referenced anywhere other than as the callee of a direct call (aliased, passed as an argument, `f.call(...)`). A reference inside a dynamic scope — a name a `with` body resolves at runtime — counts too: the model cannot order or resolve it, so the function may be invoked or aliased there with no static call site. A call to such a function can land at a point no call site pins down; a function only ever called directly by name has all its invocations enumerated by those call sites. """ binding = self.model.naming_binding(func) if binding is None: return True if binding.writes or binding.dynamic_refs or len(binding.declarations) != 1: return True for ref in self.model.references(binding): parent = ref.parent if isinstance(parent, JsCallExpression) and parent.callee is ref: continue return True return False def mutators_escape(self, binding)-
Whether some function that may write binding — itself or through a transitive callee — escapes (
function_escapes), so a write to binding may occur at a point no call site enumerates. When true, the places binding changes cannot be pinned down, and a caller reasoning about where its value survives must treat it as volatile everywhere. Memoized per binding.Expand source code Browse git
def mutators_escape(self, binding: Binding) -> bool: """ Whether some function that may write *binding* — itself or through a transitive callee — escapes (`function_escapes`), so a write to *binding* may occur at a point no call site enumerates. When true, the places *binding* changes cannot be pinned down, and a caller reasoning about where its value survives must treat it as volatile everywhere. Memoized per binding. """ cached = self._mutators_escape_cache.get(id(binding)) if cached is None: cached = any( func is not self.model.root and binding in self.summary_of(func).written_bindings and self.function_escapes(func) for func in self._functions ) self._mutators_escape_cache[id(binding)] = cached return cached def some_function_can_mutate(self, binding)-
Whether any function this file writes may write binding, itself or through a transitive callee. The answer a caller needs where a call runs a function it cannot name: not knowing which one runs, it has to reckon with every one that could. Memoized per binding.
Expand source code Browse git
def some_function_can_mutate(self, binding: Binding) -> bool: """ Whether any function this file writes may write *binding*, itself or through a transitive callee. The answer a caller needs where a call runs a function it cannot name: not knowing which one runs, it has to reckon with every one that could. Memoized per binding. """ cached = self._some_mutator_cache.get(id(binding)) if cached is None: cached = any( func is not self.model.root and self.function_can_mutate(func, binding) for func in self._functions ) self._some_mutator_cache[id(binding)] = cached return cached def is_pure_call(self, call)-
Whether evaluating call has no observable effect: it invokes a trusted pure intrinsic (under the pristine-intrinsics precondition) or a local function whose summary is pure.
Expand source code Browse git
def is_pure_call(self, call: JsCallExpression | JsNewExpression) -> bool: """ Whether evaluating *call* has no observable effect: it invokes a trusted pure intrinsic (under the pristine-intrinsics precondition) or a local function whose summary is pure. """ callee = self._resolve_callee(call) if callee is _PURE: return True if isinstance(callee, Node): return self.summary_of(callee).is_pure return False def is_pure_call_discarded(self, call)-
Whether evaluating call and discarding its result has no observable effect. Like
is_pure_callbut resolved throughEffectSummary.is_effect_free_when_discarded, so a callee whose only residual effect is a write it confines to its returned value qualifies — that write is unobservable once the result is thrown away. A caller may use this only in a position it has proven discards the value.Expand source code Browse git
def is_pure_call_discarded(self, call: JsCallExpression | JsNewExpression) -> bool: """ Whether evaluating *call* and discarding its result has no observable effect. Like `is_pure_call` but resolved through `EffectSummary.is_effect_free_when_discarded`, so a callee whose only residual effect is a write it confines to its returned value qualifies — that write is unobservable once the result is thrown away. A caller may use this only in a position it has proven discards the value. """ callee = self._resolve_callee(call) if callee is _PURE: return True if isinstance(callee, Node): return self.summary_of(callee).is_effect_free_when_discarded return False def call_clearable(self, call, callee_established)-
Whether call's callee is established — in place before the call runs — given callee_established, the caller's test for a resolved named local callee. A trusted pure intrinsic and an inline function-expression callee (defined at the call site, hence always in place) qualify unconditionally; a call resolving to a single named local function qualifies when callee_established accepts it; an unresolved or ambiguous callee does not. The resolution, the intrinsic case, and the inline-callee case live here so callers supply only the ordering judgment their layer can make. This certifies establishment ONLY, not purity — a caller deciding whether a call may be dropped must conjoin it with
is_pure_call, asside_effect_freedoes, since an established callee may still run an effectful body.Expand source code Browse git
def call_clearable( self, call: JsCallExpression | JsNewExpression, callee_established: Callable[[Node], bool], ) -> bool: """ Whether *call*'s callee is established — in place before the call runs — given *callee_established*, the caller's test for a resolved named local callee. A trusted pure intrinsic and an inline function-expression callee (defined at the call site, hence always in place) qualify unconditionally; a call resolving to a single named local function qualifies when *callee_established* accepts it; an unresolved or ambiguous callee does not. The resolution, the intrinsic case, and the inline-callee case live here so callers supply only the ordering judgment their layer can make. This certifies establishment ONLY, not purity — a caller deciding whether a call may be dropped must conjoin it with `is_pure_call`, as `side_effect_free` does, since an established callee may still run an effectful body. """ resolved = self._resolve_callee(call) if resolved is _PURE: return True if isinstance(resolved, Node): if isinstance(strip_parens(call.callee), (JsFunctionExpression, JsArrowFunctionExpression)): return True return callee_established(resolved) return False def is_side_effect_free(self, node, defunct=None, member_safe=None, call_established=None, discarded=False)-
Whether evaluating node can be dropped or reordered without an observable side effect, with the call leaf resolved through this model's
is_pure_call: a call to a proven-pure function or trusted intrinsic is free, recursing into its arguments. defunct names bindings being removed, whose calls and property reads are treated as free. This is the model-aware form of the model-freeside_effect_freein this module, which clears only calls to a defunct name; unlike it, an identifier read that resolves through awithbody's dynamic scope is rejected here — reading the bare name may fire thewithobject's getter or throw (seeSemanticModel.read_has_dynamic_effect()) — while a function value whose body performs such a read stays free, since defining it runs nothing. A caller with control-flow context passes member_safe to also clear a getter-free read through a local global-object alias it can prove established before the read; the default clears only the syntactic global case (_is_trusted_global_read).With discarded the caller asserts node's own value is thrown away, so a top-level call leaf is cleared through
is_pure_call_discardedand a callee that only mutates a local it returns is droppable — the removal contexts ofJsUnusedCodeRemovalsupply it.Expand source code Browse git
def is_side_effect_free( self, node: Node, defunct: set[str] | None = None, member_safe: Callable[[JsMemberExpression], bool] | None = None, call_established: Callable[[JsCallExpression | JsNewExpression], bool] | None = None, discarded: bool = False, ) -> bool: """ Whether evaluating *node* can be dropped or reordered without an observable side effect, with the call leaf resolved through this model's `is_pure_call`: a call to a proven-pure function or trusted intrinsic is free, recursing into its arguments. *defunct* names bindings being removed, whose calls and property reads are treated as free. This is the model-aware form of the model-free `side_effect_free` in this module, which clears only calls to a defunct name; unlike it, an identifier read that resolves through a `with` body's dynamic scope is rejected here — reading the bare name may fire the `with` object's getter or throw (see `refinery.lib.scripts.js.analysis.model.SemanticModel.read_has_dynamic_effect`) — while a function value whose body performs such a read stays free, since defining it runs nothing. A caller with control-flow context passes *member_safe* to also clear a getter-free read through a local global-object alias it can prove established before the read; the default clears only the syntactic global case (`_is_trusted_global_read`). With *discarded* the caller asserts *node*'s own value is thrown away, so a top-level call leaf is cleared through `is_pure_call_discarded` and a callee that only mutates a local it returns is droppable — the removal contexts of `JsUnusedCodeRemoval` supply it. """ return side_effect_free( node, defunct, self.is_pure_call, self.model.read_has_dynamic_effect, member_safe or self._getter_free_read, call_established or self._established_call_default, discarded, self.is_pure_call_discarded, ) def binding_is_immutable_container(self, binding, *, member_calls_mutate=True, exclude=None)-
Whether binding holds a container — an object or array — whose element and property values are stable after construction, so that an access into it may be soundly inlined at its read sites. Every reference must read through the container (
obj.k,obj[i]) or plainly rebind the name (obj = ..., whose value the caller resolves by domination); a write through the container (obj.k = v,obj[i]++,delete obj[i], afor-ofor destructuring target) makes it mutable. A method invoked on the container (obj.m(…)) may mutate it — an array'ssort/push/spliceand so on — so by default it too counts as mutable; a caller that knows the container's methods cannot mutate it (an object literal with nothis-bound property) may pass member_calls_mutate false to permit such calls. A reference that escapes is safe in two cases: it aliases another binding that is itself an immutable container (alias-following the textual predicates this replaces could not do, and the reason a reassigned-and-aliased lookup array stays inlinable), or it is passed to a statically known function as an argument whose parameter is itself an immutable container (so the callee neither mutates nor further-escapes it). Any other escape — returned, stored as a property, passed to a call that cannot be resolved — is treated conservatively as mutable. A mutation through a dynamic scope is modelled: awithbody that names the container — a member write, method call, reassignment, or escape — is attributed to it as a dynamic reference and judged by the same role logic, so awiththat never names it keeps it foldable, and a directevalin a local container's own function makes it mutable. The one residual is a script-scope container reached by an opaque global surface — a directeval,Function, timer, or dynamic global write whose code cannot be read — which cannot be frozen without also freezing the lookup arrays real samples fold, so it is left to the caller's reflection reasoning, the trust an unresolved external call already receives.The query is over a resolved binding, so it is shadowing-correct, and it descends through alias chains, callee parameters, and nested functions, so a capturing closure that mutates the container is caught. The answer is fixed for the model's lifetime — a binding's reference set does not change — so it is memoized per
(binding, member_calls_mutate). A caller may pass exclude to disregard references within that subtree — asking whether the container is stable across the rest of the program, ignoring a read site about to be relocated into it; such a query is not memoized, since the answer depends on the excluded region.Expand source code Browse git
def binding_is_immutable_container( self, binding: Binding, *, member_calls_mutate: bool = True, exclude: Node | None = None, ) -> bool: """ Whether *binding* holds a container — an object or array — whose element and property values are stable after construction, so that an access into it may be soundly inlined at its read sites. Every reference must read through the container (`obj.k`, `obj[i]`) or plainly rebind the name (`obj = ...`, whose value the caller resolves by domination); a write through the container (`obj.k = v`, `obj[i]++`, `delete obj[i]`, a `for-of` or destructuring target) makes it mutable. A method invoked on the container (`obj.m(...)`) may mutate it — an array's `sort`/`push`/`splice` and so on — so by default it too counts as mutable; a caller that knows the container's methods cannot mutate it (an object literal with no `this`-bound property) may pass *member_calls_mutate* false to permit such calls. A reference that escapes is safe in two cases: it aliases another binding that is itself an immutable container (alias-following the textual predicates this replaces could not do, and the reason a reassigned-and-aliased lookup array stays inlinable), or it is passed to a statically known function as an argument whose parameter is itself an immutable container (so the callee neither mutates nor further-escapes it). Any other escape — returned, stored as a property, passed to a call that cannot be resolved — is treated conservatively as mutable. A mutation through a dynamic scope is modelled: a `with` body that names the container — a member write, method call, reassignment, or escape — is attributed to it as a dynamic reference and judged by the same role logic, so a `with` that never names it keeps it foldable, and a direct `eval` in a local container's own function makes it mutable. The one residual is a script-scope container reached by an opaque global surface — a direct `eval`, `Function`, timer, or dynamic global write whose code cannot be read — which cannot be frozen without also freezing the lookup arrays real samples fold, so it is left to the caller's reflection reasoning, the trust an unresolved external call already receives. The query is over a *resolved binding*, so it is shadowing-correct, and it descends through alias chains, callee parameters, and nested functions, so a capturing closure that mutates the container is caught. The answer is fixed for the model's lifetime — a binding's reference set does not change — so it is memoized per `(binding, member_calls_mutate)`. A caller may pass *exclude* to disregard references within that subtree — asking whether the container is stable across the rest of the program, ignoring a read site about to be relocated into it; such a query is not memoized, since the answer depends on the excluded region. """ if exclude is not None: return self._immutable_container(binding, set(), member_calls_mutate, exclude) key = (id(binding), member_calls_mutate) cached = self._immutable_cache.get(key) if cached is None: cached = self._immutable_container(binding, set(), member_calls_mutate) self._immutable_cache[key] = cached return cached def static_callee(self, call)-
The function a call invokes, resolved permissively through
function_of: a direct function or arrow expression callee, or an identifier bound to a single function — a declaration, avar/let/constinitializer, or the value a name is assigned exactly once. For a name that held a value and was then reassigned this returns the post-reassignment value, which is the running target only where that reassignment is established before the call; a consumer that cannot order the reassignment against the call must useunambiguous_calleeinstead.Nonefor a method call, a parameter, a redeclared or dynamically-rebindable binding, or an unresolved name.Expand source code Browse git
def static_callee( self, call: JsCallExpression ) -> JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression | None: """ The function a call invokes, resolved permissively through `function_of`: a direct function or arrow expression callee, or an identifier bound to a single function — a declaration, a `var`/`let`/`const` initializer, or the value a name is assigned exactly once. For a name that held a value and was then reassigned this returns the post-reassignment value, which is the running target only where that reassignment is established before the call; a consumer that cannot order the reassignment against the call must use `unambiguous_callee` instead. `None` for a method call, a parameter, a redeclared or dynamically-rebindable binding, or an unresolved name. """ callee = call.callee if isinstance(callee, (JsFunctionExpression, JsArrowFunctionExpression)): return callee if not isinstance(callee, JsIdentifier): return None return self.function_of(self.model.resolve(callee)) def a_name_this_file_binds_holds_the_callee(self, call)-
Whether call names its callee with an identifier this file binds, while
static_calleedeclines to say which function that binding holds.A caller reasoning about what a call may have done reads
static_calleeansweringNonein two ways, and this tells them apart. Where the callee is a method, a host function, or a name nothing here declares,Nonemeans the call runs something outside this file's reckoning, which is a standing condition every such caller was written under. Where it is a name this file binds,Nonemeans the model saw the binding and would not state its value - one Annex B copies into a block's enclosing scope is such a function - and what it runs may be any of the ones written here, one that writes the very binding being reasoned about included.Expand source code Browse git
def a_name_this_file_binds_holds_the_callee(self, call: JsCallExpression) -> bool: """ Whether *call* names its callee with an identifier this file binds, while `static_callee` declines to say which function that binding holds. A caller reasoning about what a call may have done reads `static_callee` answering `None` in two ways, and this tells them apart. Where the callee is a method, a host function, or a name nothing here declares, `None` means the call runs something outside this file's reckoning, which is a standing condition every such caller was written under. Where it is a name this file binds, `None` means the model saw the binding and would not state its value - one Annex B copies into a block's enclosing scope is such a function - and what it runs may be any of the ones written here, one that writes the very binding being reasoned about included. """ callee = strip_parens(call.callee) if not isinstance(callee, JsIdentifier): return False if self.model.resolve(callee) is None: return False return self.static_callee(call) is None def unambiguous_callee(self, call)-
The ordering-free twin of
static_callee, for a consumer that reasons about a call without knowing where it sits in execution order. Identical except an identifier callee resolves throughunambiguous_function, so a name that held a value and was then reassigned — whose running target depends on the call's position relative to the reassignment — yieldsNonerather than the post-reassignment value.Expand source code Browse git
def unambiguous_callee( self, call: JsCallExpression ) -> JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression | None: """ The ordering-free twin of `static_callee`, for a consumer that reasons about a call without knowing where it sits in execution order. Identical except an identifier callee resolves through `unambiguous_function`, so a name that held a value and was then reassigned — whose running target depends on the call's position relative to the reassignment — yields `None` rather than the post-reassignment value. """ return _unambiguous_callee(self.model, call) def function_of(self, binding)-
The single function a binding stably resolves to — a sole declaration's function declaration or function/arrow initializer, or a name assigned a function exactly once (
f = function(){}, the form namespace flattening leaves) — orNonewhen the binding is absent, redeclared, reassigned to more than one value, dynamically rebindable, or not bound to a function. A lone assignment counts because the name denotes that one function wherever it is not in the value's temporal dead zone; a caller that also needs the value established before a use orders it separately. The binding-level twin ofstatic_callee, and the function-typed specialization ofSemanticModel.singular_value(): it filters that value-resolution to a function node.Expand source code Browse git
def function_of( self, binding: Binding | None ) -> JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression | None: """ The single function a *binding* stably resolves to — a sole declaration's function declaration or function/arrow initializer, or a name assigned a function exactly once (`f = function(){}`, the form namespace flattening leaves) — or `None` when the binding is absent, redeclared, reassigned to more than one value, dynamically rebindable, or not bound to a function. A lone assignment counts because the name denotes that one function wherever it is not in the value's temporal dead zone; a caller that also needs the value established before a use orders it separately. The binding-level twin of `static_callee`, and the function-typed specialization of `SemanticModel.singular_value`: it filters that value-resolution to a function node. """ value = self.model.singular_value(binding) if isinstance(value, FUNCTION_NODES): return value return None def unambiguous_function(self, binding)-
The single function binding names for a consumer that resolves calls without execution ordering — the interpreter — or
None.function_ofnarrowed to that ordering-free view: a pure function declaration, or a hoistedvar/letassigned a function exactly once (var f; f = function(){}, the bare-assignment form namespace flattening leaves), qualifies; a name that already carried a value from its declaration — a function/class declaration, an initialized declarator, or a parameter — and is then reassigned holds two values across its life and is refused. This reproduces the filter the evaluator's visible-functions map applied before interpretation routed resolution through the model.Expand source code Browse git
def unambiguous_function( self, binding: Binding | None ) -> JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression | None: """ The single function *binding* names for a consumer that resolves calls without execution ordering — the interpreter — or `None`. `function_of` narrowed to that ordering-free view: a pure function declaration, or a hoisted `var`/`let` assigned a function exactly once (`var f; f = function(){}`, the bare-assignment form namespace flattening leaves), qualifies; a name that already carried a value from its declaration — a function/class declaration, an initialized declarator, or a parameter — and is then reassigned holds two values across its life and is refused. This reproduces the filter the evaluator's visible-functions map applied before interpretation routed resolution through the model. """ return _unambiguous_function(self.model, binding) def intrinsic_of(self, node)-
The pristine intrinsic value node provably denotes:
GLOBAL_OBJECTfor the global object, an intrinsic root name ('Array','String', …) for a named intrinsic, orNone. A name is returned only underintrinsics_pristineand where the identifier is unshadowed at this use site, so the result may be value-trusted — used to construct, to clear a getter-free static read, or to foldA || B. Every value it can return —globalThisand every_PURE_INTRINSIC_ROOTSmember — is truthy, soA || Bevaluates toAwheneverintrinsic_of(A)is notNone; a contributor extending this must preserve that truthiness invariant and never return a falsy name such asNaN/undefined.It deliberately does NOT follow a local alias through its value —
intrinsic_ofof an identifier bound tovar x = ArrayisNone— because a local's value holds only where it is established, a control-flow fact this flow-insensitive query cannot certify; a consumer that owns dominance resolves the alias itself againstsingular_value. It likewise does not treat<global-object>.Nameas a value: that read's getter-freeness rests onglobal_pristine, a weaker premise than value trust, so it stays the concern of_is_trusted_global_read.Expand source code Browse git
def intrinsic_of(self, node: Node | None) -> str | _GlobalObject | None: """ The pristine intrinsic value *node* provably denotes: `GLOBAL_OBJECT` for the global object, an intrinsic root name (`'Array'`, `'String'`, …) for a named intrinsic, or `None`. A name is returned only under `intrinsics_pristine` and where the identifier is unshadowed at this use site, so the result may be *value-trusted* — used to construct, to clear a getter-free static read, or to fold `A || B`. Every value it can return — `globalThis` and every `_PURE_INTRINSIC_ROOTS` member — is truthy, so `A || B` evaluates to `A` whenever `intrinsic_of(A)` is not `None`; a contributor extending this must preserve that truthiness invariant and never return a falsy name such as `NaN`/`undefined`. It deliberately does NOT follow a local alias through its value — `intrinsic_of` of an identifier bound to `var x = Array` is `None` — because a local's value holds only where it is established, a control-flow fact this flow-insensitive query cannot certify; a consumer that owns dominance resolves the alias itself against `singular_value`. It likewise does not treat `<global-object>.Name` as a value: that read's getter-freeness rests on `global_pristine`, a weaker premise than value trust, so it stays the concern of `_is_trusted_global_read`. """ node = strip_parens(node) if isinstance(node, JsIdentifier): if node.name == 'globalThis' and self.model.lookup(node.name, self.model.scope_of(node)) is None: return GLOBAL_OBJECT if node.name in _PURE_INTRINSIC_ROOTS and self._is_global_intrinsic(node): return node.name return None if isinstance(node, JsLogicalExpression) and node.operator == '||': return self.intrinsic_of(node.left) return None def trusted_intrinsic(self, node)-
The global name node denotes, when that one name is provably still the built-in, or
None. A name qualifies when the program never binds it, never assigns to it, never writes or updates a property anywhere on it, and exposes no reflection surface through which it could be replaced at runtime.This differs from
intrinsic_ofin scope of the question, not in strictness.intrinsic_ofrests onintrinsics_pristine, one program-wide flag over a fixed root set, so a singleObject.prototype.x = 1withdraws trust from every intrinsic in the file — includingMath, which that line cannot affect. This query answers per name, so the same program still foldsMath.floorwhile decliningObject.keys. Its callers are the constant folds, which need to know whether this built-in is intact;intrinsic_ofanswers the stronger question of whether a value may be trusted for construction orA || Bfolding, and keeps its own callers and vocabulary.The name is not checked against a list of blessed intrinsics: whether a fold knows how to evaluate the call is the caller's question, answered by its own registry lookup. Conflating the two is what let a registry entry exist with no matching trust rule.
Shadowing needs no per-site scope resolution, because a name bound anywhere in the program is already disqualified:
_globals_writtencollects every binding in every scope. That is stricter than JavaScript requires — a shadow inside an unrelated function does not affect this use site — and deliberately so. A name the program binds at all is one an obfuscator may be routing values through, and the price of refusing is an unfolded call rather than a wrong value.Expand source code Browse git
def trusted_intrinsic(self, node: Node | None) -> str | None: """ The global name *node* denotes, when that one name is provably still the built-in, or `None`. A name qualifies when the program never binds it, never assigns to it, never writes or updates a property anywhere on it, and exposes no reflection surface through which it could be replaced at runtime. This differs from `intrinsic_of` in *scope of the question*, not in strictness. `intrinsic_of` rests on `intrinsics_pristine`, one program-wide flag over a fixed root set, so a single `Object.prototype.x = 1` withdraws trust from every intrinsic in the file — including `Math`, which that line cannot affect. This query answers per name, so the same program still folds `Math.floor` while declining `Object.keys`. Its callers are the constant folds, which need to know whether *this* built-in is intact; `intrinsic_of` answers the stronger question of whether a value may be trusted for construction or `A || B` folding, and keeps its own callers and vocabulary. The name is not checked against a list of blessed intrinsics: whether a fold *knows how* to evaluate the call is the caller's question, answered by its own registry lookup. Conflating the two is what let a registry entry exist with no matching trust rule. Shadowing needs no per-site scope resolution, because a name bound *anywhere* in the program is already disqualified: `_globals_written` collects every binding in every scope. That is stricter than JavaScript requires — a shadow inside an unrelated function does not affect this use site — and deliberately so. A name the program binds at all is one an obfuscator may be routing values through, and the price of refusing is an unfolded call rather than a wrong value. """ node = strip_parens(node) if not isinstance(node, JsIdentifier): return None if self.model.has_reflection_surface(): return None if node.name in self._globals_written: return None return node.name def trusted_prototype(self, value_type)-
Whether the prototype supplying value_type's methods is provably unmodified, so a method call on a receiver of that type still means what the language says. A method call on a literal receiver names no global, which is exactly why it needs its own question:
trusted_intrinsiccan only judge names the expression mentions, and'ab'.toUpperCase()mentions none.The owning intrinsic is looked up rather than assumed, and then asked the same per-name question a named callee gets —
String.prototype.toUpperCase = fandObject.defineProperty(Array.prototype, …)are both already recorded as writes toStringandArrayby_global_writes_by_name. A type with no known owner is never trusted.Expand source code Browse git
def trusted_prototype(self, value_type: type) -> bool: """ Whether the prototype supplying *value_type*'s methods is provably unmodified, so a method call on a receiver of that type still means what the language says. A method call on a literal receiver names no global, which is exactly why it needs its own question: `trusted_intrinsic` can only judge names the expression mentions, and `'ab'.toUpperCase()` mentions none. The owning intrinsic is looked up rather than assumed, and then asked the same per-name question a named callee gets — `String.prototype.toUpperCase = f` and `Object.defineProperty(Array.prototype, ...)` are both already recorded as writes to `String` and `Array` by `_global_writes_by_name`. A type with no known owner is never trusted. """ owner = _PROTOTYPE_OWNERS.get(value_type.__name__) if owner is None: return False if self.model.has_reflection_surface(): return False return owner not in self._globals_written def global_key_written(self, name, key)-
Whether the program writes the property key on a chain rooted at the global name:
Object.prototype.constructor = C,delete Object.getPrototypeOf, or a descriptor installed for that key. The per-name question_globals_writtenanswers is too coarse for a caller that cares which property was replaced — a file patchingObject.prototype.zhas writtenObject, and refusing everything aboutObjecton that basis refuses the very files the question is asked about.The write is attributed to a name rather than to a receiver, which is what makes it answerable at all: the receiver of
Object.prototype.constructor = Cis a value no static analysis names, while the chain it is written through is rooted in one that is. A name whose written keys cannot be bounded — one the program binds, hands to code this analysis cannot read, writes a computed key on, or installs a descriptor on from a value it cannot read — answersTruefor every key, and so does a name outside_KEYED_WRITE_ROOTS, which the scan records nothing about at all.Expand source code Browse git
def global_key_written(self, name: str, key: str) -> bool: """ Whether the program writes the property *key* on a chain rooted at the global *name*: `Object.prototype.constructor = C`, `delete Object.getPrototypeOf`, or a descriptor installed for that key. The per-name question `_globals_written` answers is too coarse for a caller that cares which property was replaced — a file patching `Object.prototype.z` has written `Object`, and refusing everything about `Object` on that basis refuses the very files the question is asked about. The write is attributed to a *name* rather than to a receiver, which is what makes it answerable at all: the receiver of `Object.prototype.constructor = C` is a value no static analysis names, while the chain it is written through is rooted in one that is. A name whose written keys cannot be bounded — one the program binds, hands to code this analysis cannot read, writes a computed key on, or installs a descriptor on from a value it cannot read — answers `True` for every key, and so does a name outside `_KEYED_WRITE_ROOTS`, which the scan records nothing about at all. """ if name not in _KEYED_WRITE_ROOTS: return True keys = self._global_keys_written.get(name, frozenset()) return keys is None or key in keys def read_chain_intact(self, value_type)-
Whether every prototype a plain property read on a value of value_type consults is unmodified, so the read touches a data slot and runs nothing. Strictly stronger than
trusted_prototype, which answers the neighbouring question for a method call, and the two must not be merged: a method resolves on the prototype that owns it, soArray.prototype.joinshadows anything installed onObject.prototypeand a patch there cannot change what[1, 2].join('-')means. A read of an arbitrary name has no such shadow —Object.prototyperoots every chain, so a getter installed there is reached by a read on an array literal, on a primitive, and onMathalike.Confirmed against Node in both directions rather than reasoned from the specification: patching
Object.prototype.joinleaves[1, 2].join('-')intact, while patchingObject.prototype.zzmakes[1, 2].zzrun a getter.Two separate facts have to hold and this is their conjunction: no chain root was written, which is
chain_roots_unwritten, and no reflective surface could have written one without saying so. A caller for which the second costs more than it buys takes the first alone — see the note there for what that trade is and where it is made.Expand source code Browse git
def read_chain_intact(self, value_type: type) -> bool: """ Whether every prototype a plain property read on a value of *value_type* consults is unmodified, so the read touches a data slot and runs nothing. Strictly stronger than `trusted_prototype`, which answers the neighbouring question for a method *call*, and the two must not be merged: a method resolves on the prototype that owns it, so `Array.prototype.join` shadows anything installed on `Object.prototype` and a patch there cannot change what `[1, 2].join('-')` means. A read of an arbitrary name has no such shadow — `Object.prototype` roots every chain, so a getter installed there is reached by a read on an array literal, on a primitive, and on `Math` alike. Confirmed against Node in both directions rather than reasoned from the specification: patching `Object.prototype.join` leaves `[1, 2].join('-')` intact, while patching `Object.prototype.zz` makes `[1, 2].zz` run a getter. Two separate facts have to hold and this is their conjunction: no chain root was written, which is `chain_roots_unwritten`, and no reflective surface could have written one without saying so. A caller for which the second costs more than it buys takes the first alone — see the note there for what that trade is and where it is made. """ owner = _PROTOTYPE_OWNERS.get(value_type.__name__) if owner is None: return False return self._prototypes_intact(owner, _INHERITED_CHAIN_ROOTS) def chain_roots_unwritten(self, value_type)-
Whether the program writes no prototype a plain property read on a value of value_type consults. This is
read_chain_intactwithout its reflection term, and the difference is not a weakening of the same question but a different one:read_chain_intactalso refuses whereverSemanticModel.has_reflection_surface()holds, and that predicate answers whether code could reference a global by name — true ofnew Function('return this'), which writes no prototype at all.The distinction is what the answer costs where it is wrong, and it decides who may ask this rather than being a judgement each caller makes for itself. A caller folding one expression pays an unfolded expression for refusing, so it may as well refuse under a surface, and every one of them asks
read_chain_intact. A caller deciding whether a whole pass may run pays the pass, and a reflective surface is exactly what the real obfuscated files carry: measured on the samples this project tests against, the surface is present in the input and gone from the finished output, so a pass gated on it never runs and never clears the surface that was gating it. Only those callers ask this — today namespace flattening and the dispatcher unwrapper — and they accept that an unresolvableevalcould in principle have written a prototype, which is no worse than the nothing they asked before.The two facts separate cleanly on the evidence rather than by assumption: every program in the defect ledger that reaches a wrong answer here writes a chain root and has no reflective surface, and every sample that has a surface writes no chain root.
Expand source code Browse git
def chain_roots_unwritten(self, value_type: type) -> bool: """ Whether the program writes no prototype a plain property read on a value of *value_type* consults. This is `read_chain_intact` without its reflection term, and the difference is not a weakening of the same question but a different one: `read_chain_intact` also refuses wherever `SemanticModel.has_reflection_surface` holds, and that predicate answers whether code could reference a global *by name* — true of `new Function('return this')`, which writes no prototype at all. The distinction is what the answer costs where it is wrong, and it decides who may ask this rather than being a judgement each caller makes for itself. A caller folding one expression pays an unfolded expression for refusing, so it may as well refuse under a surface, and every one of them asks `read_chain_intact`. A caller deciding whether a whole *pass* may run pays the pass, and a reflective surface is exactly what the real obfuscated files carry: measured on the samples this project tests against, the surface is present in the input and gone from the finished output, so a pass gated on it never runs and never clears the surface that was gating it. Only those callers ask this — today namespace flattening and the dispatcher unwrapper — and they accept that an unresolvable `eval` could in principle have written a prototype, which is no worse than the nothing they asked before. The two facts separate cleanly on the evidence rather than by assumption: every program in the defect ledger that reaches a wrong answer here writes a chain root and has no reflective surface, and every sample that has a surface writes no chain root. """ owner = _PROTOTYPE_OWNERS.get(value_type.__name__) if owner is None: return False return self._roots_unwritten(owner, _INHERITED_CHAIN_ROOTS) def call_is_foldable(self, node, *, receiver_type=None)-
Whether the call node may be evaluated to a constant and have its result replace it. This is the one admission gate every fold shares, so that a rule proven necessary at one site cannot be missing at another — the fold surface acquired its divergent hand-rolled checks precisely because each transform owned its own.
Three questions must all answer yes:
- The callee is still the built-in it is spelled as. A named callee (
parseInt(…),String.fromCharCode(…)) is judged bytrusted_intrinsicon its root name; a call on a literal receiver is judged bytrusted_prototypeon the type that literal's syntax fixes; and a call on another call — a chain link — is judged by asking this whole question of that inner call.receiver_typecovers the remaining case, a caller holding an already-evaluated receiver whose type it knows and this model does not. - Every function-valued argument writes nothing outside itself.
is_effect_free_when_discardedis the right predicate rather thanis_pure: a callback that mutates a fresh local and returns it — thereduceaccumulator idiom — is not pure, but that mutation is the value being computed and an evaluator that runs the call reproduces it. Purity is not sufficient on its own either, since a callback writing a script-scopevarreportswrites_captured=False— that binding is not captured from its perspective — so the write would be dropped while the value folds.written_bindingsrecords it by identity and catches it. - Nothing in the argument list is itself a call this gate does not also clear, so admitting an outer
call cannot smuggle in an inner one. A nested built-in (
Math.floor(Math.abs(-1.7))) is fine precisely because the same questions are asked of it. - No part of the call stores anything the residual would go on to read. The fold deletes the
whole expression, so a store is lost wherever in it the store was written — an argument, the
receiver, or the computed key naming the method; see
_call_stores_nothing.
Trust is not evaluability, and this answers only the first.
trusted_intrinsicsaysunknownFnis undisturbed — true, and no help, since no such built-in exists to fold. Whether a callee can actually be evaluated is the caller's question, answered by its own registry lookup before it asks this one; conflating the two is what let a registry entry exist with no matching trust rule.Expand source code Browse git
def call_is_foldable( self, node: JsCallExpression, *, receiver_type: type | None = None, ) -> bool: """ Whether the call *node* may be evaluated to a constant and have its result replace it. This is the one admission gate every fold shares, so that a rule proven necessary at one site cannot be missing at another — the fold surface acquired its divergent hand-rolled checks precisely because each transform owned its own. Three questions must all answer yes: - The callee is still the built-in it is spelled as. A named callee (`parseInt(...)`, `String.fromCharCode(...)`) is judged by `trusted_intrinsic` on its root name; a call on a literal receiver is judged by `trusted_prototype` on the type that literal's syntax fixes; and a call on another call — a chain link — is judged by asking this whole question of that inner call. `receiver_type` covers the remaining case, a caller holding an already-evaluated receiver whose type it knows and this model does not. - Every function-valued argument writes nothing outside itself. `is_effect_free_when_discarded` is the right predicate rather than `is_pure`: a callback that mutates a fresh local and returns it — the `reduce` accumulator idiom — is not pure, but that mutation is the value being computed and an evaluator that runs the call reproduces it. Purity is not sufficient on its own either, since a callback writing a script-scope `var` reports `writes_captured=False` — that binding is not captured from its perspective — so the write would be dropped while the value folds. `written_bindings` records it by identity and catches it. - Nothing in the argument list is itself a call this gate does not also clear, so admitting an outer call cannot smuggle in an inner one. A nested built-in (`Math.floor(Math.abs(-1.7))`) is fine precisely because the same questions are asked of it. - No part of the call stores anything the residual would go on to read. The fold deletes the whole expression, so a store is lost wherever in it the store was written — an argument, the receiver, or the computed key naming the method; see `_call_stores_nothing`. Trust is not evaluability, and this answers only the first. `trusted_intrinsic` says `unknownFn` is undisturbed — true, and no help, since no such built-in exists to fold. Whether a callee can actually be evaluated is the caller's question, answered by its own registry lookup before it asks this one; conflating the two is what let a registry entry exist with no matching trust rule. """ if not self._callee_is_trusted(node, receiver_type): return False if not self._call_stores_nothing(node): return False return all(self._argument_is_admissible(arg) for arg in node.arguments) - The callee is still the built-in it is spelled as. A named callee (
def member_read_getter_free(self, member, established=None)-
Whether reading member runs no user getter, so it carries no observable effect: a getter-free read off a pristine value (a fresh literal or a pristine intrinsic root) or a trusted global data-property read off a syntactic global-object alias — always, since neither is nullish — or off a local single-assigned to the global object, which holds it only from its establishing definition onward. The local case qualifies only when established confirms that definition reaches the read, an ordering this effect model cannot decide on its own (see
ReachingModel.value_preserved()).Expand source code Browse git
def member_read_getter_free( self, member: JsMemberExpression, established: Callable[[Binding, JsMemberExpression], bool] | None = None, ) -> bool: """ Whether reading *member* runs no user getter, so it carries no observable effect: a getter-free read off a pristine value (a fresh literal or a pristine intrinsic root) or a trusted global data-property read off a syntactic global-object alias — always, since neither is nullish — or off a local single-assigned to the global object, which holds it only from its establishing definition onward. The local case qualifies only when *established* confirms that definition reaches the read, an ordering this effect model cannot decide on its own (see `refinery.lib.scripts.js.analysis.reaching.ReachingModel.value_preserved`). """ if self._getter_free_read(member): return True if established is None: return False binding = self._trusted_global_alias_read(member) return binding is not None and established(binding, member)
class EffectSummary (writes_global=False, writes_captured=False, throws=False, calls_unknown=False, mutates_returned_local=False, wraps_return=False, written_bindings=<factory>)-
The observable effects one call of a function may have, each field a conservative over-estimate.
writes_globalcovers assignment to a global or to a property of an object reached through one;writes_capturedcovers assignment to a binding owned by an enclosing function (a closure mutation visible after the call returns);throwscovers athrow, an operation that may throw on a value the analysis cannot prove safe, or a read of a name that is not certain to denote a binding (SemanticModel.read_may_throw());calls_unknowncovers invoking a callee that cannot be resolved and summarized. A summary with none of these set isis_pure.mutates_returned_localis held apart from those four: it records a write to a fresh local the function owns whose sole route to the caller is the value the call returns. Such a write is a real mutation baked into the returned value, so it blocksis_pureandis_expression_replaceable, but notis_effect_free_when_discarded— a call whose result is thrown away can never expose it — and notis_literal_replaceable, whose replacement is a fresh object at every site.wraps_returnis separate: it does not bear on purity but records that a call to the function yields a wrapper (a promise from anasyncfunction, an iterator from a generator) rather than the value of its return expression, so the call cannot be replaced by that expression.written_bindingsnames, by identity, the outer bindings — captured locals and globals — a call may write where the write resolves to one, so a caller can ask which binding a call mutates rather than only whether it mutates some. It is decided independently of purity: a write the purity analysis deems unobservable because the binding never escapes the function is still recorded here, since a consumer reasoning about a read inside that function must still see the mutation. A binding written but never read anywhere adds nothing, as no read can observe the change; likewise a coarse write with no resolvable binding (a dynamic-scope orglobalThis.x =member write) setswrites_globalbut adds nothing here.Four properties read these flags, and they are not a scale from strict to permissive — each answers a different question about a different rewrite, so a consumer picks by naming the rewrite it is about to perform rather than by how many flags a property excludes:
is_pure— may the call be deleted outright, result and all? Nothing may be lost, so every flag blocks.is_effect_free_when_discarded— may the call be deleted when its result is already unused? A mutation confined to the returned value is then unreachable, so only that flag is forgiven.is_literal_replaceable— may the call become a literal denoting its value? Throwing and unknown reads are reproduced by actually evaluating it, andmutates_returned_localis forgiven because a literal is a fresh object at every site. A rewrite that yields something other than a literal may not use this.is_expression_replaceable— may the call become one expression lifted out of its body, with the rest of the body discarded? Everything the literal case needs, plus a refusal ofmutates_returned_local, since a lifted expression yields no fresh object.
Expand source code Browse git
@dataclass class EffectSummary: """ The observable effects one call of a function may have, each field a conservative over-estimate. `writes_global` covers assignment to a global or to a property of an object reached through one; `writes_captured` covers assignment to a binding owned by an enclosing function (a closure mutation visible after the call returns); `throws` covers a `throw`, an operation that may throw on a value the analysis cannot prove safe, or a read of a name that is not certain to denote a binding (`SemanticModel.read_may_throw`); `calls_unknown` covers invoking a callee that cannot be resolved and summarized. A summary with none of these set is `is_pure`. `mutates_returned_local` is held apart from those four: it records a write to a fresh local the function owns whose sole route to the caller is the value the call returns. Such a write is a real mutation baked into the returned value, so it blocks `is_pure` and `is_expression_replaceable`, but not `is_effect_free_when_discarded` — a call whose result is thrown away can never expose it — and not `is_literal_replaceable`, whose replacement is a fresh object at every site. `wraps_return` is separate: it does not bear on purity but records that a call to the function yields a wrapper (a promise from an `async` function, an iterator from a generator) rather than the value of its return expression, so the call cannot be replaced by that expression. `written_bindings` names, by identity, the outer bindings — captured locals and globals — a call may write where the write resolves to one, so a caller can ask which binding a call mutates rather than only whether it mutates some. It is decided independently of purity: a write the purity analysis deems unobservable because the binding never escapes the function is still recorded here, since a consumer reasoning about a read *inside* that function must still see the mutation. A binding written but never read anywhere adds nothing, as no read can observe the change; likewise a coarse write with no resolvable binding (a dynamic-scope or `globalThis.x =` member write) sets `writes_global` but adds nothing here. Four properties read these flags, and they are not a scale from strict to permissive — each answers a different question about a *different rewrite*, so a consumer picks by naming the rewrite it is about to perform rather than by how many flags a property excludes: - `is_pure` — may the call be deleted outright, result and all? Nothing may be lost, so every flag blocks. - `is_effect_free_when_discarded` — may the call be deleted when its result is already unused? A mutation confined to the returned value is then unreachable, so only that flag is forgiven. - `is_literal_replaceable` — may the call become a literal denoting its value? Throwing and unknown reads are reproduced by actually evaluating it, and `mutates_returned_local` is forgiven because a literal is a fresh object at every site. A rewrite that yields something other than a literal may not use this. - `is_expression_replaceable` — may the call become one expression lifted out of its body, with the rest of the body discarded? Everything the literal case needs, plus a refusal of `mutates_returned_local`, since a lifted expression yields no fresh object. """ writes_global: bool = False writes_captured: bool = False throws: bool = False calls_unknown: bool = False mutates_returned_local: bool = False wraps_return: bool = False written_bindings: set[Binding] = field(default_factory=set) @property def is_pure(self) -> bool: """ Whether a call to the summarized function produces no observable effect, so it carries no consequence the program can detect (termination aside) whether or not its result is used. A mutation the function confines to its returned value (`mutates_returned_local`) disqualifies it here, since a caller that uses the result observes that mutation; `is_effect_free_when_discarded` is the companion test for a call whose result is thrown away, which tolerates it. """ return not ( self.writes_global or self.writes_captured or self.throws or self.calls_unknown or self.mutates_returned_local ) @property def is_effect_free_when_discarded(self) -> bool: """ Whether a call to the summarized function, its result discarded, produces no observable effect. Identical to `is_pure` except it tolerates `mutates_returned_local`: a write to a fresh local the function owns is observable only through the value the call returns, so once that value is thrown away the write can never be seen and the call is free to drop. Every other way such a local — or a closure over it — reaches the caller is a distinct effect that independently sets a blocking flag (a store to a global, a store to an enclosing capture, a leak into an unknown callee, a throw), so excluding only `mutates_returned_local` here stays sound. """ return not (self.writes_global or self.writes_captured or self.throws or self.calls_unknown) @property def is_literal_replaceable(self) -> bool: """ Whether a call to the summarized function may be replaced by a *literal* denoting its computed return value. This holds when the call writes no state visible after it returns — neither a global nor a captured binding — and returns its value directly rather than wrapped: an `async` function's call is a promise and a generator's is an iterator, neither equal to the return expression, so `wraps_return` disqualifies it. Unlike `is_pure`, a call that may throw or read unknown state still qualifies: an evaluator that actually executes the call to a value reproduces those, and only a *write* would be silently lost. `mutates_returned_local` is tolerated, and the name of this property is what licenses that. Such a mutation is baked into a container the call returns, so the substituted value must be a distinct object per call — which a literal is, because `value_to_node` builds a new array or object literal at every site it fills. A replacement that is *not* a literal has no such guarantee and must not consult this property; see the family note on `EffectSummary`. """ return not ( self.writes_global or self.writes_captured or self.wraps_return ) @property def is_expression_replaceable(self) -> bool: """ Whether a call to the summarized function may be replaced by a single expression lifted out of its body, with everything else the body would have done discarded. Everything `is_literal_replaceable` requires is required here, for the same reasons: a write to a global or a capture would be lost, and a wrapped return is not the return expression. Throwing and unknown reads are tolerated identically — the lifted expression sits at the call site and still performs them, so they are reproduced rather than dropped, which is why the discard question is *not* the right one to ask even though statements are being discarded. What this additionally forbids is `mutates_returned_local`. The literal case tolerates it because `value_to_node` builds a new array or object at every site it fills, so the distinct container the mutation is baked into survives. A lifted expression is spliced from the body and names whatever the body named, guaranteeing nothing about identity, so a mutated container must not travel this way. """ return self.is_literal_replaceable and not self.mutates_returned_local def absorb(self, other: EffectSummary): """ Union *other*'s effects into this summary, used to fold a callee's effects into its caller. """ self.writes_global = self.writes_global or other.writes_global self.writes_captured = self.writes_captured or other.writes_captured self.throws = self.throws or other.throws self.calls_unknown = self.calls_unknown or other.calls_unknown self.mutates_returned_local = self.mutates_returned_local or other.mutates_returned_local self.written_bindings |= other.written_bindingsInstance variables
var written_bindings-
The type of the None singleton.
var writes_global-
The type of the None singleton.
var writes_captured-
The type of the None singleton.
var throws-
The type of the None singleton.
var calls_unknown-
The type of the None singleton.
var mutates_returned_local-
The type of the None singleton.
var wraps_return-
The type of the None singleton.
var is_pure-
Whether a call to the summarized function produces no observable effect, so it carries no consequence the program can detect (termination aside) whether or not its result is used. A mutation the function confines to its returned value (
mutates_returned_local) disqualifies it here, since a caller that uses the result observes that mutation;is_effect_free_when_discardedis the companion test for a call whose result is thrown away, which tolerates it.Expand source code Browse git
@property def is_pure(self) -> bool: """ Whether a call to the summarized function produces no observable effect, so it carries no consequence the program can detect (termination aside) whether or not its result is used. A mutation the function confines to its returned value (`mutates_returned_local`) disqualifies it here, since a caller that uses the result observes that mutation; `is_effect_free_when_discarded` is the companion test for a call whose result is thrown away, which tolerates it. """ return not ( self.writes_global or self.writes_captured or self.throws or self.calls_unknown or self.mutates_returned_local ) var is_effect_free_when_discarded-
Whether a call to the summarized function, its result discarded, produces no observable effect. Identical to
is_pureexcept it toleratesmutates_returned_local: a write to a fresh local the function owns is observable only through the value the call returns, so once that value is thrown away the write can never be seen and the call is free to drop. Every other way such a local — or a closure over it — reaches the caller is a distinct effect that independently sets a blocking flag (a store to a global, a store to an enclosing capture, a leak into an unknown callee, a throw), so excluding onlymutates_returned_localhere stays sound.Expand source code Browse git
@property def is_effect_free_when_discarded(self) -> bool: """ Whether a call to the summarized function, its result discarded, produces no observable effect. Identical to `is_pure` except it tolerates `mutates_returned_local`: a write to a fresh local the function owns is observable only through the value the call returns, so once that value is thrown away the write can never be seen and the call is free to drop. Every other way such a local — or a closure over it — reaches the caller is a distinct effect that independently sets a blocking flag (a store to a global, a store to an enclosing capture, a leak into an unknown callee, a throw), so excluding only `mutates_returned_local` here stays sound. """ return not (self.writes_global or self.writes_captured or self.throws or self.calls_unknown) var is_literal_replaceable-
Whether a call to the summarized function may be replaced by a literal denoting its computed return value. This holds when the call writes no state visible after it returns — neither a global nor a captured binding — and returns its value directly rather than wrapped: an
asyncfunction's call is a promise and a generator's is an iterator, neither equal to the return expression, sowraps_returndisqualifies it. Unlikeis_pure, a call that may throw or read unknown state still qualifies: an evaluator that actually executes the call to a value reproduces those, and only a write would be silently lost.mutates_returned_localis tolerated, and the name of this property is what licenses that. Such a mutation is baked into a container the call returns, so the substituted value must be a distinct object per call — which a literal is, becausevalue_to_nodebuilds a new array or object literal at every site it fills. A replacement that is not a literal has no such guarantee and must not consult this property; see the family note onEffectSummary.Expand source code Browse git
@property def is_literal_replaceable(self) -> bool: """ Whether a call to the summarized function may be replaced by a *literal* denoting its computed return value. This holds when the call writes no state visible after it returns — neither a global nor a captured binding — and returns its value directly rather than wrapped: an `async` function's call is a promise and a generator's is an iterator, neither equal to the return expression, so `wraps_return` disqualifies it. Unlike `is_pure`, a call that may throw or read unknown state still qualifies: an evaluator that actually executes the call to a value reproduces those, and only a *write* would be silently lost. `mutates_returned_local` is tolerated, and the name of this property is what licenses that. Such a mutation is baked into a container the call returns, so the substituted value must be a distinct object per call — which a literal is, because `value_to_node` builds a new array or object literal at every site it fills. A replacement that is *not* a literal has no such guarantee and must not consult this property; see the family note on `EffectSummary`. """ return not ( self.writes_global or self.writes_captured or self.wraps_return ) var is_expression_replaceable-
Whether a call to the summarized function may be replaced by a single expression lifted out of its body, with everything else the body would have done discarded.
Everything
is_literal_replaceablerequires is required here, for the same reasons: a write to a global or a capture would be lost, and a wrapped return is not the return expression. Throwing and unknown reads are tolerated identically — the lifted expression sits at the call site and still performs them, so they are reproduced rather than dropped, which is why the discard question is not the right one to ask even though statements are being discarded.What this additionally forbids is
mutates_returned_local. The literal case tolerates it becausevalue_to_nodebuilds a new array or object at every site it fills, so the distinct container the mutation is baked into survives. A lifted expression is spliced from the body and names whatever the body named, guaranteeing nothing about identity, so a mutated container must not travel this way.Expand source code Browse git
@property def is_expression_replaceable(self) -> bool: """ Whether a call to the summarized function may be replaced by a single expression lifted out of its body, with everything else the body would have done discarded. Everything `is_literal_replaceable` requires is required here, for the same reasons: a write to a global or a capture would be lost, and a wrapped return is not the return expression. Throwing and unknown reads are tolerated identically — the lifted expression sits at the call site and still performs them, so they are reproduced rather than dropped, which is why the discard question is *not* the right one to ask even though statements are being discarded. What this additionally forbids is `mutates_returned_local`. The literal case tolerates it because `value_to_node` builds a new array or object at every site it fills, so the distinct container the mutation is baked into survives. A lifted expression is spliced from the body and names whatever the body named, guaranteeing nothing about identity, so a mutated container must not travel this way. """ return self.is_literal_replaceable and not self.mutates_returned_local
Methods
def absorb(self, other)-
Union other's effects into this summary, used to fold a callee's effects into its caller.
Expand source code Browse git
def absorb(self, other: EffectSummary): """ Union *other*'s effects into this summary, used to fold a callee's effects into its caller. """ self.writes_global = self.writes_global or other.writes_global self.writes_captured = self.writes_captured or other.writes_captured self.throws = self.throws or other.throws self.calls_unknown = self.calls_unknown or other.calls_unknown self.mutates_returned_local = self.mutates_returned_local or other.mutates_returned_local self.written_bindings |= other.written_bindings
class LivenessModel (model, control_flow=None)-
Flow-sensitive live-variable sets and dead-store verdicts for one script, built over a
SemanticModel. Query a control-flow node's live sets withlive_inandlive_out, find the node standing for an AST element withnode_of, and ask whether a write is dead withis_dead_store. Build throughbuild_liveness().Expand source code Browse git
class LivenessModel: """ Flow-sensitive live-variable sets and dead-store verdicts for one script, built over a `refinery.lib.scripts.js.analysis.model.SemanticModel`. Query a control-flow node's live sets with `live_in` and `live_out`, find the node standing for an AST element with `node_of`, and ask whether a write is dead with `is_dead_store`. Build through `build_liveness`. """ def __init__(self, model: SemanticModel, control_flow: ControlFlowModel | None = None): self.model = model self._flow = control_flow if control_flow is not None else build_control_flow_model(model.root) self._live_in: dict[int, frozenset[Binding]] = {} self._live_out: dict[int, frozenset[Binding]] = {} self._pseudo_locals: dict[int, frozenset[Binding]] = {} self._index_pseudo_locals() for graph in self._flow.graphs.values(): self._compute_graph(graph) def live_in(self, node: CfgNode) -> frozenset[Binding]: """ The bindings live on entry to *node* — those that may be read before being overwritten on some path that begins at *node*. """ return self._live_in.get(id(node), frozenset()) def live_out(self, node: CfgNode) -> frozenset[Binding]: """ The bindings live on exit from *node* — those that may be read on some path that leaves it, including the path taken if *node* throws. """ return self._live_out.get(id(node), frozenset()) def node_of(self, element: Node) -> CfgNode | None: """ The control-flow node standing for *element* in whichever function graph owns it, or `None` if *element* is not a node the graphs represent. """ return self._flow.node_of(element) def is_dead_store(self, write: JsIdentifier) -> bool: """ Whether the value written to a binding at *write* is never read on any execution. Only an unconditional store to an uncaptured function-local `var`/`let` qualifies; a read, a compound or conditional write, a captured or outer binding, or a store whose value may still be read all return `False`, the conservative verdict. The verdict concerns the stored *value* alone: a caller removing the store must still preserve any side effect of the expression producing it. No store is reported while a `with` or direct `eval` lexically inside the owning function could read the local by name without a reference the model sees. A reflective surface elsewhere in the program runs in the global scope and cannot reach a local, so it does not suppress the report. """ located = self._flow.locate(write) if located is None: return False graph, node = located owner_scope = self.model.function_scope(graph.owner) binding, construct = self._store_target(write) if binding is None or construct is None: return False if not self._trackable(binding, owner_scope): return False if self.model.reflection_can_reach(binding): return False if binding in self.live_out(node): return False return self._unobserved_within(graph, node, write, binding, construct) def dead_stores(self) -> list[JsIdentifier]: """ Every write identifier in the script whose stored value is dead, in source order. """ result: list[JsIdentifier] = [] for node in self.model.root.walk_in_order(): if isinstance(node, JsIdentifier) and self._is_candidate_write(node): if self.is_dead_store(node): result.append(node) return result def is_dead_on_entry(self, binding: Binding, function: Node) -> bool: """ Whether *function* writes *binding* before reading it on every path, so no value carried into the call — from a previous invocation or from load — is ever observed. Answered from the liveness at the function's control-flow entry; a binding not tracked in *function* returns `False`. """ graph = self._flow.graph_of(function) if graph is None: return False return binding not in self.live_in(graph.entry) def localization_target(self, binding: Binding) -> Node | None: """ The function into which *binding*, a script-scope `var`, can be soundly relocated, or `None`. A binding qualifies when every reference is owned by one function, that function writes it before any read (so a value carried across calls or from load is never observed), it has no initializer whose load-time effect the move would strand, no reference reaches it through a global-object alias member (`globalThis.x`, which would no longer find it once it leaves the global object), it is not exported (an importer reads its value where the module leaves it, which a body local cannot answer), and the program keeps no reflection surface that could read it by name. Relocating it tightens a pseudo-global into the local it behaves as. """ if self.model.has_reflection_surface(): return None if binding.kind is not BindingKind.VAR or binding.scope is not self.model.root_scope: return None if binding.exported or binding.has_member_reference: return None if self._has_initializer(binding): return None function = self._sole_owning_function(binding) if function is None: return None if not self.is_dead_on_entry(binding, function): return None return function def localizable_bindings(self) -> list[tuple[Binding, Node]]: """ Every script-scope `var` binding that can be relocated into a function, each paired with that function, in the order the script declares them. """ result: list[tuple[Binding, Node]] = [] for binding in self.model.root_scope.bindings.values(): function = self.localization_target(binding) if function is not None: result.append((binding, function)) return result def _index_pseudo_locals(self): """ Group the script-scope `var` bindings that each behave as a single function's locals, keyed by that function, so the dataflow can track them inside it. A binding qualifies when every reference lies in one function and none at script scope or in a function nested below it. """ grouped: dict[int, set[Binding]] = {} for binding in self.model.root_scope.bindings.values(): if binding.kind is not BindingKind.VAR: continue function = self._sole_owning_function(binding) if function is not None: grouped.setdefault(id(function), set()).add(binding) self._pseudo_locals = {owner: frozenset(bindings) for owner, bindings in grouped.items()} def _compute_graph(self, graph: ControlFlowGraph): owner_scope = self.model.function_scope(graph.owner) def node_sets(_graph: ControlFlowGraph, node: CfgNode) -> tuple[set[Binding], set[Binding]]: return self._node_sets(_graph, node, owner_scope) live_in, live_out = solve_liveness(graph, node_sets) self._live_in.update(live_in) self._live_out.update(live_out) def _node_sets( self, graph: ControlFlowGraph, node: CfgNode, owner_scope: Scope | None, ) -> tuple[set[Binding], set[Binding]]: use: set[Binding] = set() kill: set[Binding] = set() if node.element is None: return use, kill for ident in self._shallow_idents(graph, node.element): declared = self.model.binding_of(ident) if declared is not None: if self._trackable(declared, owner_scope) and self._declarator_has_init(ident): kill.add(declared) continue if not is_use_position(ident): continue binding = self.model.resolve(ident) if binding is None or not self._analysable(graph, binding, owner_scope): continue role = reference_role(ident) if role is not Role.WRITE: use.add(binding) elif self._is_assignment_kill(ident, node.element): kill.add(binding) return use, kill def _unobserved_within( self, graph: ControlFlowGraph, node: CfgNode, write: JsIdentifier, binding: Binding, construct: Node, ) -> bool: """ Whether no reference to *binding* other than *write* within *node* can observe *write*'s value. A read nested in *construct* (the assignment or declarator performing the write) consumes the prior value, so it is ignored; any other reference — a later read or a second write in the same statement — is treated conservatively as observing the store, since intra-statement order is not modelled. """ assert node.element is not None for ident in self._shallow_idents(graph, node.element): if ident is write: continue if self._reference_binding(ident) is not binding: continue if self._is_read(ident) and ident.is_descendant_of(construct): continue return False return True def _shallow_idents(self, graph: ControlFlowGraph, element: Node) -> Iterator[JsIdentifier]: """ Yield the identifiers belonging to *element*'s own control-flow node: those in its subtree that are not inside a nested function or a descendant that is itself a separate control-flow node (whose identifiers are accounted there). This keeps a loop or branch head from double-counting the body that follows it. """ stack: list[Node] = list(element.children()) while stack: current = stack.pop() if isinstance(current, FUNCTION_NODES): continue if graph.node_of(current) is not None: continue if isinstance(current, JsIdentifier): yield current stack.extend(current.children()) def _store_target(self, write: JsIdentifier) -> tuple[Binding | None, Node | None]: """ The binding *write* stores into and the construct whose completion performs the store, or `(None, None)` if *write* is not an unconditional value store: a `var`/`let`/`const` declarator with an initializer, or the target of a plain `=` assignment. """ declared = self.model.binding_of(write) if declared is not None: if not self._declarator_has_init(write): return None, None return declared, self._enclosing_declarator(write) if not is_use_position(write): return None, None binding = self.model.resolve(write) if binding is None or reference_role(write) is not Role.WRITE: return None, None governor = self._governor(write) if not isinstance(governor, JsAssignmentExpression) or governor.operator != '=': return None, None return binding, governor def _is_candidate_write(self, ident: JsIdentifier) -> bool: if self.model.binding_of(ident) is not None: return self._declarator_has_init(ident) if not is_use_position(ident): return False return reference_role(ident) is Role.WRITE def _is_assignment_kill(self, ident: JsIdentifier, element: Node) -> bool: governor = self._governor(ident) if not isinstance(governor, JsAssignmentExpression) or governor.operator != '=': return False return self._is_unconditional(ident, element) def _is_read(self, ident: JsIdentifier) -> bool: if not self.model.is_reference(ident): return False return reference_role(ident) is not Role.WRITE def _reference_binding(self, ident: JsIdentifier) -> Binding | None: declared = self.model.binding_of(ident) if declared is not None: return declared if not is_use_position(ident): return None return self.model.resolve(ident) def _declarator_has_init(self, ident: JsIdentifier) -> bool: declarator = self._enclosing_declarator(ident) return declarator is not None and declarator.init is not None def _enclosing_declarator(self, ident: JsIdentifier) -> JsVariableDeclarator | None: governor, target = _governing_target(ident) if isinstance(governor, JsVariableDeclarator) and governor.id is target: return governor return None def _governor(self, ident: JsIdentifier) -> Node | None: """ The construct that governs the binding target *ident* sits in — the assignment, declarator, or loop head reached by climbing out through any destructuring containers and parentheses around it, or `None` past the top of the tree. """ governor, _ = _governing_target(ident) return governor @staticmethod def _is_unconditional(ident: JsIdentifier, element: Node) -> bool: """ Whether *ident* is written every time its control-flow node *element* runs, i.e. its position is not guarded by a short-circuit operand, a conditional branch, or a destructuring default. """ cursor: Node = ident while cursor is not element: parent = cursor.parent if parent is None: return True if isinstance(parent, JsLogicalExpression) and parent.right is cursor: return False if isinstance(parent, JsConditionalExpression) and cursor in ( parent.consequent, parent.alternate, ): return False if isinstance(parent, JsAssignmentPattern) and parent.right is cursor: return False cursor = parent return True def _trackable(self, binding: Binding, owner_scope: Scope | None) -> bool: """ Whether *binding* is a local of the function *owner_scope* is the body scope of, so that the graph built for that function sees every store to it. The parameters of a function whose parameter list holds an expression are bound in a scope of their own standing outside the body's, so their variable scope is that one rather than the body's; `Scope.closure_home` says the two belong to one call, and is what this asks. """ return ( binding.kind in _CANDIDATE_KINDS and not binding.captured and owner_scope is not None and owner_scope.kind is ScopeKind.FUNCTION and binding.scope.closure_home is owner_scope ) def _analysable( self, graph: ControlFlowGraph, binding: Binding, owner_scope: Scope | None, ) -> bool: """ Whether *binding* is tracked in *graph*: either an uncaptured function-local of the graph's own function (the strict store candidate) or a script-scope `var` whose every reference is owned by that function and so behaves as one of its locals. The second case feeds only the entry-liveness `localization_target` reads; it never reaches `is_dead_store`, which keeps the strict candidacy. """ if self._trackable(binding, owner_scope): return True return binding in self._pseudo_locals.get(id(graph.owner), frozenset()) def _sole_owning_function(self, binding: Binding) -> Node | None: """ The one function whose body lexically contains every reference to *binding*, or `None` when the references span more than one function, include one at script scope, or do not exist. A reference inside a function nested below the candidate counts as a separate owner, so a binding captured by such a nested closure is rejected. """ owner: Node | None = None for ref in (*binding.reads, *binding.writes): function = enclosing_function(ref) if function is None: return None if owner is None: owner = function elif function is not owner: return None return owner def _has_initializer(self, binding: Binding) -> bool: for declaration in binding.declarations: declarator = self._enclosing_declarator(declaration) if declarator is not None and declarator.init is not None: return True return FalseMethods
def live_in(self, node)-
The bindings live on entry to node — those that may be read before being overwritten on some path that begins at node.
Expand source code Browse git
def live_in(self, node: CfgNode) -> frozenset[Binding]: """ The bindings live on entry to *node* — those that may be read before being overwritten on some path that begins at *node*. """ return self._live_in.get(id(node), frozenset()) def live_out(self, node)-
The bindings live on exit from node — those that may be read on some path that leaves it, including the path taken if node throws.
Expand source code Browse git
def live_out(self, node: CfgNode) -> frozenset[Binding]: """ The bindings live on exit from *node* — those that may be read on some path that leaves it, including the path taken if *node* throws. """ return self._live_out.get(id(node), frozenset()) def node_of(self, element)-
The control-flow node standing for element in whichever function graph owns it, or
Noneif element is not a node the graphs represent.Expand source code Browse git
def node_of(self, element: Node) -> CfgNode | None: """ The control-flow node standing for *element* in whichever function graph owns it, or `None` if *element* is not a node the graphs represent. """ return self._flow.node_of(element) def is_dead_store(self, write)-
Whether the value written to a binding at write is never read on any execution. Only an unconditional store to an uncaptured function-local
var/letqualifies; a read, a compound or conditional write, a captured or outer binding, or a store whose value may still be read all returnFalse, the conservative verdict. The verdict concerns the stored value alone: a caller removing the store must still preserve any side effect of the expression producing it.No store is reported while a
withor directevallexically inside the owning function could read the local by name without a reference the model sees. A reflective surface elsewhere in the program runs in the global scope and cannot reach a local, so it does not suppress the report.Expand source code Browse git
def is_dead_store(self, write: JsIdentifier) -> bool: """ Whether the value written to a binding at *write* is never read on any execution. Only an unconditional store to an uncaptured function-local `var`/`let` qualifies; a read, a compound or conditional write, a captured or outer binding, or a store whose value may still be read all return `False`, the conservative verdict. The verdict concerns the stored *value* alone: a caller removing the store must still preserve any side effect of the expression producing it. No store is reported while a `with` or direct `eval` lexically inside the owning function could read the local by name without a reference the model sees. A reflective surface elsewhere in the program runs in the global scope and cannot reach a local, so it does not suppress the report. """ located = self._flow.locate(write) if located is None: return False graph, node = located owner_scope = self.model.function_scope(graph.owner) binding, construct = self._store_target(write) if binding is None or construct is None: return False if not self._trackable(binding, owner_scope): return False if self.model.reflection_can_reach(binding): return False if binding in self.live_out(node): return False return self._unobserved_within(graph, node, write, binding, construct) def dead_stores(self)-
Every write identifier in the script whose stored value is dead, in source order.
Expand source code Browse git
def dead_stores(self) -> list[JsIdentifier]: """ Every write identifier in the script whose stored value is dead, in source order. """ result: list[JsIdentifier] = [] for node in self.model.root.walk_in_order(): if isinstance(node, JsIdentifier) and self._is_candidate_write(node): if self.is_dead_store(node): result.append(node) return result def is_dead_on_entry(self, binding, function)-
Whether function writes binding before reading it on every path, so no value carried into the call — from a previous invocation or from load — is ever observed. Answered from the liveness at the function's control-flow entry; a binding not tracked in function returns
False.Expand source code Browse git
def is_dead_on_entry(self, binding: Binding, function: Node) -> bool: """ Whether *function* writes *binding* before reading it on every path, so no value carried into the call — from a previous invocation or from load — is ever observed. Answered from the liveness at the function's control-flow entry; a binding not tracked in *function* returns `False`. """ graph = self._flow.graph_of(function) if graph is None: return False return binding not in self.live_in(graph.entry) def localization_target(self, binding)-
The function into which binding, a script-scope
var, can be soundly relocated, orNone. A binding qualifies when every reference is owned by one function, that function writes it before any read (so a value carried across calls or from load is never observed), it has no initializer whose load-time effect the move would strand, no reference reaches it through a global-object alias member (globalThis.x, which would no longer find it once it leaves the global object), it is not exported (an importer reads its value where the module leaves it, which a body local cannot answer), and the program keeps no reflection surface that could read it by name. Relocating it tightens a pseudo-global into the local it behaves as.Expand source code Browse git
def localization_target(self, binding: Binding) -> Node | None: """ The function into which *binding*, a script-scope `var`, can be soundly relocated, or `None`. A binding qualifies when every reference is owned by one function, that function writes it before any read (so a value carried across calls or from load is never observed), it has no initializer whose load-time effect the move would strand, no reference reaches it through a global-object alias member (`globalThis.x`, which would no longer find it once it leaves the global object), it is not exported (an importer reads its value where the module leaves it, which a body local cannot answer), and the program keeps no reflection surface that could read it by name. Relocating it tightens a pseudo-global into the local it behaves as. """ if self.model.has_reflection_surface(): return None if binding.kind is not BindingKind.VAR or binding.scope is not self.model.root_scope: return None if binding.exported or binding.has_member_reference: return None if self._has_initializer(binding): return None function = self._sole_owning_function(binding) if function is None: return None if not self.is_dead_on_entry(binding, function): return None return function def localizable_bindings(self)-
Every script-scope
varbinding that can be relocated into a function, each paired with that function, in the order the script declares them.Expand source code Browse git
def localizable_bindings(self) -> list[tuple[Binding, Node]]: """ Every script-scope `var` binding that can be relocated into a function, each paired with that function, in the order the script declares them. """ result: list[tuple[Binding, Node]] = [] for binding in self.model.root_scope.bindings.values(): function = self.localization_target(binding) if function is not None: result.append((binding, function)) return result
class Role (*args, **kwds)-
Create a collection of name/value pairs.
Example enumeration:
>>> class Color(Enum): ... RED = 1 ... BLUE = 2 ... GREEN = 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 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 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 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_unresolvablenames. 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_of()is the function-typed specialization of this query, and it is the value-resolution the bare-assignment recognition sites route through instead of re-deriving binding shapes.Expand source code Browse git
def singular_value(self, binding: Binding | None) -> Node | None: """ The single value node a *binding* provably holds: the initializer of a sole `var`/`let`/`const` declarator, the function of a sole function declaration, or the right-hand side of the one assignment that establishes a name written exactly once (`x = <value>`, the form namespace flattening leaves). `None` when the binding is absent, redeclared, reassigned to more than one value, dynamically rebindable, or declared with no initializer and never assigned. The value is what the name denotes wherever it is not in the value's temporal dead zone; a consumer that also needs the value established before a use orders it separately, since a bare-assignment binding reads `undefined` before its write. `EffectModel.function_of` is the function-typed specialization of this query, and it is the value-resolution the bare-assignment recognition sites route through instead of re-deriving binding shapes. """ if binding is None or len(binding.declarations) != 1: return None if 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_baserather 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)