Module refinery.lib.scripts.js.deobfuscation.constants
Inline constant variable references in JavaScript.
Expand source code Browse git
"""
Inline constant variable references in JavaScript.
"""
from __future__ import annotations
from typing import NamedTuple
from refinery.lib.scripts import (
Node,
_clone_node,
_remove_from_parent,
_replace_in_parent,
is_attached,
)
from refinery.lib.scripts.js.analysis.cache import ModelCache, model_cache
from refinery.lib.scripts.js.analysis.dominance import DominanceModel
from refinery.lib.scripts.js.analysis.effects import EffectModel
from refinery.lib.scripts.js.analysis.model import (
FUNCTION_NODES,
Binding,
Role,
SemanticModel,
enclosing_function,
is_invocation_target,
is_member_write_target,
pattern_identifiers,
reference_role,
)
from refinery.lib.scripts.js.analysis.reaching import ReachingModel
from refinery.lib.scripts.js.deobfuscation.helpers import (
BatchedScopeTransformer,
a_host_reaches_the_binding,
collect_identifier_names,
is_literal,
remove_declarator,
substitute_use_position,
walk_scope,
)
from refinery.lib.scripts.js.model import (
JsArrayExpression,
JsArrayPattern,
JsArrowFunctionExpression,
JsAssignmentExpression,
JsAwaitExpression,
JsCallExpression,
JsClassExpression,
JsExpressionStatement,
JsForInStatement,
JsForOfStatement,
JsFunctionDeclaration,
JsFunctionExpression,
JsIdentifier,
JsMemberExpression,
JsNewExpression,
JsNumericLiteral,
JsObjectExpression,
JsObjectPattern,
JsScript,
JsStringLiteral,
JsTaggedTemplateExpression,
JsUnaryExpression,
JsUpdateExpression,
JsVariableDeclaration,
JsVariableDeclarator,
JsVarKind,
JsYieldExpression,
strip_parens,
)
from refinery.lib.scripts.js.numbers import exact_integer
def _pattern_identifiers(pattern: Node) -> set[str]:
"""
Extract all identifier names from a destructuring pattern (array or object pattern). These are
the variables being assigned to.
"""
return {n.name for n in pattern.walk() if isinstance(n, JsIdentifier)}
class _CandidateEntry(NamedTuple):
declarator: JsVariableDeclarator | None
value: Node
class _MemberArrayEntry(NamedTuple):
assignment: JsAssignmentExpression
array: JsArrayExpression
class _Substitution(NamedTuple):
target: Node
value: Node
key: str
class _DeclaratorRemoval(NamedTuple):
declarator: JsVariableDeclarator
key: str
class _MemberArrayRemoval(NamedTuple):
assignment: JsAssignmentExpression
key: str
class _ScopePlan(NamedTuple):
scope: Node
substitutions: list[_Substitution]
declarator_removals: list[_DeclaratorRemoval]
member_array_removals: list[_MemberArrayRemoval]
decl_ids: set[int]
planned: set[str]
def _candidate_decl_ids(candidates: dict[str, list[_CandidateEntry]]) -> set[int]:
"""
Collect the `id()` values of all declaration-site identifier nodes across candidate entries.
Used to distinguish binding occurrences from reference occurrences during scope walks.
"""
result: set[int] = set()
for entries in candidates.values():
for entry in entries:
if (d := entry.declarator) is not None:
result.add(id(d.id))
return result
def _is_primitive_and_pure(node: Node) -> bool:
"""
Return whether evaluating *node* is guaranteed to produce no observable side effects and the
result is a primitive value (not an object, array, or function). This is stricter than
`refinery.lib.scripts.js.analysis.effects.side_effect_free` — it rejects expressions
that allocate objects or access properties, because inlining such expressions into a new
location can change reference identity or trigger getters at a different point in execution.
The deliberate complement of `EffectModel._fresh_kind`, over primitives rather than containers, and not
to be merged with it: this admits exactly the values that have no identity to duplicate, where that one
admits containers whose identity is known to be new. Their answers are disjoint by construction.
A `delete` is observable — it removes a binding or property — so its unary form is rejected while
the operator's other uses, none of which touch what they name, stay admitted.
"""
for n in node.walk():
if isinstance(n, (
JsCallExpression,
JsNewExpression,
JsAssignmentExpression,
JsUpdateExpression,
JsYieldExpression,
JsAwaitExpression,
JsTaggedTemplateExpression,
JsMemberExpression,
JsObjectExpression,
JsArrayExpression,
JsFunctionExpression,
JsArrowFunctionExpression,
JsClassExpression,
)):
return False
if isinstance(n, JsUnaryExpression) and n.operator == 'delete':
return False
return True
def _count_scope_references(
scope: Node,
names: set[str],
decl_ids: set[int],
*,
walk_full: bool = False,
count_member_access: bool = False,
) -> dict[str, int]:
"""
Count identifier references within *scope* for each name in *names*, excluding declaration
sites in *decl_ids* and simple (`=`) assignment write targets. A compound assignment (`x += e`,
`x <<= e`) reads its target, so its left side is counted as a reference and the variable stays
live. When *walk_full* is True, the entire subtree
is traversed (including nested function bodies); otherwise only the current scope is walked.
When *count_member_access* is True, computed member accesses like `name[idx]` are counted
separately (the identifier inside the member is counted and the walk continues so the member
node itself is not double-counted).
"""
walker = scope.walk() if walk_full else walk_scope(scope, include_root_body=True)
counts: dict[str, int] = {}
for node in walker:
if count_member_access and isinstance(node, JsMemberExpression) and node.computed:
obj = node.object
if isinstance(obj, JsIdentifier) and id(obj) not in decl_ids and obj.name in names:
counts[obj.name] = counts.get(obj.name, 0) + 1
continue
if not isinstance(node, JsIdentifier):
continue
if id(node) in decl_ids:
continue
name = node.name
if name not in names:
continue
parent = node.parent
if isinstance(parent, JsAssignmentExpression) and parent.left is node and parent.operator == '=':
continue
if isinstance(parent, JsMemberExpression) and parent.property is node and not parent.computed:
continue
if count_member_access:
if isinstance(parent, JsMemberExpression) and parent.object is node and parent.computed:
continue
counts[name] = counts.get(name, 0) + 1
return counts
def _is_literal_array(node: Node) -> bool:
"""
Return whether *node* is a `refinery.lib.scripts.js.model.JsArrayExpression` where every element
is a literal.
"""
if not isinstance(node, JsArrayExpression):
return False
return all(el is not None and is_literal(el) for el in node.elements)
def _is_member_array_safe(scope: Node, prefix_name: str, prop_name: str) -> bool:
"""
Verify that `prefix.prop` (a member-expression array) is never mutated after its initial
assignment. Checks that: (1) the property is never written to via an element write —
`prefix.prop[i] = ...`, but also `prefix.prop[i]++`, `delete prefix.prop[i]`, and any other write
target, decided by the shared `is_member_write_target` climb rather than a hand-rolled
assignment-only test that a compound, update, or `delete` slips past; (2) the property value is
never passed as an argument or assigned to another variable (aliased); (3) no method calls that
could mutate the array exist (`prefix.prop.push(...)` etc.).
"""
for node in scope.walk():
if not isinstance(node, JsMemberExpression):
continue
if node.object is None or node.property is None:
continue
obj = node.object
if not isinstance(obj, JsIdentifier) or obj.name != prefix_name:
continue
if not isinstance(node.property, JsIdentifier) or node.property.name != prop_name:
continue
parent = node.parent
if isinstance(parent, JsMemberExpression) and parent.object is node:
if parent.computed:
if is_member_write_target(parent):
return False
else:
if is_invocation_target(parent):
return False
continue
if isinstance(parent, JsAssignmentExpression) and parent.left is node:
continue
if isinstance(parent, JsCallExpression):
if not is_invocation_target(node):
return False
return True
def _is_constant_value(node: Node) -> bool:
"""
Return whether *node* is a constant value eligible for multi-use inlining: a scalar literal or
an all-literal array.
"""
return is_literal(node) or _is_literal_array(node)
def _is_intrinsic_alias_value(effects: EffectModel, node: Node) -> bool:
"""
Whether *node* is a bare identifier naming a pristine intrinsic (or the global object) — a stable,
unshadowed value that may be inlined at a cross-function use as itself, emitting the same name. Like a
literal it never goes stale (the intrinsic is pristine and the binding is single-assigned), so it
joins the `const`-like class the cross-function inliner admits; the emitted name is separately checked
unshadowed at each use, since intrinsic pristineness only rules out shadowing at the script scope.
"""
return isinstance(node, JsIdentifier) and effects.intrinsic_of(node) is not None
def _is_const_qualified(declarator: JsVariableDeclarator) -> bool:
parent = declarator.parent
return isinstance(parent, JsVariableDeclaration) and parent.kind is JsVarKind.CONST
def _calls_a_function_of_this_file_by_an_unstated_name(scope: Node, effects: EffectModel) -> bool:
"""
Whether *scope* holds a call naming its callee by an identifier this file binds while the model
declines to say which function that name holds.
Such a call may run any function written here, one that writes a candidate included, and
`_collect_call_sites` cannot report it: that answers with the functions a call may reach, and
this one reaches none it can name. Where it is true, a candidate survives only if no function of
this file writes it at all, which is what the substitution needs: it is the only guard standing
between a non-escaping mutator and a constant inlined past its write.
"""
return any(
effects.a_name_this_file_binds_holds_the_callee(node)
for node in walk_scope(scope, include_root_body=True)
if isinstance(node, JsCallExpression)
)
def _collect_called_functions(scope: Node, effects: EffectModel) -> list[Node]:
"""
The statically resolvable callees within *scope*, each listed once in first-seen order. A call
whose target `EffectModel.static_callee` cannot pin down (a method, a reassigned or redeclared
binding, an unresolved name) contributes nothing.
"""
called_funcs: list[Node] = []
seen: set[int] = set()
for node in walk_scope(scope, include_root_body=True):
if isinstance(node, JsCallExpression):
target = effects.static_callee(node)
if target is not None and id(target) not in seen:
seen.add(id(target))
called_funcs.append(target)
return called_funcs
def _reader_qualifies(
value: Node, reader: Node, binding: Binding,
effects: EffectModel, dominance: DominanceModel,
) -> bool:
"""
Whether a candidate that is not `const`-qualified — a `var` carrying its own initializer,
whose value a call could rewrite — may be inlined into the reader function *reader* holding
*value*, the question both arms of the cross-function substitution ask, stated once so the
computed-member and plain-identifier arms cannot disagree. The reader must not write the
binding, and the value must run before every one of its invocations, of which it must have at
least one (`runs_before_every_invocation`): a reader nothing invokes answers the ordering
vacuously and is refused, while an anonymous IIFE or arrow is always orderable, its single
reference point being the function expression itself — the closure cannot be invoked before it
is created. A `const`-qualified candidate, or an intrinsic alias, needs neither: its value
cannot be replaced short of a dynamic rebind, which the candidate collection has already
refused.
"""
if effects.function_can_mutate(reader, binding):
return False
return dominance.runs_before_every_invocation(value, reader)
def _planned_keys(substitutions: list[_Substitution]) -> set[str]:
return {sub.key for sub in substitutions}
class JsConstantInlining(BatchedScopeTransformer[_ScopePlan]):
"""
Inline variables that are assigned once and never mutated. Literal-valued variables are inlined
at all use sites; single-use variables with side-effect-free initializers are inlined when no
intervening mutation could alter the referenced identifiers. All-literal arrays declared with
`const` are inlined element-by-element when accessed with numeric literal indices.
One invocation decides in rounds: each round traverses the tree once, every scope decides one
round of edits against the model snapshot the round opened with, and the round's plans apply
once the traversal ends, inner scopes first. A round that lands no edit ends the invocation.
Every edit the batch applies removes something — a read, an index access, a declarator, an
assignment statement — and installs no binding, so the emission registry has no reader here:
the one bare name a substitution can write is an intrinsic alias, checked unshadowed at the
site it goes.
Non-interference, per fact the decisions read:
- `ReachingModel.value_preserved` — whether a definition's value reaches a read unchanged.
Batch edits remove reads, write sites, and declarators; none adds a kill, so a value that
reached a read unchanged still does. A declined read — one a kill stood between — may become
inlinable once the edit removing the kill applies, which the next round decides; the refusal
the batch acted on stays valid.
- `DominanceModel.runs_before_function` and `runs_before_every_invocation` — whether a value
runs before a function, or before every invocation of one. Batch edits remove statements and
reads, so the set of invocation points can only shrink; a value that preceded them all still
does.
- The effect summaries — `EffectModel.mutated_bindings`, `EffectModel.function_escapes`,
`EffectModel.static_callee`, `EffectModel.function_can_mutate` — over functions the batch
never rewrites. Removing a call site removes a mutation opportunity rather than adding one,
so a function that could not write a binding still cannot.
- The reference counts that gate removals: no removal decision reads one — a count taken
before the round's plans apply cannot see the reads another plan of the same round deletes,
and a substitution whose value is an identifier adds a read the entry snapshot never saw —
so the plan counts at apply time, on the tree its substitutions leave behind, and
`is_attached` refuses the substitutions an earlier plan detached.
"""
def __init__(self, max_inline_length: int = 64):
super().__init__()
self.max_inline_length = max_inline_length
self._root: JsScript | None = None
self._edits_applied = 0
def visit_JsScript(self, node: JsScript):
self._root = node
while True:
self._edits_applied = 0
super().visit_JsScript(node)
if not self._edits_applied:
break
return None
def _process_scope(self, scope: Node) -> None:
assert self._root is not None
cache = model_cache(self, self._root)
candidates, mutated = self._collect_candidates(scope, cache.effects)
member_arrays = self._collect_member_array_candidates(scope)
if not candidates and not member_arrays:
return
substitutions = self._decide_constants(scope, candidates, cache)
if member_arrays:
substitutions.extend(self._decide_member_arrays(scope, member_arrays))
if not substitutions:
substitutions = self._decide_expressions(scope, candidates, mutated, cache)
if not substitutions:
return
planned = _planned_keys(substitutions)
decl_ids = _candidate_decl_ids(candidates)
self._submit(_ScopePlan(
scope,
substitutions,
self._decide_dead_declarators(candidates, planned, cache),
self._decide_dead_member_arrays(member_arrays, planned) if member_arrays else [],
decl_ids,
planned,
))
def _collect_candidates(
self,
scope: Node,
effects: EffectModel,
) -> tuple[dict[str, list[_CandidateEntry]], set[str]]:
"""
Collect constant declaration entries per variable. Each entry is a `_CandidateEntry` of
(declarator, constant_value)
Also returns the set of fully rejected (mutated) names — those reassigned, updated,
destructured, or written by an escaping function, whose value cannot be pinned to a single
definition. A candidate a dynamic scope could rewrite with no static write site is no longer
rejected outright: the reaching query orders every located rebind hazard
(`SemanticModel.binding_reflection_kill_sites`) against each read, so the reads that stand
before the hazard fold and the ones after it stay. The one rebind that keeps its rejection
is the nodeless one — the write a call makes on entry, which the text does not spell and
no ordering can place (`binding_dynamic_rebind_sites` answering `None`) — and a candidate
the analyst declared a host reaches by name is rejected the same way: the host reads it once
the file has run, and may have rewritten it before any read the substitution would fold, so
neither its declarator nor any read of it may be replaced. A write through a global-object
alias is rejected alongside. The points past which a surviving candidate's value no longer
holds are not enumerated here; the reaching query derives them from the effect model at
each use.
"""
candidates: dict[str, list[_CandidateEntry]] = {}
rejected: set[str] = set()
uninitialized: dict[str, JsVariableDeclarator] = {}
for node in walk_scope(scope, include_root_body=True):
if isinstance(node, JsVariableDeclaration):
for decl in node.declarations:
if not isinstance(decl, JsVariableDeclarator):
continue
if not isinstance(decl.id, JsIdentifier):
for ident in pattern_identifiers(decl.id):
rejected.add(ident.name)
candidates.pop(ident.name, None)
uninitialized.pop(ident.name, None)
continue
name = decl.id.name
if name in rejected:
continue
if decl.init is None:
if name not in candidates:
uninitialized[name] = decl
continue
if name in candidates:
rejected.add(name)
candidates.pop(name, None)
uninitialized.pop(name, None)
continue
candidates[name] = [_CandidateEntry(decl, decl.init)]
uninitialized.pop(name, None)
if isinstance(node, JsAssignmentExpression):
left = strip_parens(node.left)
if isinstance(left, JsIdentifier):
name = left.name
if (
node.operator == '='
and name in uninitialized
and name not in candidates
and name not in rejected
):
decl = uninitialized.pop(name)
rhs = node.right
if rhs is None:
rejected.add(name)
else:
candidates[name] = [_CandidateEntry(decl, rhs)]
else:
rejected.add(name)
candidates.pop(name, None)
uninitialized.pop(name, None)
elif isinstance(left, (JsArrayPattern, JsObjectPattern)):
for name in _pattern_identifiers(left):
rejected.add(name)
candidates.pop(name, None)
uninitialized.pop(name, None)
if isinstance(node, JsUpdateExpression):
target = strip_parens(node.argument)
if isinstance(target, JsIdentifier):
name = target.name
rejected.add(name)
candidates.pop(name, None)
if isinstance(node, (JsForInStatement, JsForOfStatement)):
left = strip_parens(node.left)
loop_targets: set[str] = set()
if isinstance(left, JsVariableDeclaration):
for decl in left.declarations:
if isinstance(decl, JsVariableDeclarator) and decl.id is not None:
loop_targets |= _pattern_identifiers(decl.id)
elif isinstance(left, JsIdentifier):
loop_targets.add(left.name)
elif isinstance(left, (
JsArrayExpression, JsObjectExpression, JsArrayPattern, JsObjectPattern,
)):
loop_targets |= _pattern_identifiers(left)
for name in loop_targets:
rejected.add(name)
candidates.pop(name, None)
uninitialized.pop(name, None)
model = effects.model
candidate_bindings: dict[str, Binding] = {}
for cand_name, cand_entries in candidates.items():
decl = cand_entries[0].declarator
if decl is not None and isinstance(decl.id, JsIdentifier):
binding = model.binding_of(decl.id)
if binding is not None:
candidate_bindings[cand_name] = binding
def _reject(target_name: str) -> None:
rejected.add(target_name)
candidates.pop(target_name, None)
uninitialized.pop(target_name, None)
candidate_bindings.pop(target_name, None)
functions = [node for node in scope.walk() if isinstance(node, FUNCTION_NODES)]
for cand_name, binding in list(candidate_bindings.items()):
if (
binding.has_global_member_write
or model.binding_dynamic_rebind_sites(binding) is None
or a_host_reaches_the_binding(model, binding, self.options)
):
_reject(cand_name)
for func in functions:
written = effects.mutated_bindings(func)
if not written:
continue
touched = [n for n, binding in candidate_bindings.items() if binding in written]
if not touched:
continue
if effects.function_escapes(func):
for cand_name in touched:
_reject(cand_name)
return candidates, rejected
@staticmethod
def _candidate_binding(entry: _CandidateEntry, model: SemanticModel) -> Binding | None:
"""
The binding a candidate entry defines, resolved through its declarator identifier, or `None` when
the entry carries no single-identifier declarator. This is the binding whose value the reaching
query tracks from the definition to a use.
"""
decl = entry.declarator
if decl is None or not isinstance(decl.id, JsIdentifier):
return None
return model.binding_of(decl.id)
def _decide_constants(
self,
scope: Node,
candidates: dict[str, list[_CandidateEntry]],
cache: ModelCache,
) -> list[_Substitution]:
"""
Decide the constant (literal and literal-array) inlines of one round. A reference is inlined
only where the definition's value provably reaches it unchanged (`ReachingModel.value_preserved`),
covering both scalar references and computed index access into all-literal arrays. The value
each decision records is the entry snapshot's own node; the plan clones it where it applies.
"""
bloat_blocked: set[str] = set()
decl_ids = _candidate_decl_ids(candidates)
ref_counts = _count_scope_references(
scope, set(candidates), decl_ids, count_member_access=True,
)
for name, entries in candidates.items():
if len(entries) != 1:
continue
value = entries[0].value
count = ref_counts.get(name, 0)
if count <= 1:
continue
if isinstance(value, JsStringLiteral) and value.value is not None:
if len(value.value) > self.max_inline_length:
bloat_blocked.add(name)
effects = cache.effects
reaching = cache.reaching
model = effects.model
constant_names = {
name for name, entries in candidates.items()
if any(_is_constant_value(e.value) or _is_intrinsic_alias_value(effects, e.value) for e in entries)
}
if not constant_names:
return []
substitutions: list[_Substitution] = []
for node in walk_scope(scope, include_root_body=True):
if isinstance(node, JsMemberExpression) and node.computed:
obj = node.object
if (
isinstance(obj, JsIdentifier)
and id(obj) not in decl_ids
and obj.name in constant_names
and obj.name not in bloat_blocked
and self._index_array_immutable(obj, effects, direct_eval_ordered=True)
):
entry = candidates[obj.name][0]
binding = self._candidate_binding(entry, model)
if binding is not None and reaching.value_preserved(binding, entry.value, node):
element = self._index_access_element(node, entry)
if element is not None:
substitutions.append(_Substitution(node, element, obj.name))
continue
if not isinstance(node, JsIdentifier):
continue
if id(node) in decl_ids:
continue
name = node.name
if name not in constant_names or name in bloat_blocked:
continue
parent = node.parent
if reference_role(node) is not Role.READ:
continue
if isinstance(parent, JsMemberExpression) and parent.object is node and parent.computed:
continue
entry = candidates[name][0]
if not is_literal(entry.value):
continue
binding = self._candidate_binding(entry, model)
if binding is None or model.resolve(node) is not binding:
continue
if not reaching.value_preserved(binding, entry.value, node):
continue
substitutions.append(_Substitution(node, entry.value, name))
substitutions.extend(self._decide_const_across_functions(
scope, candidates, decl_ids, bloat_blocked, cache,
))
return substitutions
@staticmethod
def _index_array_immutable(
obj: JsIdentifier, effects: EffectModel, direct_eval_ordered: bool = False,
) -> bool:
"""
Whether the array binding referenced by *obj* is an immutable, non-escaping container, so that
`obj[idx]` may be inlined to its literal element. Resolved through the binding (not the textual
name), so shadowing is respected; the model memoizes the judgment for the model's lifetime. A
name that does not resolve to a local binding (a free or global array) is treated as unsafe.
*direct_eval_ordered* carries the caller's promise that it has ordered the direct-`eval`
sites against the read it is folding — the `value_preserved` check the same arm
makes — so a local array in a function holding a direct `eval` stays inlinable where the
read is ordered before every eval site.
"""
binding = effects.model.resolve(obj)
if binding is None:
return False
return effects.binding_is_immutable_container(
binding, direct_eval_ordered=direct_eval_ordered)
@staticmethod
def _index_access_element(member: JsMemberExpression, entry: _CandidateEntry) -> Node | None:
"""
The literal element an index access into a candidate's all-literal array resolves to, or
`None` when the access names no in-bounds literal element.
"""
prop = member.property
if not isinstance(prop, JsNumericLiteral):
return None
idx = exact_integer(prop.value)
if idx is None:
return None
value = entry.value
if not isinstance(value, JsArrayExpression):
return None
if not (0 <= idx < len(value.elements)):
return None
element = value.elements[idx]
if element is None or not is_literal(element):
return None
return element
def _decide_const_across_functions(
self,
scope: Node,
candidates: dict[str, list[_CandidateEntry]],
decl_ids: set[int],
bloat_blocked: set[str],
cache: ModelCache,
) -> list[_Substitution]:
"""
Decide the constant-valued inlines of one round that cross into nested function bodies. A
`const`-qualified candidate, or an uninitialized `var` later assigned a single constant, is
inlined into a function only when the value provably runs before every invocation of that
function (`DominanceModel.runs_before_function`) — otherwise a call could read the value
early: a stale read for the `var` form, a temporal-dead-zone throw for the `const` form. A
`var`/`let` candidate that carries its own initializer is additionally restricted by
`_reader_qualifies` to a reader that neither writes the binding nor runs an invocation the
value does not precede — a named function invoked in scope, an anonymous IIFE, an arrow, or
a function stored in a binding, each of whose invocation points is enumerable and ordered;
an uncalled named function is refused, since its ordering answer is the vacuous one an
empty set of reference points gives. The interprocedural runs-before check subsumes the
earlier escape and statement-position heuristics: a function cannot be invoked before a
reference to it has been evaluated, so it orders the value against every point the function
is referenced — recursing up the call graph for a reference that lies inside another
function — and inlines only when the value dominates all of them, refusing whenever a
reference cannot be ordered (its binding is reassigned or redeclared, or it lies on a call
cycle).
A candidate a dynamic scope could rewrite is refused here
(`binding_maybe_reassigned_dynamically`): this consumer's ordering runs the value before an
*invocation*, and an invocation cannot be ordered against the site that rewrites the value
— a name read once can be stored and invoked arbitrarily later, past the hazard — so the
reads that fold on the located hazards are the same-function ones, which the reaching
query answers at the use itself.
"""
effects = cache.effects
dominance = cache.dominance
model = effects.model
cross_candidates: dict[str, list[_CandidateEntry]] = {}
cross_bindings: dict[str, Binding] = {}
const_names: set[str] = set()
for name, entries in candidates.items():
if name in bloat_blocked:
continue
if len(entries) != 1:
continue
entry = entries[0]
if entry.declarator is None or not isinstance(entry.declarator.id, JsIdentifier):
continue
intrinsic_alias = _is_intrinsic_alias_value(effects, entry.value)
if not (_is_constant_value(entry.value) or intrinsic_alias):
continue
binding = model.binding_of(entry.declarator.id)
if binding is None:
continue
cross_candidates[name] = entries
cross_bindings[name] = binding
if _is_const_qualified(entry.declarator) or entry.declarator.init is None or intrinsic_alias:
const_names.add(name)
if not cross_candidates:
return []
outer = model.scope_of(scope)
assert outer is not None
owner = enclosing_function(scope)
called_funcs = _collect_called_functions(scope, effects)
a_callee_is_unstated = _calls_a_function_of_this_file_by_an_unstated_name(scope, effects)
for name in [
candidate for candidate, binding in cross_bindings.items()
if model.binding_maybe_reassigned_dynamically(binding)
or (a_callee_is_unstated and effects.some_function_can_mutate(binding))
or any(effects.function_can_mutate(func, binding) for func in called_funcs)
]:
del cross_candidates[name]
del cross_bindings[name]
if not cross_candidates:
return []
substitutions: list[_Substitution] = []
for node in scope.walk():
if isinstance(node, JsMemberExpression) and node.computed:
obj = node.object
if (
isinstance(obj, JsIdentifier)
and id(obj) not in decl_ids
and obj.name in cross_candidates
and self._index_array_immutable(obj, effects)
):
name = obj.name
enclosing = enclosing_function(obj)
if enclosing is None or enclosing is owner:
continue
if model.resolve(obj) is not cross_bindings[name]:
continue
if name in const_names:
if not dominance.runs_before_function(
cross_candidates[name][0].value, enclosing,
):
continue
elif not _reader_qualifies(
cross_candidates[name][0].value, enclosing,
cross_bindings[name], effects, dominance,
):
continue
if model.is_shadowed(name, obj, outer):
continue
element = self._index_access_element(node, cross_candidates[name][0])
if element is not None:
substitutions.append(_Substitution(node, element, name))
continue
if not isinstance(node, JsIdentifier):
continue
if id(node) in decl_ids:
continue
name = node.name
if name not in cross_candidates:
continue
parent = node.parent
if reference_role(node) is not Role.READ:
continue
if isinstance(parent, JsMemberExpression) and parent.object is node and parent.computed:
continue
if isinstance(parent, JsVariableDeclarator) and parent.id is node:
continue
if isinstance(parent, (JsFunctionDeclaration, JsFunctionExpression)) and parent.id is node:
continue
entry = cross_candidates[name][0]
intrinsic_alias = _is_intrinsic_alias_value(effects, entry.value)
if not (is_literal(entry.value) or intrinsic_alias):
continue
enclosing = enclosing_function(node)
if enclosing is None or enclosing is owner:
continue
if model.resolve(node) is not cross_bindings[name]:
continue
if name in const_names:
if not dominance.runs_before_function(entry.value, enclosing):
continue
elif not _reader_qualifies(
entry.value, enclosing, cross_bindings[name], effects, dominance,
):
continue
if model.is_shadowed(name, node, outer):
continue
if (
intrinsic_alias
and isinstance(entry.value, JsIdentifier)
and model.lookup(entry.value.name, model.scope_of(node)) is not None
):
continue
substitutions.append(_Substitution(node, entry.value, name))
return substitutions
def _decide_expressions(
self,
scope: Node,
candidates: dict[str, list[_CandidateEntry]],
mutated: set[str],
cache: ModelCache,
) -> list[_Substitution]:
"""
Decide the single-use, side-effect-free, non-literal expression inlines of one round.
Relocating the initializer to its use is sound only when the value it computed still holds
there — which means the candidate's own binding reaches the use unchanged *and* every
variable the initializer reads holds, at the use, the value it held at the definition.
`ReachingModel.value_preserved` decides each: the candidate binding for ordering and its
own kills, then one query per resolved free variable. The `_is_primitive_and_pure` gate
keeps the initializer free of side effects and reference identity, and the `mutated` gate
rejects a free variable written through a name no binding resolves (a global reassigned in
scope), which the binding-keyed reaching query cannot see. A candidate whose initializer
reads a bare name through a `with` body's dynamic scope needs no separate gate here: such
an initializer is inside the `with` body, so the candidate's own binding is written in a
dynamic scope and the reaching query orders that hazard against the relocated read.
"""
decl_ids = _candidate_decl_ids(candidates)
ref_counts = _count_scope_references(scope, set(candidates), decl_ids)
to_inline: dict[str, _CandidateEntry] = {}
for name, entries in candidates.items():
if len(entries) != 1:
continue
entry = entries[0]
init = entry.value
count = ref_counts.get(name, 0)
if is_literal(init) or _is_literal_array(init) or count != 1:
continue
if not _is_primitive_and_pure(init):
continue
if collect_identifier_names(init) & mutated:
continue
to_inline[name] = entry
if not to_inline:
return []
reaching = cache.reaching
model = cache.effects.model
substitutions: list[_Substitution] = []
for node in walk_scope(scope, include_root_body=True):
if not isinstance(node, JsIdentifier):
continue
if id(node) in decl_ids:
continue
name = node.name
if name not in to_inline:
continue
if reference_role(node) is not Role.READ:
continue
entry = to_inline[name]
binding = self._candidate_binding(entry, model)
if binding is None or not reaching.value_preserved(binding, entry.value, node):
continue
if not self._free_variables_preserved(entry.value, node, model, reaching):
continue
substitutions.append(_Substitution(node, entry.value, name))
return substitutions
@staticmethod
def _free_variables_preserved(
value: Node, use: Node, model: SemanticModel, reaching: ReachingModel,
) -> bool:
"""
Whether re-evaluating *value* at *use* yields the same result it had where *value* was
defined. Each name *value* reads must resolve, from *use*'s scope, to the binding it read
where it was defined — a name a block or function around *use* shadows would read a
different binding there, so a global read at the definition becomes a local read once
substituted — and, where that binding exists, must still hold the value it held
(`ReachingModel.value_preserved`). A free name that stays free preserves its value by the
caller's `mutated` gate, so only its resolution is checked here. This is the twin of
`JsCallWrapperInliner._forwarded_callee_reaches`, which guards the same capture for a
forwarded callee.
"""
use_scope = model.scope_of(use)
for ident in value.walk():
if not isinstance(ident, JsIdentifier) or not model.is_reference(ident):
continue
binding = model.resolve(ident)
if model.lookup(ident.name, use_scope) is not binding:
return False
if binding is not None and not reaching.value_preserved(binding, value, use):
return False
return True
def _decide_dead_declarators(
self,
candidates: dict[str, list[_CandidateEntry]],
planned: set[str],
cache: ModelCache,
) -> list[_DeclaratorRemoval]:
"""
Decide which declarators of the round-inlined names are up for removal. No reference count
is read here — one taken before the round's plans apply cannot see the reads another plan
of the same round deletes — so every name the round substitutes is proposed and the plan
counts references at apply time. An exported binding keeps its declarator even with every
local read inlined, because an importer still reads it across the module boundary;
removing it would leave an `export` naming a binding the module no longer declares. A
binding that code the model cannot read could name keeps its declarator for the same
reason: every read this file spells may have folded, and the surface — a direct `eval`,
a span of source the model never read, a `with` body — reads the binding through no
reference the inlining counted, so removing the declaration would turn its value into
a `ReferenceError`. An opaque global write is not such a surface: it stores a property
and runs nothing, and the reads it could replace were ordered against it before they
folded, so the property it may write is the same residual the fold already concedes.
"""
model = cache.model
removals: list[_DeclaratorRemoval] = []
for name in planned:
entries = candidates.get(name)
if entries is None:
continue
for entry in entries:
if entry.declarator is None:
continue
binding = self._candidate_binding(entry, model)
if binding is not None and (
binding.exported
or model.reachable_by_opaque_reflection(binding)
or bool(binding.dynamic_refs)
):
continue
removals.append(_DeclaratorRemoval(entry.declarator, name))
return removals
@staticmethod
def _collect_member_array_candidates(scope: Node) -> dict[str, _MemberArrayEntry]:
"""
Collect member-expression assignments of all-literal arrays: `X.Y = [literals...]`.
Returns a dict keyed by `"X.Y"` to `_MemberArrayEntry`. Only single-assignment,
non-aliased properties qualify.
"""
candidates: dict[str, _MemberArrayEntry] = {}
rejected: set[str] = set()
prefix_rejected: set[str] = set()
for node in walk_scope(scope, include_root_body=True):
if not isinstance(node, JsAssignmentExpression) or node.operator != '=':
continue
lhs = node.left
if not isinstance(lhs, JsMemberExpression) or lhs.computed:
continue
if not isinstance(lhs.object, JsIdentifier) or not isinstance(lhs.property, JsIdentifier):
continue
key = F'{lhs.object.name}.{lhs.property.name}'
if key in rejected:
continue
rhs = node.right
if not isinstance(rhs, JsArrayExpression) or not _is_literal_array(rhs):
rejected.add(key)
candidates.pop(key, None)
continue
if key in candidates:
rejected.add(key)
candidates.pop(key)
continue
candidates[key] = _MemberArrayEntry(node, rhs)
if not candidates:
return candidates
prefix_names = {k.split('.', 1)[0] for k in candidates}
for node in walk_scope(scope, include_root_body=True):
if isinstance(node, JsAssignmentExpression) and node.operator == '=':
if isinstance(node.left, JsIdentifier) and node.left.name in prefix_names:
prefix_rejected.add(node.left.name)
if isinstance(node, JsUpdateExpression) and isinstance(node.argument, JsIdentifier):
if node.argument.name in prefix_names:
prefix_rejected.add(node.argument.name)
if prefix_rejected:
candidates = {
k: v for k, v in candidates.items()
if k.split('.', 1)[0] not in prefix_rejected
}
for key in list(candidates):
prefix, prop = key.split('.', 1)
if not _is_member_array_safe(scope, prefix, prop):
del candidates[key]
return candidates
def _decide_member_arrays(
self,
scope: Node,
member_arrays: dict[str, _MemberArrayEntry],
) -> list[_Substitution]:
"""
Decide the `X.Y[N]` → element inlines of one round for all collected member-array
candidates. Walks the full subtree (including nested function bodies) since these arrays
are scope-level constants.
"""
substitutions: list[_Substitution] = []
for node in scope.walk():
if not isinstance(node, JsMemberExpression) or not node.computed:
continue
prop = node.property
if not isinstance(prop, JsNumericLiteral):
continue
obj = node.object
if not isinstance(obj, JsMemberExpression) or obj.computed:
continue
if not isinstance(obj.object, JsIdentifier) or not isinstance(obj.property, JsIdentifier):
continue
key = F'{obj.object.name}.{obj.property.name}'
entry = member_arrays.get(key)
if entry is None:
continue
idx = exact_integer(prop.value)
if idx is None or not (0 <= idx < len(entry.array.elements)):
continue
element = entry.array.elements[idx]
if element is None or not is_literal(element):
continue
substitutions.append(_Substitution(node, element, key))
return substitutions
def _decide_dead_member_arrays(
self,
member_arrays: dict[str, _MemberArrayEntry],
planned: set[str],
) -> list[_MemberArrayRemoval]:
return [
_MemberArrayRemoval(entry.assignment, key)
for key, entry in member_arrays.items()
if key in planned
]
@staticmethod
def _count_member_array_accesses(scope: Node, keys: set[str]) -> dict[str, int]:
"""
The computed `X.Y[...]` accesses standing in *scope*'s subtree per key in *keys*, counted
over the tree as it stands when asked — the plan asks once its substitutions have landed.
"""
remaining: dict[str, int] = {}
for node in scope.walk():
if not isinstance(node, JsMemberExpression) or not node.computed:
continue
obj = node.object
if not isinstance(obj, JsMemberExpression) or obj.computed:
continue
if not isinstance(obj.object, JsIdentifier) or not isinstance(obj.property, JsIdentifier):
continue
key = F'{obj.object.name}.{obj.property.name}'
if key in keys:
remaining[key] = remaining.get(key, 0) + 1
return remaining
def _apply_plan(self, plan: _ScopePlan) -> None:
for sub in plan.substitutions:
if not is_attached(sub.target):
continue
clone = _clone_node(sub.value)
if isinstance(sub.target, JsIdentifier):
substituted = substitute_use_position(sub.target, clone)
else:
substituted = _replace_in_parent(sub.target, clone)
if not substituted:
continue
self.mark_changed()
self._edits_applied += 1
if plan.declarator_removals:
ref_counts = _count_scope_references(
plan.scope, plan.planned, plan.decl_ids, walk_full=True,
)
for removal in plan.declarator_removals:
if ref_counts.get(removal.key, 0) > 0:
continue
if not is_attached(removal.declarator):
continue
remove_declarator(removal.declarator)
self.mark_changed()
self._edits_applied += 1
if plan.member_array_removals:
keys = {removal.key for removal in plan.member_array_removals}
remaining = self._count_member_array_accesses(plan.scope, keys)
for removal in plan.member_array_removals:
if remaining.get(removal.key, 0) > 0:
continue
if not is_attached(removal.assignment):
continue
stmt = removal.assignment.parent
if isinstance(stmt, JsExpressionStatement) and _remove_from_parent(stmt):
self.mark_changed()
self._edits_applied += 1
Classes
class JsConstantInlining (max_inline_length=64)-
Inline variables that are assigned once and never mutated. Literal-valued variables are inlined at all use sites; single-use variables with side-effect-free initializers are inlined when no intervening mutation could alter the referenced identifiers. All-literal arrays declared with
constare inlined element-by-element when accessed with numeric literal indices.One invocation decides in rounds: each round traverses the tree once, every scope decides one round of edits against the model snapshot the round opened with, and the round's plans apply once the traversal ends, inner scopes first. A round that lands no edit ends the invocation.
Every edit the batch applies removes something — a read, an index access, a declarator, an assignment statement — and installs no binding, so the emission registry has no reader here: the one bare name a substitution can write is an intrinsic alias, checked unshadowed at the site it goes.
Non-interference, per fact the decisions read:
ReachingModel.value_preserved— whether a definition's value reaches a read unchanged. Batch edits remove reads, write sites, and declarators; none adds a kill, so a value that reached a read unchanged still does. A declined read — one a kill stood between — may become inlinable once the edit removing the kill applies, which the next round decides; the refusal the batch acted on stays valid.DominanceModel.runs_before_functionandruns_before_every_invocation— whether a value runs before a function, or before every invocation of one. Batch edits remove statements and reads, so the set of invocation points can only shrink; a value that preceded them all still does.- The effect summaries —
EffectModel.mutated_bindings,EffectModel.function_escapes,EffectModel.static_callee,EffectModel.function_can_mutate— over functions the batch never rewrites. Removing a call site removes a mutation opportunity rather than adding one, so a function that could not write a binding still cannot. - The reference counts that gate removals: no removal decision reads one — a count taken
before the round's plans apply cannot see the reads another plan of the same round deletes,
and a substitution whose value is an identifier adds a read the entry snapshot never saw —
so the plan counts at apply time, on the tree its substitutions leave behind, and
is_attachedrefuses the substitutions an earlier plan detached.
Expand source code Browse git
class JsConstantInlining(BatchedScopeTransformer[_ScopePlan]): """ Inline variables that are assigned once and never mutated. Literal-valued variables are inlined at all use sites; single-use variables with side-effect-free initializers are inlined when no intervening mutation could alter the referenced identifiers. All-literal arrays declared with `const` are inlined element-by-element when accessed with numeric literal indices. One invocation decides in rounds: each round traverses the tree once, every scope decides one round of edits against the model snapshot the round opened with, and the round's plans apply once the traversal ends, inner scopes first. A round that lands no edit ends the invocation. Every edit the batch applies removes something — a read, an index access, a declarator, an assignment statement — and installs no binding, so the emission registry has no reader here: the one bare name a substitution can write is an intrinsic alias, checked unshadowed at the site it goes. Non-interference, per fact the decisions read: - `ReachingModel.value_preserved` — whether a definition's value reaches a read unchanged. Batch edits remove reads, write sites, and declarators; none adds a kill, so a value that reached a read unchanged still does. A declined read — one a kill stood between — may become inlinable once the edit removing the kill applies, which the next round decides; the refusal the batch acted on stays valid. - `DominanceModel.runs_before_function` and `runs_before_every_invocation` — whether a value runs before a function, or before every invocation of one. Batch edits remove statements and reads, so the set of invocation points can only shrink; a value that preceded them all still does. - The effect summaries — `EffectModel.mutated_bindings`, `EffectModel.function_escapes`, `EffectModel.static_callee`, `EffectModel.function_can_mutate` — over functions the batch never rewrites. Removing a call site removes a mutation opportunity rather than adding one, so a function that could not write a binding still cannot. - The reference counts that gate removals: no removal decision reads one — a count taken before the round's plans apply cannot see the reads another plan of the same round deletes, and a substitution whose value is an identifier adds a read the entry snapshot never saw — so the plan counts at apply time, on the tree its substitutions leave behind, and `is_attached` refuses the substitutions an earlier plan detached. """ def __init__(self, max_inline_length: int = 64): super().__init__() self.max_inline_length = max_inline_length self._root: JsScript | None = None self._edits_applied = 0 def visit_JsScript(self, node: JsScript): self._root = node while True: self._edits_applied = 0 super().visit_JsScript(node) if not self._edits_applied: break return None def _process_scope(self, scope: Node) -> None: assert self._root is not None cache = model_cache(self, self._root) candidates, mutated = self._collect_candidates(scope, cache.effects) member_arrays = self._collect_member_array_candidates(scope) if not candidates and not member_arrays: return substitutions = self._decide_constants(scope, candidates, cache) if member_arrays: substitutions.extend(self._decide_member_arrays(scope, member_arrays)) if not substitutions: substitutions = self._decide_expressions(scope, candidates, mutated, cache) if not substitutions: return planned = _planned_keys(substitutions) decl_ids = _candidate_decl_ids(candidates) self._submit(_ScopePlan( scope, substitutions, self._decide_dead_declarators(candidates, planned, cache), self._decide_dead_member_arrays(member_arrays, planned) if member_arrays else [], decl_ids, planned, )) def _collect_candidates( self, scope: Node, effects: EffectModel, ) -> tuple[dict[str, list[_CandidateEntry]], set[str]]: """ Collect constant declaration entries per variable. Each entry is a `_CandidateEntry` of (declarator, constant_value) Also returns the set of fully rejected (mutated) names — those reassigned, updated, destructured, or written by an escaping function, whose value cannot be pinned to a single definition. A candidate a dynamic scope could rewrite with no static write site is no longer rejected outright: the reaching query orders every located rebind hazard (`SemanticModel.binding_reflection_kill_sites`) against each read, so the reads that stand before the hazard fold and the ones after it stay. The one rebind that keeps its rejection is the nodeless one — the write a call makes on entry, which the text does not spell and no ordering can place (`binding_dynamic_rebind_sites` answering `None`) — and a candidate the analyst declared a host reaches by name is rejected the same way: the host reads it once the file has run, and may have rewritten it before any read the substitution would fold, so neither its declarator nor any read of it may be replaced. A write through a global-object alias is rejected alongside. The points past which a surviving candidate's value no longer holds are not enumerated here; the reaching query derives them from the effect model at each use. """ candidates: dict[str, list[_CandidateEntry]] = {} rejected: set[str] = set() uninitialized: dict[str, JsVariableDeclarator] = {} for node in walk_scope(scope, include_root_body=True): if isinstance(node, JsVariableDeclaration): for decl in node.declarations: if not isinstance(decl, JsVariableDeclarator): continue if not isinstance(decl.id, JsIdentifier): for ident in pattern_identifiers(decl.id): rejected.add(ident.name) candidates.pop(ident.name, None) uninitialized.pop(ident.name, None) continue name = decl.id.name if name in rejected: continue if decl.init is None: if name not in candidates: uninitialized[name] = decl continue if name in candidates: rejected.add(name) candidates.pop(name, None) uninitialized.pop(name, None) continue candidates[name] = [_CandidateEntry(decl, decl.init)] uninitialized.pop(name, None) if isinstance(node, JsAssignmentExpression): left = strip_parens(node.left) if isinstance(left, JsIdentifier): name = left.name if ( node.operator == '=' and name in uninitialized and name not in candidates and name not in rejected ): decl = uninitialized.pop(name) rhs = node.right if rhs is None: rejected.add(name) else: candidates[name] = [_CandidateEntry(decl, rhs)] else: rejected.add(name) candidates.pop(name, None) uninitialized.pop(name, None) elif isinstance(left, (JsArrayPattern, JsObjectPattern)): for name in _pattern_identifiers(left): rejected.add(name) candidates.pop(name, None) uninitialized.pop(name, None) if isinstance(node, JsUpdateExpression): target = strip_parens(node.argument) if isinstance(target, JsIdentifier): name = target.name rejected.add(name) candidates.pop(name, None) if isinstance(node, (JsForInStatement, JsForOfStatement)): left = strip_parens(node.left) loop_targets: set[str] = set() if isinstance(left, JsVariableDeclaration): for decl in left.declarations: if isinstance(decl, JsVariableDeclarator) and decl.id is not None: loop_targets |= _pattern_identifiers(decl.id) elif isinstance(left, JsIdentifier): loop_targets.add(left.name) elif isinstance(left, ( JsArrayExpression, JsObjectExpression, JsArrayPattern, JsObjectPattern, )): loop_targets |= _pattern_identifiers(left) for name in loop_targets: rejected.add(name) candidates.pop(name, None) uninitialized.pop(name, None) model = effects.model candidate_bindings: dict[str, Binding] = {} for cand_name, cand_entries in candidates.items(): decl = cand_entries[0].declarator if decl is not None and isinstance(decl.id, JsIdentifier): binding = model.binding_of(decl.id) if binding is not None: candidate_bindings[cand_name] = binding def _reject(target_name: str) -> None: rejected.add(target_name) candidates.pop(target_name, None) uninitialized.pop(target_name, None) candidate_bindings.pop(target_name, None) functions = [node for node in scope.walk() if isinstance(node, FUNCTION_NODES)] for cand_name, binding in list(candidate_bindings.items()): if ( binding.has_global_member_write or model.binding_dynamic_rebind_sites(binding) is None or a_host_reaches_the_binding(model, binding, self.options) ): _reject(cand_name) for func in functions: written = effects.mutated_bindings(func) if not written: continue touched = [n for n, binding in candidate_bindings.items() if binding in written] if not touched: continue if effects.function_escapes(func): for cand_name in touched: _reject(cand_name) return candidates, rejected @staticmethod def _candidate_binding(entry: _CandidateEntry, model: SemanticModel) -> Binding | None: """ The binding a candidate entry defines, resolved through its declarator identifier, or `None` when the entry carries no single-identifier declarator. This is the binding whose value the reaching query tracks from the definition to a use. """ decl = entry.declarator if decl is None or not isinstance(decl.id, JsIdentifier): return None return model.binding_of(decl.id) def _decide_constants( self, scope: Node, candidates: dict[str, list[_CandidateEntry]], cache: ModelCache, ) -> list[_Substitution]: """ Decide the constant (literal and literal-array) inlines of one round. A reference is inlined only where the definition's value provably reaches it unchanged (`ReachingModel.value_preserved`), covering both scalar references and computed index access into all-literal arrays. The value each decision records is the entry snapshot's own node; the plan clones it where it applies. """ bloat_blocked: set[str] = set() decl_ids = _candidate_decl_ids(candidates) ref_counts = _count_scope_references( scope, set(candidates), decl_ids, count_member_access=True, ) for name, entries in candidates.items(): if len(entries) != 1: continue value = entries[0].value count = ref_counts.get(name, 0) if count <= 1: continue if isinstance(value, JsStringLiteral) and value.value is not None: if len(value.value) > self.max_inline_length: bloat_blocked.add(name) effects = cache.effects reaching = cache.reaching model = effects.model constant_names = { name for name, entries in candidates.items() if any(_is_constant_value(e.value) or _is_intrinsic_alias_value(effects, e.value) for e in entries) } if not constant_names: return [] substitutions: list[_Substitution] = [] for node in walk_scope(scope, include_root_body=True): if isinstance(node, JsMemberExpression) and node.computed: obj = node.object if ( isinstance(obj, JsIdentifier) and id(obj) not in decl_ids and obj.name in constant_names and obj.name not in bloat_blocked and self._index_array_immutable(obj, effects, direct_eval_ordered=True) ): entry = candidates[obj.name][0] binding = self._candidate_binding(entry, model) if binding is not None and reaching.value_preserved(binding, entry.value, node): element = self._index_access_element(node, entry) if element is not None: substitutions.append(_Substitution(node, element, obj.name)) continue if not isinstance(node, JsIdentifier): continue if id(node) in decl_ids: continue name = node.name if name not in constant_names or name in bloat_blocked: continue parent = node.parent if reference_role(node) is not Role.READ: continue if isinstance(parent, JsMemberExpression) and parent.object is node and parent.computed: continue entry = candidates[name][0] if not is_literal(entry.value): continue binding = self._candidate_binding(entry, model) if binding is None or model.resolve(node) is not binding: continue if not reaching.value_preserved(binding, entry.value, node): continue substitutions.append(_Substitution(node, entry.value, name)) substitutions.extend(self._decide_const_across_functions( scope, candidates, decl_ids, bloat_blocked, cache, )) return substitutions @staticmethod def _index_array_immutable( obj: JsIdentifier, effects: EffectModel, direct_eval_ordered: bool = False, ) -> bool: """ Whether the array binding referenced by *obj* is an immutable, non-escaping container, so that `obj[idx]` may be inlined to its literal element. Resolved through the binding (not the textual name), so shadowing is respected; the model memoizes the judgment for the model's lifetime. A name that does not resolve to a local binding (a free or global array) is treated as unsafe. *direct_eval_ordered* carries the caller's promise that it has ordered the direct-`eval` sites against the read it is folding — the `value_preserved` check the same arm makes — so a local array in a function holding a direct `eval` stays inlinable where the read is ordered before every eval site. """ binding = effects.model.resolve(obj) if binding is None: return False return effects.binding_is_immutable_container( binding, direct_eval_ordered=direct_eval_ordered) @staticmethod def _index_access_element(member: JsMemberExpression, entry: _CandidateEntry) -> Node | None: """ The literal element an index access into a candidate's all-literal array resolves to, or `None` when the access names no in-bounds literal element. """ prop = member.property if not isinstance(prop, JsNumericLiteral): return None idx = exact_integer(prop.value) if idx is None: return None value = entry.value if not isinstance(value, JsArrayExpression): return None if not (0 <= idx < len(value.elements)): return None element = value.elements[idx] if element is None or not is_literal(element): return None return element def _decide_const_across_functions( self, scope: Node, candidates: dict[str, list[_CandidateEntry]], decl_ids: set[int], bloat_blocked: set[str], cache: ModelCache, ) -> list[_Substitution]: """ Decide the constant-valued inlines of one round that cross into nested function bodies. A `const`-qualified candidate, or an uninitialized `var` later assigned a single constant, is inlined into a function only when the value provably runs before every invocation of that function (`DominanceModel.runs_before_function`) — otherwise a call could read the value early: a stale read for the `var` form, a temporal-dead-zone throw for the `const` form. A `var`/`let` candidate that carries its own initializer is additionally restricted by `_reader_qualifies` to a reader that neither writes the binding nor runs an invocation the value does not precede — a named function invoked in scope, an anonymous IIFE, an arrow, or a function stored in a binding, each of whose invocation points is enumerable and ordered; an uncalled named function is refused, since its ordering answer is the vacuous one an empty set of reference points gives. The interprocedural runs-before check subsumes the earlier escape and statement-position heuristics: a function cannot be invoked before a reference to it has been evaluated, so it orders the value against every point the function is referenced — recursing up the call graph for a reference that lies inside another function — and inlines only when the value dominates all of them, refusing whenever a reference cannot be ordered (its binding is reassigned or redeclared, or it lies on a call cycle). A candidate a dynamic scope could rewrite is refused here (`binding_maybe_reassigned_dynamically`): this consumer's ordering runs the value before an *invocation*, and an invocation cannot be ordered against the site that rewrites the value — a name read once can be stored and invoked arbitrarily later, past the hazard — so the reads that fold on the located hazards are the same-function ones, which the reaching query answers at the use itself. """ effects = cache.effects dominance = cache.dominance model = effects.model cross_candidates: dict[str, list[_CandidateEntry]] = {} cross_bindings: dict[str, Binding] = {} const_names: set[str] = set() for name, entries in candidates.items(): if name in bloat_blocked: continue if len(entries) != 1: continue entry = entries[0] if entry.declarator is None or not isinstance(entry.declarator.id, JsIdentifier): continue intrinsic_alias = _is_intrinsic_alias_value(effects, entry.value) if not (_is_constant_value(entry.value) or intrinsic_alias): continue binding = model.binding_of(entry.declarator.id) if binding is None: continue cross_candidates[name] = entries cross_bindings[name] = binding if _is_const_qualified(entry.declarator) or entry.declarator.init is None or intrinsic_alias: const_names.add(name) if not cross_candidates: return [] outer = model.scope_of(scope) assert outer is not None owner = enclosing_function(scope) called_funcs = _collect_called_functions(scope, effects) a_callee_is_unstated = _calls_a_function_of_this_file_by_an_unstated_name(scope, effects) for name in [ candidate for candidate, binding in cross_bindings.items() if model.binding_maybe_reassigned_dynamically(binding) or (a_callee_is_unstated and effects.some_function_can_mutate(binding)) or any(effects.function_can_mutate(func, binding) for func in called_funcs) ]: del cross_candidates[name] del cross_bindings[name] if not cross_candidates: return [] substitutions: list[_Substitution] = [] for node in scope.walk(): if isinstance(node, JsMemberExpression) and node.computed: obj = node.object if ( isinstance(obj, JsIdentifier) and id(obj) not in decl_ids and obj.name in cross_candidates and self._index_array_immutable(obj, effects) ): name = obj.name enclosing = enclosing_function(obj) if enclosing is None or enclosing is owner: continue if model.resolve(obj) is not cross_bindings[name]: continue if name in const_names: if not dominance.runs_before_function( cross_candidates[name][0].value, enclosing, ): continue elif not _reader_qualifies( cross_candidates[name][0].value, enclosing, cross_bindings[name], effects, dominance, ): continue if model.is_shadowed(name, obj, outer): continue element = self._index_access_element(node, cross_candidates[name][0]) if element is not None: substitutions.append(_Substitution(node, element, name)) continue if not isinstance(node, JsIdentifier): continue if id(node) in decl_ids: continue name = node.name if name not in cross_candidates: continue parent = node.parent if reference_role(node) is not Role.READ: continue if isinstance(parent, JsMemberExpression) and parent.object is node and parent.computed: continue if isinstance(parent, JsVariableDeclarator) and parent.id is node: continue if isinstance(parent, (JsFunctionDeclaration, JsFunctionExpression)) and parent.id is node: continue entry = cross_candidates[name][0] intrinsic_alias = _is_intrinsic_alias_value(effects, entry.value) if not (is_literal(entry.value) or intrinsic_alias): continue enclosing = enclosing_function(node) if enclosing is None or enclosing is owner: continue if model.resolve(node) is not cross_bindings[name]: continue if name in const_names: if not dominance.runs_before_function(entry.value, enclosing): continue elif not _reader_qualifies( entry.value, enclosing, cross_bindings[name], effects, dominance, ): continue if model.is_shadowed(name, node, outer): continue if ( intrinsic_alias and isinstance(entry.value, JsIdentifier) and model.lookup(entry.value.name, model.scope_of(node)) is not None ): continue substitutions.append(_Substitution(node, entry.value, name)) return substitutions def _decide_expressions( self, scope: Node, candidates: dict[str, list[_CandidateEntry]], mutated: set[str], cache: ModelCache, ) -> list[_Substitution]: """ Decide the single-use, side-effect-free, non-literal expression inlines of one round. Relocating the initializer to its use is sound only when the value it computed still holds there — which means the candidate's own binding reaches the use unchanged *and* every variable the initializer reads holds, at the use, the value it held at the definition. `ReachingModel.value_preserved` decides each: the candidate binding for ordering and its own kills, then one query per resolved free variable. The `_is_primitive_and_pure` gate keeps the initializer free of side effects and reference identity, and the `mutated` gate rejects a free variable written through a name no binding resolves (a global reassigned in scope), which the binding-keyed reaching query cannot see. A candidate whose initializer reads a bare name through a `with` body's dynamic scope needs no separate gate here: such an initializer is inside the `with` body, so the candidate's own binding is written in a dynamic scope and the reaching query orders that hazard against the relocated read. """ decl_ids = _candidate_decl_ids(candidates) ref_counts = _count_scope_references(scope, set(candidates), decl_ids) to_inline: dict[str, _CandidateEntry] = {} for name, entries in candidates.items(): if len(entries) != 1: continue entry = entries[0] init = entry.value count = ref_counts.get(name, 0) if is_literal(init) or _is_literal_array(init) or count != 1: continue if not _is_primitive_and_pure(init): continue if collect_identifier_names(init) & mutated: continue to_inline[name] = entry if not to_inline: return [] reaching = cache.reaching model = cache.effects.model substitutions: list[_Substitution] = [] for node in walk_scope(scope, include_root_body=True): if not isinstance(node, JsIdentifier): continue if id(node) in decl_ids: continue name = node.name if name not in to_inline: continue if reference_role(node) is not Role.READ: continue entry = to_inline[name] binding = self._candidate_binding(entry, model) if binding is None or not reaching.value_preserved(binding, entry.value, node): continue if not self._free_variables_preserved(entry.value, node, model, reaching): continue substitutions.append(_Substitution(node, entry.value, name)) return substitutions @staticmethod def _free_variables_preserved( value: Node, use: Node, model: SemanticModel, reaching: ReachingModel, ) -> bool: """ Whether re-evaluating *value* at *use* yields the same result it had where *value* was defined. Each name *value* reads must resolve, from *use*'s scope, to the binding it read where it was defined — a name a block or function around *use* shadows would read a different binding there, so a global read at the definition becomes a local read once substituted — and, where that binding exists, must still hold the value it held (`ReachingModel.value_preserved`). A free name that stays free preserves its value by the caller's `mutated` gate, so only its resolution is checked here. This is the twin of `JsCallWrapperInliner._forwarded_callee_reaches`, which guards the same capture for a forwarded callee. """ use_scope = model.scope_of(use) for ident in value.walk(): if not isinstance(ident, JsIdentifier) or not model.is_reference(ident): continue binding = model.resolve(ident) if model.lookup(ident.name, use_scope) is not binding: return False if binding is not None and not reaching.value_preserved(binding, value, use): return False return True def _decide_dead_declarators( self, candidates: dict[str, list[_CandidateEntry]], planned: set[str], cache: ModelCache, ) -> list[_DeclaratorRemoval]: """ Decide which declarators of the round-inlined names are up for removal. No reference count is read here — one taken before the round's plans apply cannot see the reads another plan of the same round deletes — so every name the round substitutes is proposed and the plan counts references at apply time. An exported binding keeps its declarator even with every local read inlined, because an importer still reads it across the module boundary; removing it would leave an `export` naming a binding the module no longer declares. A binding that code the model cannot read could name keeps its declarator for the same reason: every read this file spells may have folded, and the surface — a direct `eval`, a span of source the model never read, a `with` body — reads the binding through no reference the inlining counted, so removing the declaration would turn its value into a `ReferenceError`. An opaque global write is not such a surface: it stores a property and runs nothing, and the reads it could replace were ordered against it before they folded, so the property it may write is the same residual the fold already concedes. """ model = cache.model removals: list[_DeclaratorRemoval] = [] for name in planned: entries = candidates.get(name) if entries is None: continue for entry in entries: if entry.declarator is None: continue binding = self._candidate_binding(entry, model) if binding is not None and ( binding.exported or model.reachable_by_opaque_reflection(binding) or bool(binding.dynamic_refs) ): continue removals.append(_DeclaratorRemoval(entry.declarator, name)) return removals @staticmethod def _collect_member_array_candidates(scope: Node) -> dict[str, _MemberArrayEntry]: """ Collect member-expression assignments of all-literal arrays: `X.Y = [literals...]`. Returns a dict keyed by `"X.Y"` to `_MemberArrayEntry`. Only single-assignment, non-aliased properties qualify. """ candidates: dict[str, _MemberArrayEntry] = {} rejected: set[str] = set() prefix_rejected: set[str] = set() for node in walk_scope(scope, include_root_body=True): if not isinstance(node, JsAssignmentExpression) or node.operator != '=': continue lhs = node.left if not isinstance(lhs, JsMemberExpression) or lhs.computed: continue if not isinstance(lhs.object, JsIdentifier) or not isinstance(lhs.property, JsIdentifier): continue key = F'{lhs.object.name}.{lhs.property.name}' if key in rejected: continue rhs = node.right if not isinstance(rhs, JsArrayExpression) or not _is_literal_array(rhs): rejected.add(key) candidates.pop(key, None) continue if key in candidates: rejected.add(key) candidates.pop(key) continue candidates[key] = _MemberArrayEntry(node, rhs) if not candidates: return candidates prefix_names = {k.split('.', 1)[0] for k in candidates} for node in walk_scope(scope, include_root_body=True): if isinstance(node, JsAssignmentExpression) and node.operator == '=': if isinstance(node.left, JsIdentifier) and node.left.name in prefix_names: prefix_rejected.add(node.left.name) if isinstance(node, JsUpdateExpression) and isinstance(node.argument, JsIdentifier): if node.argument.name in prefix_names: prefix_rejected.add(node.argument.name) if prefix_rejected: candidates = { k: v for k, v in candidates.items() if k.split('.', 1)[0] not in prefix_rejected } for key in list(candidates): prefix, prop = key.split('.', 1) if not _is_member_array_safe(scope, prefix, prop): del candidates[key] return candidates def _decide_member_arrays( self, scope: Node, member_arrays: dict[str, _MemberArrayEntry], ) -> list[_Substitution]: """ Decide the `X.Y[N]` → element inlines of one round for all collected member-array candidates. Walks the full subtree (including nested function bodies) since these arrays are scope-level constants. """ substitutions: list[_Substitution] = [] for node in scope.walk(): if not isinstance(node, JsMemberExpression) or not node.computed: continue prop = node.property if not isinstance(prop, JsNumericLiteral): continue obj = node.object if not isinstance(obj, JsMemberExpression) or obj.computed: continue if not isinstance(obj.object, JsIdentifier) or not isinstance(obj.property, JsIdentifier): continue key = F'{obj.object.name}.{obj.property.name}' entry = member_arrays.get(key) if entry is None: continue idx = exact_integer(prop.value) if idx is None or not (0 <= idx < len(entry.array.elements)): continue element = entry.array.elements[idx] if element is None or not is_literal(element): continue substitutions.append(_Substitution(node, element, key)) return substitutions def _decide_dead_member_arrays( self, member_arrays: dict[str, _MemberArrayEntry], planned: set[str], ) -> list[_MemberArrayRemoval]: return [ _MemberArrayRemoval(entry.assignment, key) for key, entry in member_arrays.items() if key in planned ] @staticmethod def _count_member_array_accesses(scope: Node, keys: set[str]) -> dict[str, int]: """ The computed `X.Y[...]` accesses standing in *scope*'s subtree per key in *keys*, counted over the tree as it stands when asked — the plan asks once its substitutions have landed. """ remaining: dict[str, int] = {} for node in scope.walk(): if not isinstance(node, JsMemberExpression) or not node.computed: continue obj = node.object if not isinstance(obj, JsMemberExpression) or obj.computed: continue if not isinstance(obj.object, JsIdentifier) or not isinstance(obj.property, JsIdentifier): continue key = F'{obj.object.name}.{obj.property.name}' if key in keys: remaining[key] = remaining.get(key, 0) + 1 return remaining def _apply_plan(self, plan: _ScopePlan) -> None: for sub in plan.substitutions: if not is_attached(sub.target): continue clone = _clone_node(sub.value) if isinstance(sub.target, JsIdentifier): substituted = substitute_use_position(sub.target, clone) else: substituted = _replace_in_parent(sub.target, clone) if not substituted: continue self.mark_changed() self._edits_applied += 1 if plan.declarator_removals: ref_counts = _count_scope_references( plan.scope, plan.planned, plan.decl_ids, walk_full=True, ) for removal in plan.declarator_removals: if ref_counts.get(removal.key, 0) > 0: continue if not is_attached(removal.declarator): continue remove_declarator(removal.declarator) self.mark_changed() self._edits_applied += 1 if plan.member_array_removals: keys = {removal.key for removal in plan.member_array_removals} remaining = self._count_member_array_accesses(plan.scope, keys) for removal in plan.member_array_removals: if remaining.get(removal.key, 0) > 0: continue if not is_attached(removal.assignment): continue stmt = removal.assignment.parent if isinstance(stmt, JsExpressionStatement) and _remove_from_parent(stmt): self.mark_changed() self._edits_applied += 1Ancestors
- BatchedScopeTransformer
- ScopeProcessingTransformer
- Transformer
- Visitor
- typing.Generic
Methods
def visit_JsScript(self, node)-
Expand source code Browse git
def visit_JsScript(self, node: JsScript): self._root = node while True: self._edits_applied = 0 super().visit_JsScript(node) if not self._edits_applied: break return None
Inherited members