Module refinery.lib.scripts.js.deobfuscation.evaluator
Evaluate pure JavaScript functions called with constant arguments and replace call sites with computed results.
Expand source code Browse git
"""
Evaluate pure JavaScript functions called with constant arguments and replace call sites with
computed results.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from refinery.lib.scripts.js.deobfuscation.interpreter import Value
from refinery.lib.scripts import Node, Transformer, _remove_from_parent, _replace_in_parent
from refinery.lib.scripts.js.analysis.cache import model_cache
from refinery.lib.scripts.js.analysis.effects import EffectModel
from refinery.lib.scripts.js.analysis.model import (
Scope,
SemanticModel,
is_invocation_target,
pattern_identifiers,
)
from refinery.lib.scripts.js.deobfuscation.helpers import (
ScriptLevelTransformer,
access_key,
binding_has_references,
extract_literal_value,
is_reference,
references_receiver_this,
remove_declarator,
substitute_params,
value_to_node,
walk_scope,
)
from refinery.lib.scripts.js.deobfuscation.interpreter import (
InterpreterError,
IrreducibleExpression,
JsInterpreter,
_contains_jsbuffer,
is_runtime_name,
)
from refinery.lib.scripts.js.model import (
JsArrowFunctionExpression,
JsAssignmentExpression,
JsBlockStatement,
JsCallExpression,
JsCatchClause,
JsForInStatement,
JsForOfStatement,
JsForStatement,
JsFunctionDeclaration,
JsFunctionExpression,
JsIdentifier,
JsMemberExpression,
JsNumericLiteral,
JsReturnStatement,
JsScript,
JsStringLiteral,
JsSwitchCase,
JsSwitchStatement,
JsUnaryExpression,
JsUpdateExpression,
JsVariableDeclaration,
JsVariableDeclarator,
JsVarKind,
strip_parens,
)
MAX_RESULT_ARRAY_LEN = 260
_FuncNode = JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression
_MISSING = object()
_MUTATING_ARRAY_METHODS = frozenset({
'push', 'pop', 'shift', 'unshift', 'splice', 'reverse', 'sort', 'fill', 'copyWithin',
})
def _is_inplace_mutation(node: JsIdentifier) -> bool:
"""
Return whether the reference *node* mutates the value bound to its name in place: a member-target
write (`x[i] = v`, `x.p = v`, `x[i]++`, `delete x.p`) or a call to a known mutating array method
(`x.push(...)`, `x.reverse()`, ...). Such mutations are invisible to closure / const-argument
capture, which snapshots only the declared initializer value.
"""
parent = node.parent
if not isinstance(parent, JsMemberExpression) or parent.object is not node:
return False
grand = parent.parent
if isinstance(grand, JsAssignmentExpression) and grand.left is parent:
return True
if isinstance(grand, JsUpdateExpression) and grand.argument is parent:
return True
if isinstance(grand, JsUnaryExpression) and grand.operator == 'delete' and grand.operand is parent:
return True
if isinstance(grand, (JsForOfStatement, JsForInStatement)) and grand.left is parent:
return True
if is_invocation_target(parent):
return access_key(parent) in _MUTATING_ARRAY_METHODS
return False
def _is_value_closed(
func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression,
known_pure: set[str],
) -> bool:
"""
Check whether a function's body is closed enough for the interpreter to evaluate a call to it: it
references only its own parameters, names declared within its body, functions in the known-pure
set, registry built-ins, and well-known globals. This is the *value* precondition — every name the
interpreter needs is resolvable — and is deliberately separate from the *effect* precondition that a
call writes no observable state, which `refinery.lib.scripts.js.analysis.effects.EffectModel`
decides. A body that is a single switch-return (globalConcealing shape) qualifies even when its
return expressions reference external names, because the irreducible fallback substitutes the
parameters into them.
"""
for param in func.params:
if not isinstance(param, JsIdentifier):
return False
body = func.body
if body is None:
return True
local_names = {p.name for p in func.params if isinstance(p, JsIdentifier)}
_collect_declared_names(body, local_names)
if isinstance(func, JsFunctionDeclaration) and isinstance(func.id, JsIdentifier):
local_names.add(func.id.name)
elif isinstance(func, JsFunctionExpression) and isinstance(func.id, JsIdentifier):
local_names.add(func.id.name)
if references_receiver_this(body):
return False
if _is_switch_return_pattern(body, local_names):
return True
for node in walk_scope(body):
if isinstance(node, JsIdentifier) and is_reference(node) and node.name not in local_names:
name = node.name
if name in known_pure or name in ('undefined', 'NaN', 'Infinity') or is_runtime_name(name):
continue
return False
return True
def _is_switch_return_pattern(body, local_names: set[str]) -> bool:
"""
Check whether the function body is a switch statement where every case returns an expression.
This is the globalConcealing pattern where return expressions may reference external names
but the dispatch logic itself is pure (switch on a parameter).
"""
if not isinstance(body, JsBlockStatement):
return False
stmts = body.body
if not stmts:
return False
switch = stmts[0]
if not isinstance(switch, JsSwitchStatement):
return False
for remaining in stmts[1:]:
if isinstance(remaining, JsReturnStatement) and remaining.argument is None:
continue
return False
if switch.discriminant is None:
return False
if isinstance(switch.discriminant, JsIdentifier):
if switch.discriminant.name not in local_names:
return False
elif not _all_refs_local(switch.discriminant, local_names):
return False
if not switch.cases:
return False
for case in switch.cases:
if not isinstance(case, JsSwitchCase):
return False
if case.test is not None and not isinstance(case.test, (JsStringLiteral, JsNumericLiteral)):
return False
case_body = case.body
if len(case_body) != 1:
return False
stmt = case_body[0]
if not isinstance(stmt, JsReturnStatement) or stmt.argument is None:
return False
return True
def _all_refs_local(node: Node, local_names: set[str]) -> bool:
for child in node.walk():
if isinstance(child, JsIdentifier) and is_reference(child):
if child.name not in local_names:
return False
return True
def _collect_declared_names(body, names: set[str]) -> None:
if not isinstance(body, JsBlockStatement):
return
for node in walk_scope(body):
if isinstance(node, JsVariableDeclaration):
for decl in node.declarations:
if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier):
names.add(decl.id.name)
if isinstance(node, JsFunctionDeclaration) and isinstance(node.id, JsIdentifier):
names.add(node.id.name)
if isinstance(node, JsCatchClause) and isinstance(node.param, JsIdentifier):
names.add(node.param.name)
def _unresolved_names(
func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression,
known_pure: set[str],
) -> set[str]:
"""
Return the set of external names referenced by *func* that are not locally declared, not in
*known_pure*, and not well-known globals or runtime names. Names that are plain-assigned (`=`)
within the function body AND also read within the same body are treated as implicit locals
(obfuscator temporaries like `rr = expr; ... use(rr)`) and excluded. Names that are only
plain-assigned but never read are also excluded (write-only temps). Compound-assigned names
(`+=`, `|=`, etc.) that were NOT also plain-initialized are always retained — they perform a
read-modify-write of the external binding and are never a local temp.
"""
body = func.body
if body is None:
return set()
local_names = {p.name for p in func.params if isinstance(p, JsIdentifier)}
_collect_declared_names(body, local_names)
if isinstance(func, JsFunctionDeclaration) and isinstance(func.id, JsIdentifier):
local_names.add(func.id.name)
elif isinstance(func, JsFunctionExpression) and isinstance(func.id, JsIdentifier):
local_names.add(func.id.name)
plain_assigned: set[str] = set()
compound_assigned: set[str] = set()
read: set[str] = set()
for node in walk_scope(body, include_root_body=True):
if not isinstance(node, JsIdentifier) or not is_reference(node):
continue
name = node.name
if name in local_names:
continue
if isinstance(node.parent, JsAssignmentExpression) and node.parent.left is node:
if node.parent.operator == '=':
plain_assigned.add(name)
else:
compound_assigned.add(name)
read.add(name)
else:
read.add(name)
external_names: set[str] = set()
for name in read - plain_assigned:
if name in known_pure or name in ('undefined', 'NaN', 'Infinity') or is_runtime_name(name):
continue
external_names.add(name)
return external_names
class JsFunctionEvaluator(ScriptLevelTransformer):
"""
Evaluate pure JavaScript functions called with constant arguments, replacing call sites with
computed results. Handles named function calls and IIFEs.
"""
def __init__(self):
super().__init__()
self._script: JsScript | None = None
self._effects: EffectModel | None = None
self._functions: list[_FuncNode] = []
self._pure_nodes: set[int] = set()
self._closure_env: dict[int, dict[str, Value]] = {}
self._call_counts: dict[int, int] = {}
self._resolved_counts: dict[int, int] = {}
self._failed_counts: dict[int, int] = {}
def _process_script(self, node: JsScript) -> None:
self._script = node
self._effects = None
self._functions = []
self._pure_nodes.clear()
self._closure_env.clear()
self._call_counts.clear()
self._resolved_counts.clear()
self._failed_counts.clear()
self._analyze_purity(node)
self._evaluate_calls(node)
self._remove_resolved_definitions(node)
def _collect_named_functions(self, script: JsScript) -> list[_FuncNode]:
"""
Every function the model resolves a name to unambiguously: a function declaration, a
`var`/`let`/`const` declarator initializer, or a hoisted `var` assigned a function exactly once
(`var f; f = function(){}`, the form namespace flattening leaves). This is
`EffectModel.unambiguous_function`, the same filter the interpreter resolves nested callees
through, so every name a call site can reach is a candidate for purity analysis here. Anonymous
functions and names that held a value and were then reassigned are excluded, because the model
resolves no single function for them.
"""
effects = self._effects
if effects is None:
return []
model = effects.model
functions: list[_FuncNode] = []
for node in script.walk():
if not isinstance(node, _FuncNode):
continue
if effects.unambiguous_function(model.invocation_binding(node)) is node:
functions.append(node)
return functions
def _visible_pure_names(self, scope: Scope | None) -> set[str]:
"""
The names that resolve, from *scope* outward, to a function already proven pure. A nearer
binding wins: a name rebound below the scope that holds the pure function — a parameter or a
local — shadows it and is not treated as pure, matching how the name resolves inside the body
being analyzed.
"""
effects = self._effects
if effects is None:
return set()
names: set[str] = set()
seen: set[str] = set()
current = scope
while current is not None:
for name, binding in current.bindings.items():
if name in seen:
continue
seen.add(name)
func = effects.unambiguous_function(binding)
if func is not None and id(func) in self._pure_nodes:
names.add(name)
current = current.parent
return names
def _value_safe_to_capture(self, name: str, value: Value, owner: Node | None) -> bool:
"""
Return whether a `const`-bound *value* can be safely inlined for *name*. A `const` binding is
immutable, so primitive values are always safe. Arrays and objects, however, are mutated in
place even when const-bound, and capture snapshots only the declared initializer — so they
are unsafe if *name* is mutated in place anywhere except inside *owner*. The interpreter
models *owner*'s own mutations (per-call deep copy plus cross-call writeback); mutations by
any other code (a sibling statement, or a different capturing function) are not. When *owner*
is None (const-argument resolution), any in-place mutation makes the value unsafe.
"""
if not isinstance(value, (list, dict)):
return True
script = self._script
if script is None:
return False
for node in script.walk():
if not isinstance(node, JsIdentifier) or node.name != name:
continue
if not is_reference(node) or not _is_inplace_mutation(node):
continue
if owner is not None and node.is_descendant_of(owner):
continue
return False
return True
def _collect_closure_constants(self, func: _FuncNode) -> dict[str, Value]:
child: Node | None
own_declarator: JsVariableDeclarator | None = None
scope_node: Node | None
if isinstance(func, (JsFunctionExpression, JsArrowFunctionExpression)):
declarator = func.parent
if isinstance(declarator, JsVariableDeclarator):
declaration = declarator.parent
if isinstance(declaration, JsVariableDeclaration):
scope_node = declaration.parent
child = declaration
own_declarator = declarator
else:
scope_node = func.parent
child = func
else:
scope_node = func.parent
child = func
else:
scope_node = func.parent
child = func
result: dict[str, Value] = {}
shadowed: set[str] = set()
while scope_node is not None:
if isinstance(scope_node, (JsFunctionDeclaration, JsFunctionExpression, JsArrowFunctionExpression)):
for p in scope_node.params:
if isinstance(p, JsIdentifier):
shadowed.add(p.name)
if isinstance(scope_node, JsCatchClause) and isinstance(scope_node.param, JsIdentifier):
shadowed.add(scope_node.param.name)
if isinstance(scope_node, (JsScript, JsBlockStatement)):
self._collect_hoisted_vars(scope_node, shadowed)
found_child = False
for stmt in scope_node.body:
if stmt is child:
found_child = True
if own_declarator is not None and isinstance(stmt, JsVariableDeclaration):
if stmt.kind == JsVarKind.CONST:
for decl in stmt.declarations:
if decl is own_declarator:
break
if (
not isinstance(decl, JsVariableDeclarator)
or not isinstance(decl.id, JsIdentifier)
):
continue
name = decl.id.name
if name in result or name in shadowed:
continue
init = decl.init
if init is None:
shadowed.add(name)
continue
if isinstance(init, (JsFunctionExpression, JsArrowFunctionExpression)):
result[name] = init
else:
ok, val = extract_literal_value(init)
if ok and self._value_safe_to_capture(name, val, func):
result[name] = val
else:
shadowed.add(name)
own_declarator = None
continue
if not found_child:
if isinstance(stmt, JsVariableDeclaration):
if stmt.kind == JsVarKind.CONST:
for decl in stmt.declarations:
if (
not isinstance(decl, JsVariableDeclarator)
or not isinstance(decl.id, JsIdentifier)
):
continue
name = decl.id.name
if name in result or name in shadowed:
continue
init = decl.init
if init is None:
shadowed.add(name)
continue
if isinstance(init, (JsFunctionExpression, JsArrowFunctionExpression)):
result[name] = init
else:
ok, val = extract_literal_value(init)
if ok and self._value_safe_to_capture(name, val, func):
result[name] = val
else:
shadowed.add(name)
elif stmt.kind == JsVarKind.LET:
for decl in stmt.declarations:
if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier):
shadowed.add(decl.id.name)
elif isinstance(stmt, JsFunctionDeclaration):
if isinstance(stmt.id, JsIdentifier):
shadowed.add(stmt.id.name)
else:
if isinstance(stmt, JsVariableDeclaration) and stmt.kind != JsVarKind.VAR:
for decl in stmt.declarations:
if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier):
shadowed.add(decl.id.name)
child = scope_node
scope_node = scope_node.parent
return result
@staticmethod
def _collect_hoisted_vars(scope_node: Node, shadowed: set[str]) -> None:
"""
Recursively scan a block for `var` declarations and add their names to *shadowed*.
In JavaScript, `var` is hoisted to the enclosing function scope regardless of textual
position or block nesting, so a `var x` anywhere (including inside if/for/while/try)
shadows an outer `const x`.
"""
for node in walk_scope(scope_node):
if isinstance(node, JsVariableDeclaration) and node.kind == JsVarKind.VAR:
for decl in node.declarations:
if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier):
shadowed.add(decl.id.name)
def _analyze_purity(self, script: JsScript) -> None:
self._effects = model_cache(self, script).effects
self._functions = self._collect_named_functions(script)
closure_cache: dict[int, dict[str, Value]] = {
id(func): self._collect_closure_constants(func)
for func in self._functions
if not isinstance(func, JsFunctionDeclaration)
}
changed = True
while changed:
changed = False
for func in self._functions:
if id(func) in self._pure_nodes:
continue
if not self._effects.summary_of(func).is_value_replaceable:
continue
visible_pure = self._visible_pure_names(self._effects.model.function_scope(func))
if _is_value_closed(func, visible_pure):
self._pure_nodes.add(id(func))
changed = True
continue
if (
not isinstance(func, JsFunctionDeclaration)
and func.body is not None
and not references_receiver_this(func.body)
):
unresolved = _unresolved_names(func, visible_pure)
if not unresolved:
self._pure_nodes.add(id(func))
changed = True
continue
closure = closure_cache.get(id(func), {})
if unresolved <= closure.keys():
self._pure_nodes.add(id(func))
self._closure_env[id(func)] = {n: closure[n] for n in unresolved}
changed = True
def _evaluate_calls(self, script: JsScript) -> None:
for node in list(script.walk_in_order()):
if not isinstance(node, JsCallExpression):
continue
if node.callee is None:
continue
callee = strip_parens(node.callee)
if isinstance(callee, JsIdentifier):
self._try_named_call(node)
elif isinstance(callee, (JsFunctionExpression, JsArrowFunctionExpression)):
self._try_iife(node, callee)
def _established_before(self, func: _FuncNode, call: JsCallExpression) -> bool:
"""
Whether *func*'s value is installed before *call* runs. A function declaration is hoisted, so it
is always established; a declarator initializer (`const`/`let`/`var f = function(){}`) is in place
only once its declarator has run, and a lone assignment (`f = function(){}`, the form namespace
flattening leaves) only once that assignment has run. A premature call to either reads a value
that is absent — a temporal dead zone `ReferenceError`, or the hoisted `undefined` a `var` call
throws a `TypeError` on — which the interpreted body must not silently replace with a result. The
model names the establishing nodes; dominance decides whether they all precede the call.
"""
script = self._script
if script is None:
return False
return model_cache(self, script).dominance.established_before(func, call)
def _try_named_call(self, node: JsCallExpression) -> None:
if self._effects is None:
return
func = self._effects.static_callee(node)
if func is None or id(func) not in self._pure_nodes:
return
if node.is_descendant_of(func):
return
if not self._established_before(func, node):
return
func_id = id(func)
self._call_counts[func_id] = self._call_counts.get(func_id, 0) + 1
args = self._extract_constant_args(node.arguments, node)
if args is None:
return
success = self._evaluate_and_replace(node, func, args, gate_unresolved=True)
if success:
self._resolved_counts[func_id] = self._resolved_counts.get(func_id, 0) + 1
else:
self._failed_counts[func_id] = self._failed_counts.get(func_id, 0) + 1
def _try_iife(
self,
node: JsCallExpression,
func: JsFunctionExpression | JsArrowFunctionExpression,
) -> None:
if self._effects is None or not self._effects.summary_of(func).is_value_replaceable:
return
pure_names = self._visible_pure_names(self._effects.model.scope_of(node))
if _is_value_closed(func, pure_names):
args = self._extract_constant_args(node.arguments, node)
if args is None:
return
self._evaluate_and_replace(node, func, args, gate_unresolved=False)
return
if (
func.body is not None
and not references_receiver_this(func.body)
):
unresolved = _unresolved_names(func, pure_names)
closure = self._collect_closure_constants(func)
if unresolved <= closure.keys():
args = self._extract_constant_args(node.arguments, node)
if args is None:
return
closure_env = {n: closure[n] for n in unresolved}
self._evaluate_and_replace(node, func, args, gate_unresolved=False, closure_override=closure_env)
def _evaluate_and_replace(
self,
node: JsCallExpression,
func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression,
args: list,
gate_unresolved: bool,
closure_override: dict | None = None,
) -> bool:
"""
Run the interpreter on *func* with *args* and, on success, replace *node* with the result.
Returns True if the call site was resolved (either to a value or a substituted expression).
"""
closure = closure_override if closure_override is not None else self._closure_env.get(id(func))
interpreter = JsInterpreter(
effects=self._effects,
closure=closure,
closure_env=self._closure_env,
established=lambda callee: self._established_before(callee, node),
)
try:
result = interpreter.execute(func, args)
except IrreducibleExpression as irr:
if gate_unresolved and self._is_unresolved_call(irr.node):
return False
if self._substitution_would_break(irr.node, func, node):
return False
replacement = self._substitute_params_in_clone(irr.node, func, args, self)
_replace_in_parent(node, replacement)
self.mark_changed()
return True
except InterpreterError:
return False
if _contains_jsbuffer(result):
return False
if isinstance(result, list) and len(result) > MAX_RESULT_ARRAY_LEN:
return False
replacement = value_to_node(result)
if replacement is None:
return False
if closure is not None and closure_override is None:
for name in closure:
if name in interpreter._env:
closure[name] = interpreter._env[name]
_replace_in_parent(node, replacement)
self.mark_changed()
return True
def _function_is_removable(self, model: SemanticModel, func: _FuncNode) -> bool:
"""
Whether *func*'s definition can be deleted: it has no surviving static reference and cannot be
reached by a runtime name lookup. A function named inside a `with` body or otherwise reflected
(`model.reflection_can_reach`) must be kept even once every direct call has folded away — the
`with`-body call still needs the binding. This mirrors the reflection gate the unused-code
remover applies, so both transforms keep the same functions.
"""
binding = model.naming_binding(func)
if binding is None:
return False
exclude = self._function_exclude_node(func)
return (
not binding_has_references(model, binding, exclude=exclude)
and not model.reflection_can_reach(binding)
)
def _remove_resolved_definitions(self, script: JsScript) -> None:
removed: set[int] = set()
while True:
model = model_cache(self, script).model
before = len(removed)
for func in self._functions:
func_id = id(func)
if func_id in removed:
continue
call_count = self._call_counts.get(func_id, 0)
if call_count == 0:
continue
resolved = self._resolved_counts.get(func_id, 0)
failed = self._failed_counts.get(func_id, 0)
if (resolved + failed) < call_count:
continue
name = self._function_name(func)
if name is None:
continue
if self._function_is_removable(model, func):
self._remove_function(func)
removed.add(func_id)
self.mark_changed()
for func in self._functions:
func_id = id(func)
if func_id in removed:
continue
if func_id not in self._pure_nodes:
continue
name = self._function_name(func)
if name is None:
continue
call_count = self._call_counts.get(func_id, 0)
if call_count == 0:
continue
if self._function_is_removable(model, func):
self._remove_function(func)
removed.add(func_id)
self.mark_changed()
if len(removed) == before:
break
self._remove_orphaned_closure_constants(script, removed)
@staticmethod
def _function_name(func: _FuncNode) -> str | None:
if isinstance(func, JsFunctionDeclaration):
return func.id.name if isinstance(func.id, JsIdentifier) else None
declarator = func.parent
if isinstance(declarator, JsVariableDeclarator) and isinstance(declarator.id, JsIdentifier):
return declarator.id.name
return None
@staticmethod
def _function_exclude_node(func: _FuncNode) -> Node:
if isinstance(func, JsFunctionDeclaration):
return func
declarator = func.parent
if isinstance(declarator, JsVariableDeclarator):
return declarator
return func
def _remove_function(self, func: _FuncNode) -> None:
if isinstance(func, JsFunctionDeclaration):
_remove_from_parent(func)
else:
declarator = func.parent
if isinstance(declarator, JsVariableDeclarator):
remove_declarator(declarator)
else:
_remove_from_parent(func)
def _remove_orphaned_closure_constants(
self, script: JsScript, removed: set[int],
) -> None:
closure_names: set[str] = set()
for func_id in removed:
env = self._closure_env.get(func_id)
if env:
closure_names.update(env.keys())
if not closure_names:
return
model = model_cache(self, script).model
for node in list(script.walk()):
if not isinstance(node, JsVariableDeclaration) or node.kind != JsVarKind.CONST:
continue
for decl in list(node.declarations):
if not isinstance(decl, JsVariableDeclarator) or not isinstance(decl.id, JsIdentifier):
continue
if decl.id.name not in closure_names:
continue
binding = model.binding_of(decl.id)
if not binding_has_references(model, binding, exclude=decl):
remove_declarator(decl)
self.mark_changed()
def _extract_constant_args(
self,
arguments: list,
call_node: JsCallExpression,
) -> list[Value] | None:
args: list[Value] = []
for arg in arguments:
ok, value = extract_literal_value(arg)
if not ok:
if isinstance(arg, JsIdentifier):
resolved = self._resolve_const_identifier(arg, call_node)
if resolved is not _MISSING:
args.append(resolved) # type: ignore[arg-type]
continue
return None
args.append(value)
return args
def _resolve_const_identifier(self, node: JsIdentifier, context: Node) -> object:
name = node.name
child: Node = context
current = context.parent
while current is not None:
if isinstance(current, (JsFunctionDeclaration, JsFunctionExpression, JsArrowFunctionExpression)):
if any(isinstance(p, JsIdentifier) and p.name == name for p in current.params):
return _MISSING
if isinstance(current, JsCatchClause) and isinstance(current.param, JsIdentifier):
if current.param.name == name:
return _MISSING
if isinstance(current, (JsForOfStatement, JsForInStatement)):
if self._decl_binds_name(current.left, name):
return _MISSING
if isinstance(current, JsForStatement):
if self._decl_binds_name(current.init, name):
return _MISSING
if isinstance(current, (JsScript, JsBlockStatement)):
body = current.body
if self._has_hoisted_var(current, name):
return _MISSING
for stmt in body:
if isinstance(stmt, JsVariableDeclaration):
if stmt.kind == JsVarKind.CONST:
for decl in stmt.declarations:
if context.is_descendant_of(decl):
break
if (
isinstance(decl, JsVariableDeclarator)
and isinstance(decl.id, JsIdentifier)
and decl.id.name == name
and decl.init is not None
):
ok, val = extract_literal_value(decl.init)
if ok and self._value_safe_to_capture(name, val, None):
return val
return _MISSING
elif stmt.kind == JsVarKind.LET:
for decl in stmt.declarations:
if (
isinstance(decl, JsVariableDeclarator)
and isinstance(decl.id, JsIdentifier)
and decl.id.name == name
):
return _MISSING
elif isinstance(stmt, JsFunctionDeclaration):
if isinstance(stmt.id, JsIdentifier) and stmt.id.name == name:
return _MISSING
if stmt is child:
break
child = current
current = current.parent
return _MISSING
@staticmethod
def _has_hoisted_var(scope_node: Node, name: str) -> bool:
"""
Return whether *scope_node* contains any `var` declaration for *name* at any nesting depth.
"""
for node in walk_scope(scope_node):
if isinstance(node, JsVariableDeclaration) and node.kind == JsVarKind.VAR:
for decl in node.declarations:
if (
isinstance(decl, JsVariableDeclarator)
and isinstance(decl.id, JsIdentifier)
and decl.id.name == name
):
return True
return False
@staticmethod
def _decl_binds_name(node: Node | None, name: str) -> bool:
"""
Return whether *node* is a `refinery.lib.scripts.js.model.JsVariableDeclaration` (e.g. a
`for`/`for-of`/`for-in` loop header) that declares *name*. A bare identifier loop target
assigns to an outer binding and does not shadow, so it is not treated as a binding here.
"""
if not isinstance(node, JsVariableDeclaration):
return False
for decl in node.declarations:
if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier):
if decl.id.name == name:
return True
return False
def _is_unresolved_call(self, node: Node) -> bool:
"""
Check whether the irreducible expression contains any function call. If so, the wrapper
inliner or string-array resolver should handle it — the evaluator should not substitute
parameters into a call that it couldn't fully evaluate.
"""
for child in node.walk():
if isinstance(child, JsCallExpression):
return True
return False
def _substitution_would_break(self, node: Node, func: _FuncNode, call_site: Node) -> bool:
"""
Whether splicing *node* — an irreducible sub-expression of *func*'s body — into *call_site* by
parameter substitution would change behavior. Substitution replaces each parameter with its
original argument value and discards the rest of the body, so it is unsafe when *node*:
- writes a parameter, which would place the argument value at a write target (`delete p`
becomes `delete <literal>`, `(p = 5)` becomes `(<literal> = 5)`);
- reads a parameter that *func* reassigns — statically, or through a `with` body or direct
`eval` — whose value at the irreducible point is no longer the original argument the
substitution would supply;
- references a name bound inside *func* but declared outside *node* — a body local (including a
destructured or `catch` binding), a nested declaration, `arguments`, or the function
expression's own name — which has no binding once the body is discarded, leaving a dangling
reference;
- references a name that resolves outside *func*, or to no binding at all, but which a
same-named local in scope at *call_site* would recapture, so the spliced reference would bind
to a different declaration there than it does in *func*.
A binding *node* itself introduces travels with it and stays intact. Every reference is resolved
through the model, so shadowing and destructuring are exact; a free name whose read crosses a
`with` in *func* is treated as unsafe, since its runtime target cannot be matched at *call_site*.
"""
script = self._script
if script is None:
return True
model = model_cache(self, script).model
call_scope = model.scope_of(call_site)
if call_scope is None:
return True
param_bindings = {
binding
for param in func.params
for ident in pattern_identifiers(param)
if (binding := model.binding_of(ident)) is not None
}
for child in node.walk():
if not isinstance(child, JsIdentifier) or not model.is_reference(child):
continue
binding = model.resolve(child)
if binding is None:
if model.read_has_dynamic_effect(child):
return True
if model.lookup(child.name, call_scope) is not None:
return True
continue
owner = binding.scope.node
if owner is not func and not owner.is_descendant_of(func):
if model.lookup(child.name, call_scope) is not binding:
return True
continue
if binding in param_bindings:
if not model.binding_never_reassigned(binding):
return True
continue
if owner is node or owner.is_descendant_of(node):
continue
return True
return False
@staticmethod
def _substitute_params_in_clone(
node: Node,
func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression,
args: list[Value],
transformer: Transformer,
) -> Node:
params: list[Node] = []
arguments: list[Node] = []
for i, p in enumerate(func.params):
if not isinstance(p, JsIdentifier):
continue
arg_node = value_to_node(args[i] if i < len(args) else None)
if arg_node is None:
continue
params.append(p)
arguments.append(arg_node)
return substitute_params(node, params, arguments, transformer=transformer)
Classes
class JsFunctionEvaluator-
Evaluate pure JavaScript functions called with constant arguments, replacing call sites with computed results. Handles named function calls and IIFEs.
Expand source code Browse git
class JsFunctionEvaluator(ScriptLevelTransformer): """ Evaluate pure JavaScript functions called with constant arguments, replacing call sites with computed results. Handles named function calls and IIFEs. """ def __init__(self): super().__init__() self._script: JsScript | None = None self._effects: EffectModel | None = None self._functions: list[_FuncNode] = [] self._pure_nodes: set[int] = set() self._closure_env: dict[int, dict[str, Value]] = {} self._call_counts: dict[int, int] = {} self._resolved_counts: dict[int, int] = {} self._failed_counts: dict[int, int] = {} def _process_script(self, node: JsScript) -> None: self._script = node self._effects = None self._functions = [] self._pure_nodes.clear() self._closure_env.clear() self._call_counts.clear() self._resolved_counts.clear() self._failed_counts.clear() self._analyze_purity(node) self._evaluate_calls(node) self._remove_resolved_definitions(node) def _collect_named_functions(self, script: JsScript) -> list[_FuncNode]: """ Every function the model resolves a name to unambiguously: a function declaration, a `var`/`let`/`const` declarator initializer, or a hoisted `var` assigned a function exactly once (`var f; f = function(){}`, the form namespace flattening leaves). This is `EffectModel.unambiguous_function`, the same filter the interpreter resolves nested callees through, so every name a call site can reach is a candidate for purity analysis here. Anonymous functions and names that held a value and were then reassigned are excluded, because the model resolves no single function for them. """ effects = self._effects if effects is None: return [] model = effects.model functions: list[_FuncNode] = [] for node in script.walk(): if not isinstance(node, _FuncNode): continue if effects.unambiguous_function(model.invocation_binding(node)) is node: functions.append(node) return functions def _visible_pure_names(self, scope: Scope | None) -> set[str]: """ The names that resolve, from *scope* outward, to a function already proven pure. A nearer binding wins: a name rebound below the scope that holds the pure function — a parameter or a local — shadows it and is not treated as pure, matching how the name resolves inside the body being analyzed. """ effects = self._effects if effects is None: return set() names: set[str] = set() seen: set[str] = set() current = scope while current is not None: for name, binding in current.bindings.items(): if name in seen: continue seen.add(name) func = effects.unambiguous_function(binding) if func is not None and id(func) in self._pure_nodes: names.add(name) current = current.parent return names def _value_safe_to_capture(self, name: str, value: Value, owner: Node | None) -> bool: """ Return whether a `const`-bound *value* can be safely inlined for *name*. A `const` binding is immutable, so primitive values are always safe. Arrays and objects, however, are mutated in place even when const-bound, and capture snapshots only the declared initializer — so they are unsafe if *name* is mutated in place anywhere except inside *owner*. The interpreter models *owner*'s own mutations (per-call deep copy plus cross-call writeback); mutations by any other code (a sibling statement, or a different capturing function) are not. When *owner* is None (const-argument resolution), any in-place mutation makes the value unsafe. """ if not isinstance(value, (list, dict)): return True script = self._script if script is None: return False for node in script.walk(): if not isinstance(node, JsIdentifier) or node.name != name: continue if not is_reference(node) or not _is_inplace_mutation(node): continue if owner is not None and node.is_descendant_of(owner): continue return False return True def _collect_closure_constants(self, func: _FuncNode) -> dict[str, Value]: child: Node | None own_declarator: JsVariableDeclarator | None = None scope_node: Node | None if isinstance(func, (JsFunctionExpression, JsArrowFunctionExpression)): declarator = func.parent if isinstance(declarator, JsVariableDeclarator): declaration = declarator.parent if isinstance(declaration, JsVariableDeclaration): scope_node = declaration.parent child = declaration own_declarator = declarator else: scope_node = func.parent child = func else: scope_node = func.parent child = func else: scope_node = func.parent child = func result: dict[str, Value] = {} shadowed: set[str] = set() while scope_node is not None: if isinstance(scope_node, (JsFunctionDeclaration, JsFunctionExpression, JsArrowFunctionExpression)): for p in scope_node.params: if isinstance(p, JsIdentifier): shadowed.add(p.name) if isinstance(scope_node, JsCatchClause) and isinstance(scope_node.param, JsIdentifier): shadowed.add(scope_node.param.name) if isinstance(scope_node, (JsScript, JsBlockStatement)): self._collect_hoisted_vars(scope_node, shadowed) found_child = False for stmt in scope_node.body: if stmt is child: found_child = True if own_declarator is not None and isinstance(stmt, JsVariableDeclaration): if stmt.kind == JsVarKind.CONST: for decl in stmt.declarations: if decl is own_declarator: break if ( not isinstance(decl, JsVariableDeclarator) or not isinstance(decl.id, JsIdentifier) ): continue name = decl.id.name if name in result or name in shadowed: continue init = decl.init if init is None: shadowed.add(name) continue if isinstance(init, (JsFunctionExpression, JsArrowFunctionExpression)): result[name] = init else: ok, val = extract_literal_value(init) if ok and self._value_safe_to_capture(name, val, func): result[name] = val else: shadowed.add(name) own_declarator = None continue if not found_child: if isinstance(stmt, JsVariableDeclaration): if stmt.kind == JsVarKind.CONST: for decl in stmt.declarations: if ( not isinstance(decl, JsVariableDeclarator) or not isinstance(decl.id, JsIdentifier) ): continue name = decl.id.name if name in result or name in shadowed: continue init = decl.init if init is None: shadowed.add(name) continue if isinstance(init, (JsFunctionExpression, JsArrowFunctionExpression)): result[name] = init else: ok, val = extract_literal_value(init) if ok and self._value_safe_to_capture(name, val, func): result[name] = val else: shadowed.add(name) elif stmt.kind == JsVarKind.LET: for decl in stmt.declarations: if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier): shadowed.add(decl.id.name) elif isinstance(stmt, JsFunctionDeclaration): if isinstance(stmt.id, JsIdentifier): shadowed.add(stmt.id.name) else: if isinstance(stmt, JsVariableDeclaration) and stmt.kind != JsVarKind.VAR: for decl in stmt.declarations: if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier): shadowed.add(decl.id.name) child = scope_node scope_node = scope_node.parent return result @staticmethod def _collect_hoisted_vars(scope_node: Node, shadowed: set[str]) -> None: """ Recursively scan a block for `var` declarations and add their names to *shadowed*. In JavaScript, `var` is hoisted to the enclosing function scope regardless of textual position or block nesting, so a `var x` anywhere (including inside if/for/while/try) shadows an outer `const x`. """ for node in walk_scope(scope_node): if isinstance(node, JsVariableDeclaration) and node.kind == JsVarKind.VAR: for decl in node.declarations: if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier): shadowed.add(decl.id.name) def _analyze_purity(self, script: JsScript) -> None: self._effects = model_cache(self, script).effects self._functions = self._collect_named_functions(script) closure_cache: dict[int, dict[str, Value]] = { id(func): self._collect_closure_constants(func) for func in self._functions if not isinstance(func, JsFunctionDeclaration) } changed = True while changed: changed = False for func in self._functions: if id(func) in self._pure_nodes: continue if not self._effects.summary_of(func).is_value_replaceable: continue visible_pure = self._visible_pure_names(self._effects.model.function_scope(func)) if _is_value_closed(func, visible_pure): self._pure_nodes.add(id(func)) changed = True continue if ( not isinstance(func, JsFunctionDeclaration) and func.body is not None and not references_receiver_this(func.body) ): unresolved = _unresolved_names(func, visible_pure) if not unresolved: self._pure_nodes.add(id(func)) changed = True continue closure = closure_cache.get(id(func), {}) if unresolved <= closure.keys(): self._pure_nodes.add(id(func)) self._closure_env[id(func)] = {n: closure[n] for n in unresolved} changed = True def _evaluate_calls(self, script: JsScript) -> None: for node in list(script.walk_in_order()): if not isinstance(node, JsCallExpression): continue if node.callee is None: continue callee = strip_parens(node.callee) if isinstance(callee, JsIdentifier): self._try_named_call(node) elif isinstance(callee, (JsFunctionExpression, JsArrowFunctionExpression)): self._try_iife(node, callee) def _established_before(self, func: _FuncNode, call: JsCallExpression) -> bool: """ Whether *func*'s value is installed before *call* runs. A function declaration is hoisted, so it is always established; a declarator initializer (`const`/`let`/`var f = function(){}`) is in place only once its declarator has run, and a lone assignment (`f = function(){}`, the form namespace flattening leaves) only once that assignment has run. A premature call to either reads a value that is absent — a temporal dead zone `ReferenceError`, or the hoisted `undefined` a `var` call throws a `TypeError` on — which the interpreted body must not silently replace with a result. The model names the establishing nodes; dominance decides whether they all precede the call. """ script = self._script if script is None: return False return model_cache(self, script).dominance.established_before(func, call) def _try_named_call(self, node: JsCallExpression) -> None: if self._effects is None: return func = self._effects.static_callee(node) if func is None or id(func) not in self._pure_nodes: return if node.is_descendant_of(func): return if not self._established_before(func, node): return func_id = id(func) self._call_counts[func_id] = self._call_counts.get(func_id, 0) + 1 args = self._extract_constant_args(node.arguments, node) if args is None: return success = self._evaluate_and_replace(node, func, args, gate_unresolved=True) if success: self._resolved_counts[func_id] = self._resolved_counts.get(func_id, 0) + 1 else: self._failed_counts[func_id] = self._failed_counts.get(func_id, 0) + 1 def _try_iife( self, node: JsCallExpression, func: JsFunctionExpression | JsArrowFunctionExpression, ) -> None: if self._effects is None or not self._effects.summary_of(func).is_value_replaceable: return pure_names = self._visible_pure_names(self._effects.model.scope_of(node)) if _is_value_closed(func, pure_names): args = self._extract_constant_args(node.arguments, node) if args is None: return self._evaluate_and_replace(node, func, args, gate_unresolved=False) return if ( func.body is not None and not references_receiver_this(func.body) ): unresolved = _unresolved_names(func, pure_names) closure = self._collect_closure_constants(func) if unresolved <= closure.keys(): args = self._extract_constant_args(node.arguments, node) if args is None: return closure_env = {n: closure[n] for n in unresolved} self._evaluate_and_replace(node, func, args, gate_unresolved=False, closure_override=closure_env) def _evaluate_and_replace( self, node: JsCallExpression, func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression, args: list, gate_unresolved: bool, closure_override: dict | None = None, ) -> bool: """ Run the interpreter on *func* with *args* and, on success, replace *node* with the result. Returns True if the call site was resolved (either to a value or a substituted expression). """ closure = closure_override if closure_override is not None else self._closure_env.get(id(func)) interpreter = JsInterpreter( effects=self._effects, closure=closure, closure_env=self._closure_env, established=lambda callee: self._established_before(callee, node), ) try: result = interpreter.execute(func, args) except IrreducibleExpression as irr: if gate_unresolved and self._is_unresolved_call(irr.node): return False if self._substitution_would_break(irr.node, func, node): return False replacement = self._substitute_params_in_clone(irr.node, func, args, self) _replace_in_parent(node, replacement) self.mark_changed() return True except InterpreterError: return False if _contains_jsbuffer(result): return False if isinstance(result, list) and len(result) > MAX_RESULT_ARRAY_LEN: return False replacement = value_to_node(result) if replacement is None: return False if closure is not None and closure_override is None: for name in closure: if name in interpreter._env: closure[name] = interpreter._env[name] _replace_in_parent(node, replacement) self.mark_changed() return True def _function_is_removable(self, model: SemanticModel, func: _FuncNode) -> bool: """ Whether *func*'s definition can be deleted: it has no surviving static reference and cannot be reached by a runtime name lookup. A function named inside a `with` body or otherwise reflected (`model.reflection_can_reach`) must be kept even once every direct call has folded away — the `with`-body call still needs the binding. This mirrors the reflection gate the unused-code remover applies, so both transforms keep the same functions. """ binding = model.naming_binding(func) if binding is None: return False exclude = self._function_exclude_node(func) return ( not binding_has_references(model, binding, exclude=exclude) and not model.reflection_can_reach(binding) ) def _remove_resolved_definitions(self, script: JsScript) -> None: removed: set[int] = set() while True: model = model_cache(self, script).model before = len(removed) for func in self._functions: func_id = id(func) if func_id in removed: continue call_count = self._call_counts.get(func_id, 0) if call_count == 0: continue resolved = self._resolved_counts.get(func_id, 0) failed = self._failed_counts.get(func_id, 0) if (resolved + failed) < call_count: continue name = self._function_name(func) if name is None: continue if self._function_is_removable(model, func): self._remove_function(func) removed.add(func_id) self.mark_changed() for func in self._functions: func_id = id(func) if func_id in removed: continue if func_id not in self._pure_nodes: continue name = self._function_name(func) if name is None: continue call_count = self._call_counts.get(func_id, 0) if call_count == 0: continue if self._function_is_removable(model, func): self._remove_function(func) removed.add(func_id) self.mark_changed() if len(removed) == before: break self._remove_orphaned_closure_constants(script, removed) @staticmethod def _function_name(func: _FuncNode) -> str | None: if isinstance(func, JsFunctionDeclaration): return func.id.name if isinstance(func.id, JsIdentifier) else None declarator = func.parent if isinstance(declarator, JsVariableDeclarator) and isinstance(declarator.id, JsIdentifier): return declarator.id.name return None @staticmethod def _function_exclude_node(func: _FuncNode) -> Node: if isinstance(func, JsFunctionDeclaration): return func declarator = func.parent if isinstance(declarator, JsVariableDeclarator): return declarator return func def _remove_function(self, func: _FuncNode) -> None: if isinstance(func, JsFunctionDeclaration): _remove_from_parent(func) else: declarator = func.parent if isinstance(declarator, JsVariableDeclarator): remove_declarator(declarator) else: _remove_from_parent(func) def _remove_orphaned_closure_constants( self, script: JsScript, removed: set[int], ) -> None: closure_names: set[str] = set() for func_id in removed: env = self._closure_env.get(func_id) if env: closure_names.update(env.keys()) if not closure_names: return model = model_cache(self, script).model for node in list(script.walk()): if not isinstance(node, JsVariableDeclaration) or node.kind != JsVarKind.CONST: continue for decl in list(node.declarations): if not isinstance(decl, JsVariableDeclarator) or not isinstance(decl.id, JsIdentifier): continue if decl.id.name not in closure_names: continue binding = model.binding_of(decl.id) if not binding_has_references(model, binding, exclude=decl): remove_declarator(decl) self.mark_changed() def _extract_constant_args( self, arguments: list, call_node: JsCallExpression, ) -> list[Value] | None: args: list[Value] = [] for arg in arguments: ok, value = extract_literal_value(arg) if not ok: if isinstance(arg, JsIdentifier): resolved = self._resolve_const_identifier(arg, call_node) if resolved is not _MISSING: args.append(resolved) # type: ignore[arg-type] continue return None args.append(value) return args def _resolve_const_identifier(self, node: JsIdentifier, context: Node) -> object: name = node.name child: Node = context current = context.parent while current is not None: if isinstance(current, (JsFunctionDeclaration, JsFunctionExpression, JsArrowFunctionExpression)): if any(isinstance(p, JsIdentifier) and p.name == name for p in current.params): return _MISSING if isinstance(current, JsCatchClause) and isinstance(current.param, JsIdentifier): if current.param.name == name: return _MISSING if isinstance(current, (JsForOfStatement, JsForInStatement)): if self._decl_binds_name(current.left, name): return _MISSING if isinstance(current, JsForStatement): if self._decl_binds_name(current.init, name): return _MISSING if isinstance(current, (JsScript, JsBlockStatement)): body = current.body if self._has_hoisted_var(current, name): return _MISSING for stmt in body: if isinstance(stmt, JsVariableDeclaration): if stmt.kind == JsVarKind.CONST: for decl in stmt.declarations: if context.is_descendant_of(decl): break if ( isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier) and decl.id.name == name and decl.init is not None ): ok, val = extract_literal_value(decl.init) if ok and self._value_safe_to_capture(name, val, None): return val return _MISSING elif stmt.kind == JsVarKind.LET: for decl in stmt.declarations: if ( isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier) and decl.id.name == name ): return _MISSING elif isinstance(stmt, JsFunctionDeclaration): if isinstance(stmt.id, JsIdentifier) and stmt.id.name == name: return _MISSING if stmt is child: break child = current current = current.parent return _MISSING @staticmethod def _has_hoisted_var(scope_node: Node, name: str) -> bool: """ Return whether *scope_node* contains any `var` declaration for *name* at any nesting depth. """ for node in walk_scope(scope_node): if isinstance(node, JsVariableDeclaration) and node.kind == JsVarKind.VAR: for decl in node.declarations: if ( isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier) and decl.id.name == name ): return True return False @staticmethod def _decl_binds_name(node: Node | None, name: str) -> bool: """ Return whether *node* is a `refinery.lib.scripts.js.model.JsVariableDeclaration` (e.g. a `for`/`for-of`/`for-in` loop header) that declares *name*. A bare identifier loop target assigns to an outer binding and does not shadow, so it is not treated as a binding here. """ if not isinstance(node, JsVariableDeclaration): return False for decl in node.declarations: if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier): if decl.id.name == name: return True return False def _is_unresolved_call(self, node: Node) -> bool: """ Check whether the irreducible expression contains any function call. If so, the wrapper inliner or string-array resolver should handle it — the evaluator should not substitute parameters into a call that it couldn't fully evaluate. """ for child in node.walk(): if isinstance(child, JsCallExpression): return True return False def _substitution_would_break(self, node: Node, func: _FuncNode, call_site: Node) -> bool: """ Whether splicing *node* — an irreducible sub-expression of *func*'s body — into *call_site* by parameter substitution would change behavior. Substitution replaces each parameter with its original argument value and discards the rest of the body, so it is unsafe when *node*: - writes a parameter, which would place the argument value at a write target (`delete p` becomes `delete <literal>`, `(p = 5)` becomes `(<literal> = 5)`); - reads a parameter that *func* reassigns — statically, or through a `with` body or direct `eval` — whose value at the irreducible point is no longer the original argument the substitution would supply; - references a name bound inside *func* but declared outside *node* — a body local (including a destructured or `catch` binding), a nested declaration, `arguments`, or the function expression's own name — which has no binding once the body is discarded, leaving a dangling reference; - references a name that resolves outside *func*, or to no binding at all, but which a same-named local in scope at *call_site* would recapture, so the spliced reference would bind to a different declaration there than it does in *func*. A binding *node* itself introduces travels with it and stays intact. Every reference is resolved through the model, so shadowing and destructuring are exact; a free name whose read crosses a `with` in *func* is treated as unsafe, since its runtime target cannot be matched at *call_site*. """ script = self._script if script is None: return True model = model_cache(self, script).model call_scope = model.scope_of(call_site) if call_scope is None: return True param_bindings = { binding for param in func.params for ident in pattern_identifiers(param) if (binding := model.binding_of(ident)) is not None } for child in node.walk(): if not isinstance(child, JsIdentifier) or not model.is_reference(child): continue binding = model.resolve(child) if binding is None: if model.read_has_dynamic_effect(child): return True if model.lookup(child.name, call_scope) is not None: return True continue owner = binding.scope.node if owner is not func and not owner.is_descendant_of(func): if model.lookup(child.name, call_scope) is not binding: return True continue if binding in param_bindings: if not model.binding_never_reassigned(binding): return True continue if owner is node or owner.is_descendant_of(node): continue return True return False @staticmethod def _substitute_params_in_clone( node: Node, func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression, args: list[Value], transformer: Transformer, ) -> Node: params: list[Node] = [] arguments: list[Node] = [] for i, p in enumerate(func.params): if not isinstance(p, JsIdentifier): continue arg_node = value_to_node(args[i] if i < len(args) else None) if arg_node is None: continue params.append(p) arguments.append(arg_node) return substitute_params(node, params, arguments, transformer=transformer)Ancestors
Inherited members