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
from refinery.lib.scripts.js.analysis.cache import 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 (
ScopeProcessingTransformer,
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,
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
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.
"""
for n in node.walk():
if isinstance(n, (
JsCallExpression,
JsNewExpression,
JsAssignmentExpression,
JsUpdateExpression,
JsYieldExpression,
JsAwaitExpression,
JsTaggedTemplateExpression,
JsMemberExpression,
JsObjectExpression,
JsArrayExpression,
JsFunctionExpression,
JsArrowFunctionExpression,
JsClassExpression,
)):
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:
"""
Return whether a declarator belongs to a `const` declaration.
"""
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_call_sites(
scope: Node,
effects: EffectModel,
) -> tuple[dict[int, list[Node]], list[Node]]:
"""
Map each statically resolvable callee within *scope* to the call expressions that target it,
and list those callees once each 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.
"""
call_sites: dict[int, list[Node]] = {}
called_funcs: list[Node] = []
for node in walk_scope(scope, include_root_body=True):
if isinstance(node, JsCallExpression):
target = effects.static_callee(node)
if target is not None:
if id(target) not in call_sites:
called_funcs.append(target)
call_sites.setdefault(id(target), []).append(node)
return call_sites, called_funcs
class JsConstantInlining(ScopeProcessingTransformer):
"""
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.
"""
def __init__(self, max_inline_length: int = 64):
super().__init__()
self.max_inline_length = max_inline_length
self._root: JsScript | None = None
def visit_JsScript(self, node: JsScript):
self._root = node
return super().visit_JsScript(node)
def _process_scope(self, scope: Node) -> None:
assert self._root is not None
effects = model_cache(self, self._root).effects
while True:
candidates, mutated = self._collect_candidates(scope, effects)
member_arrays = self._collect_member_array_candidates(scope)
if not candidates and not member_arrays:
return
inlined = self._substitute_constants(scope, candidates)
if member_arrays:
self._substitute_member_arrays(scope, member_arrays, inlined)
if inlined:
self._remove_dead(scope, candidates, inlined)
self._remove_dead_member_arrays(scope, member_arrays, inlined)
continue
inlined = self._substitute_expressions(scope, candidates, mutated)
if inlined:
self._remove_dead(scope, candidates, inlined)
continue
return
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 rejected
the same way, through `binding_maybe_reassigned_dynamically`: a `with`-body write the model
attributes to it, or a direct `eval` in its owning function that could rebind it through a
string; a write through a global-object alias is rejected alongside. A candidate the analyst
declared a host reaches by name is rejected outright — 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. 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_maybe_reassigned_dynamically(binding)
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 _substitute_constants(
self,
scope: Node,
candidates: dict[str, list[_CandidateEntry]],
) -> dict[str, int]:
"""
Inline constant (literal and literal-array) variable references. A reference is inlined only
where the definition's value provably reaches it unchanged (`ReachingModel.value_preserved`).
Handles both scalar references and computed index access into all-literal arrays.
"""
inlined: dict[str, int] = {}
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 len(value.value) > self.max_inline_length:
bloat_blocked.add(name)
assert self._root is not None
cache = model_cache(self, self._root)
effects = cache.effects
dominance = cache.dominance
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 inlined
for node in list(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)
):
entry = candidates[obj.name][0]
binding = self._candidate_binding(entry, model)
if binding is not None and reaching.value_preserved(binding, entry.value, node):
self._apply_index_access_inline(node, entry, obj.name, inlined)
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
substituted = substitute_use_position(node, _clone_node(entry.value))
self._record_inline(substituted, name, inlined)
self._substitute_const_across_functions(
scope, candidates, decl_ids, bloat_blocked, inlined, effects, dominance,
)
return inlined
@staticmethod
def _index_array_immutable(obj: JsIdentifier, effects: EffectModel) -> 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.
"""
binding = effects.model.resolve(obj)
if binding is None:
return False
return effects.binding_is_immutable_container(binding)
def _record_inline(self, substituted: bool, name: str, inlined: dict[str, int]) -> None:
"""
Announce one inlining of *name*, where it happened. Every edit this pass makes reports
whether it found the slot it was aiming at, and a pass that announces a change it did not
make announces one every round: the fixpoint the pipeline runs it in never closes.
"""
if not substituted:
return
self.mark_changed()
inlined[name] = inlined.get(name, 0) + 1
def _apply_index_access_inline(
self,
member: JsMemberExpression,
entry: _CandidateEntry,
name: str,
inlined: dict[str, int],
) -> None:
prop = member.property
if not isinstance(prop, JsNumericLiteral):
return
idx = exact_integer(prop.value)
if idx is None:
return
value = entry.value
if not isinstance(value, JsArrayExpression):
return
if not (0 <= idx < len(value.elements)):
return
element = value.elements[idx]
if element is None or not is_literal(element):
return
substituted = _replace_in_parent(member, _clone_node(element))
self._record_inline(substituted, name, inlined)
def _substitute_const_across_functions(
self,
scope: Node,
candidates: dict[str, list[_CandidateEntry]],
decl_ids: set[int],
bloat_blocked: set[str],
inlined: dict[str, int],
effects: EffectModel,
dominance: DominanceModel,
) -> None:
"""
For constant-valued candidates, walk the full subtree to inline references inside 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 to a named function declaration actually called in the current scope that the effect
model proves does not mutate the binding. 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).
"""
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)
call_sites, called_funcs = _collect_call_sites(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 (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
for node in list(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 not in const_names and (
not isinstance(enclosing, JsFunctionDeclaration)
or id(enclosing) not in call_sites
or effects.function_can_mutate(enclosing, cross_bindings[name])
):
continue
if not dominance.runs_before_function(cross_candidates[name][0].value, enclosing):
continue
if model.is_shadowed(name, obj, outer):
continue
self._apply_index_access_inline(
node, cross_candidates[name][0], name, inlined,
)
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 not in const_names and (
not isinstance(enclosing, JsFunctionDeclaration)
or id(enclosing) not in call_sites
or effects.function_can_mutate(enclosing, cross_bindings[name])
):
continue
if not dominance.runs_before_function(entry.value, enclosing):
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
substituted = substitute_use_position(node, _clone_node(entry.value))
self._record_inline(substituted, name, inlined)
def _substitute_expressions(
self,
scope: Node,
candidates: dict[str, list[_CandidateEntry]],
mutated: set[str],
) -> dict[str, int]:
"""
Inline single-use, side-effect-free, non-literal expressions. 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 `_collect_candidates` already rejects it through
`binding_maybe_reassigned_dynamically`.
"""
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 {}
assert self._root is not None
cache = model_cache(self, self._root)
reaching = cache.reaching
model = cache.effects.model
inlined: dict[str, int] = {}
for node in list(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
substituted = substitute_use_position(node, _clone_node(entry.value))
self._record_inline(substituted, name, inlined)
return inlined
@staticmethod
def _free_variables_preserved(
value: Node, use: Node, model: SemanticModel, reaching: ReachingModel,
) -> bool:
"""
Whether every variable *value* reads holds, at *use*, the value it held where *value* was defined
— so re-evaluating *value* at *use* yields the same result. Each identifier that resolves to a
binding is checked with `ReachingModel.value_preserved`; an identifier that resolves to no
binding (a global) is left to the caller's `mutated` gate.
"""
for ident in value.walk():
if not isinstance(ident, JsIdentifier):
continue
binding = model.resolve(ident)
if binding is not None and not reaching.value_preserved(binding, value, use):
return False
return True
def _remove_dead(
self,
scope: Node,
candidates: dict[str, list[_CandidateEntry]],
inlined: dict[str, int],
) -> None:
"""
Remove declarators for variables where all references have been inlined. For `const`
qualified candidates, check the full subtree since cross-function inlining may have
replaced references inside nested functions. 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.
"""
assert self._root is not None
model = model_cache(self, self._root).model
decl_ids = _candidate_decl_ids(candidates)
ref_counts = _count_scope_references(
scope, set(inlined), decl_ids, walk_full=True,
)
for name in inlined:
remaining = ref_counts.get(name, 0)
if remaining > 0:
continue
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:
continue
remove_declarator(entry.declarator)
self.mark_changed()
@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 _substitute_member_arrays(
self,
scope: Node,
member_arrays: dict[str, _MemberArrayEntry],
inlined: dict[str, int],
) -> None:
"""
Inline `X.Y[N]` → element for all collected member-array candidates. Walks the full
subtree (including nested function bodies) since these arrays are scope-level constants.
"""
for node in list(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
substituted = _replace_in_parent(node, _clone_node(element))
self._record_inline(substituted, key, inlined)
def _remove_dead_member_arrays(
self,
scope: Node,
member_arrays: dict[str, _MemberArrayEntry],
inlined: dict[str, int],
) -> None:
"""
Remove the assignment statement for member arrays where all references were inlined.
"""
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 member_arrays:
remaining[key] = remaining.get(key, 0) + 1
for key, entry in member_arrays.items():
if key not in inlined:
continue
if remaining.get(key, 0) > 0:
continue
stmt = entry.assignment.parent
if isinstance(stmt, JsExpressionStatement):
_remove_from_parent(stmt)
self.mark_changed()
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.Expand source code Browse git
class JsConstantInlining(ScopeProcessingTransformer): """ 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. """ def __init__(self, max_inline_length: int = 64): super().__init__() self.max_inline_length = max_inline_length self._root: JsScript | None = None def visit_JsScript(self, node: JsScript): self._root = node return super().visit_JsScript(node) def _process_scope(self, scope: Node) -> None: assert self._root is not None effects = model_cache(self, self._root).effects while True: candidates, mutated = self._collect_candidates(scope, effects) member_arrays = self._collect_member_array_candidates(scope) if not candidates and not member_arrays: return inlined = self._substitute_constants(scope, candidates) if member_arrays: self._substitute_member_arrays(scope, member_arrays, inlined) if inlined: self._remove_dead(scope, candidates, inlined) self._remove_dead_member_arrays(scope, member_arrays, inlined) continue inlined = self._substitute_expressions(scope, candidates, mutated) if inlined: self._remove_dead(scope, candidates, inlined) continue return 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 rejected the same way, through `binding_maybe_reassigned_dynamically`: a `with`-body write the model attributes to it, or a direct `eval` in its owning function that could rebind it through a string; a write through a global-object alias is rejected alongside. A candidate the analyst declared a host reaches by name is rejected outright — 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. 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_maybe_reassigned_dynamically(binding) 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 _substitute_constants( self, scope: Node, candidates: dict[str, list[_CandidateEntry]], ) -> dict[str, int]: """ Inline constant (literal and literal-array) variable references. A reference is inlined only where the definition's value provably reaches it unchanged (`ReachingModel.value_preserved`). Handles both scalar references and computed index access into all-literal arrays. """ inlined: dict[str, int] = {} 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 len(value.value) > self.max_inline_length: bloat_blocked.add(name) assert self._root is not None cache = model_cache(self, self._root) effects = cache.effects dominance = cache.dominance 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 inlined for node in list(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) ): entry = candidates[obj.name][0] binding = self._candidate_binding(entry, model) if binding is not None and reaching.value_preserved(binding, entry.value, node): self._apply_index_access_inline(node, entry, obj.name, inlined) 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 substituted = substitute_use_position(node, _clone_node(entry.value)) self._record_inline(substituted, name, inlined) self._substitute_const_across_functions( scope, candidates, decl_ids, bloat_blocked, inlined, effects, dominance, ) return inlined @staticmethod def _index_array_immutable(obj: JsIdentifier, effects: EffectModel) -> 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. """ binding = effects.model.resolve(obj) if binding is None: return False return effects.binding_is_immutable_container(binding) def _record_inline(self, substituted: bool, name: str, inlined: dict[str, int]) -> None: """ Announce one inlining of *name*, where it happened. Every edit this pass makes reports whether it found the slot it was aiming at, and a pass that announces a change it did not make announces one every round: the fixpoint the pipeline runs it in never closes. """ if not substituted: return self.mark_changed() inlined[name] = inlined.get(name, 0) + 1 def _apply_index_access_inline( self, member: JsMemberExpression, entry: _CandidateEntry, name: str, inlined: dict[str, int], ) -> None: prop = member.property if not isinstance(prop, JsNumericLiteral): return idx = exact_integer(prop.value) if idx is None: return value = entry.value if not isinstance(value, JsArrayExpression): return if not (0 <= idx < len(value.elements)): return element = value.elements[idx] if element is None or not is_literal(element): return substituted = _replace_in_parent(member, _clone_node(element)) self._record_inline(substituted, name, inlined) def _substitute_const_across_functions( self, scope: Node, candidates: dict[str, list[_CandidateEntry]], decl_ids: set[int], bloat_blocked: set[str], inlined: dict[str, int], effects: EffectModel, dominance: DominanceModel, ) -> None: """ For constant-valued candidates, walk the full subtree to inline references inside 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 to a named function declaration actually called in the current scope that the effect model proves does not mutate the binding. 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). """ 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) call_sites, called_funcs = _collect_call_sites(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 (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 for node in list(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 not in const_names and ( not isinstance(enclosing, JsFunctionDeclaration) or id(enclosing) not in call_sites or effects.function_can_mutate(enclosing, cross_bindings[name]) ): continue if not dominance.runs_before_function(cross_candidates[name][0].value, enclosing): continue if model.is_shadowed(name, obj, outer): continue self._apply_index_access_inline( node, cross_candidates[name][0], name, inlined, ) 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 not in const_names and ( not isinstance(enclosing, JsFunctionDeclaration) or id(enclosing) not in call_sites or effects.function_can_mutate(enclosing, cross_bindings[name]) ): continue if not dominance.runs_before_function(entry.value, enclosing): 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 substituted = substitute_use_position(node, _clone_node(entry.value)) self._record_inline(substituted, name, inlined) def _substitute_expressions( self, scope: Node, candidates: dict[str, list[_CandidateEntry]], mutated: set[str], ) -> dict[str, int]: """ Inline single-use, side-effect-free, non-literal expressions. 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 `_collect_candidates` already rejects it through `binding_maybe_reassigned_dynamically`. """ 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 {} assert self._root is not None cache = model_cache(self, self._root) reaching = cache.reaching model = cache.effects.model inlined: dict[str, int] = {} for node in list(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 substituted = substitute_use_position(node, _clone_node(entry.value)) self._record_inline(substituted, name, inlined) return inlined @staticmethod def _free_variables_preserved( value: Node, use: Node, model: SemanticModel, reaching: ReachingModel, ) -> bool: """ Whether every variable *value* reads holds, at *use*, the value it held where *value* was defined — so re-evaluating *value* at *use* yields the same result. Each identifier that resolves to a binding is checked with `ReachingModel.value_preserved`; an identifier that resolves to no binding (a global) is left to the caller's `mutated` gate. """ for ident in value.walk(): if not isinstance(ident, JsIdentifier): continue binding = model.resolve(ident) if binding is not None and not reaching.value_preserved(binding, value, use): return False return True def _remove_dead( self, scope: Node, candidates: dict[str, list[_CandidateEntry]], inlined: dict[str, int], ) -> None: """ Remove declarators for variables where all references have been inlined. For `const` qualified candidates, check the full subtree since cross-function inlining may have replaced references inside nested functions. 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. """ assert self._root is not None model = model_cache(self, self._root).model decl_ids = _candidate_decl_ids(candidates) ref_counts = _count_scope_references( scope, set(inlined), decl_ids, walk_full=True, ) for name in inlined: remaining = ref_counts.get(name, 0) if remaining > 0: continue 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: continue remove_declarator(entry.declarator) self.mark_changed() @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 _substitute_member_arrays( self, scope: Node, member_arrays: dict[str, _MemberArrayEntry], inlined: dict[str, int], ) -> None: """ Inline `X.Y[N]` → element for all collected member-array candidates. Walks the full subtree (including nested function bodies) since these arrays are scope-level constants. """ for node in list(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 substituted = _replace_in_parent(node, _clone_node(element)) self._record_inline(substituted, key, inlined) def _remove_dead_member_arrays( self, scope: Node, member_arrays: dict[str, _MemberArrayEntry], inlined: dict[str, int], ) -> None: """ Remove the assignment statement for member arrays where all references were inlined. """ 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 member_arrays: remaining[key] = remaining.get(key, 0) + 1 for key, entry in member_arrays.items(): if key not in inlined: continue if remaining.get(key, 0) > 0: continue stmt = entry.assignment.parent if isinstance(stmt, JsExpressionStatement): _remove_from_parent(stmt) self.mark_changed()Ancestors
Methods
def visit_JsScript(self, node)-
Expand source code Browse git
def visit_JsScript(self, node: JsScript): self._root = node return super().visit_JsScript(node)
Inherited members