Module refinery.lib.scripts.ps1.deobfuscation.deadcode
Eliminate dead code from PowerShell scripts after constant folding.
Expand source code Browse git
"""
Eliminate dead code from PowerShell scripts after constant folding.
"""
from __future__ import annotations
from refinery.lib.scripts import Block, Expression, Node, Statement, Transformer
from refinery.lib.scripts.ps1.deobfuscation.data import COMPARISON_OPS, KNOWN_CMDLETS
from refinery.lib.scripts.ps1.deobfuscation.helpers import (
BodyRole,
classify_body,
get_body,
is_builtin_variable,
is_truthy,
switch_matches,
unwrap_integer,
unwrap_parens,
)
from refinery.lib.scripts.ps1.deobfuscation.purity import (
is_side_effect_free,
)
from refinery.lib.scripts.ps1.model import (
Ps1AssignmentExpression,
Ps1BinaryExpression,
Ps1BreakStatement,
Ps1CommandArgument,
Ps1CommandInvocation,
Ps1ContinueStatement,
Ps1DoLoop,
Ps1ExpressionStatement,
Ps1ForLoop,
Ps1IfStatement,
Ps1IntegerLiteral,
Ps1ParenExpression,
Ps1RealLiteral,
Ps1ScopeModifier,
Ps1Script,
Ps1StringLiteral,
Ps1SwitchStatement,
Ps1TrapStatement,
Ps1TryCatchFinally,
Ps1UnaryExpression,
Ps1Variable,
Ps1WhileLoop,
)
_PATH_EXTENSIONS = frozenset({'.exe', '.ps1', '.cmd', '.bat', '.com', '.vbs', '.msi'})
def _is_unresolvable_command(expr: Expression) -> bool:
"""
Return `True` when `expr` is a command invocation of an unknown bareword whose arguments are all
side-effect-free. Such an invocation will throw `CommandNotFoundException` at runtime — no side
effect precedes the throw, and if it somehow resolved, the discarded result is harmless. This
predicate is intentionally narrow: only bareword string-literal names that do not match any
known cmdlet or alias and do not look like a filesystem path.
"""
if not isinstance(expr, Ps1CommandInvocation):
return False
if not isinstance(expr.name, Ps1StringLiteral):
return False
name = expr.name.value
name_lower = name.lower()
if name_lower in KNOWN_CMDLETS:
return False
if any(sep in name for sep in ('\\', '/', ':')):
return False
if any(name_lower.endswith(ext) for ext in _PATH_EXTENSIONS):
return False
if name.startswith('.') or name.startswith('~'):
return False
for arg in expr.arguments:
value = arg.value if isinstance(arg, Ps1CommandArgument) else arg
if value is not None and not is_side_effect_free(value):
return False
return True
def _try_body_is_harmless(body: list[Statement]) -> bool:
"""
Return `True` when every statement in a try body is guaranteed to produce no observable side
effects whether it succeeds or throws. This covers pure expressions (value discarded) and
unresolvable bareword commands (throw `CommandNotFoundException` with no preceding side effect).
"""
for stmt in body:
if not isinstance(stmt, Ps1ExpressionStatement):
return False
if stmt.expression is None:
continue
if is_side_effect_free(stmt.expression):
continue
if _is_unresolvable_command(stmt.expression):
continue
return False
return True
def _evaluate_for_condition(node: Ps1ForLoop) -> bool | None:
"""
Try to evaluate a for-loop condition at loop entry by substituting the initial value of the
loop variable into the comparison. Returns the boolean result, or `None` if the pattern does not
match.
"""
init = node.initializer
cond = node.condition
if not isinstance(init, Ps1AssignmentExpression) or init.operator != '=':
return None
if not isinstance(init.target, Ps1Variable):
return None
init_val = unwrap_integer(init.value)
if init_val is None:
return None
if not isinstance(cond, Ps1BinaryExpression):
return None
op_fn = COMPARISON_OPS.get(cond.operator.lower())
if op_fn is None:
return None
var_name = init.target.name.lower()
var_scope = init.target.scope
left_val = _resolve_side(cond.left, var_name, var_scope, init_val.value)
right_val = _resolve_side(cond.right, var_name, var_scope, init_val.value)
if left_val is None or right_val is None:
return None
return bool(op_fn(left_val, right_val))
def _resolve_side(
node, var_name: str, var_scope: Ps1ScopeModifier, init_val: int,
) -> int | None:
"""
Resolve one side of a for-loop condition to an integer: if the node is the loop variable,
return the initial value; if it is a constant integer, return that; otherwise return `None`.
"""
node = unwrap_parens(node) if isinstance(node, Expression) else node
if (
isinstance(node, Ps1Variable)
and node.name.lower() == var_name
and node.scope == var_scope
):
return init_val
result = unwrap_integer(node)
return result.value if result is not None else None
def _make_int_literal(value: int) -> Ps1IntegerLiteral:
return Ps1IntegerLiteral(value=value, raw=str(value))
def _is_counter_variable(node, var_name: str, var_scope: Ps1ScopeModifier) -> bool:
node = unwrap_parens(node) if isinstance(node, Expression) else node
return (
isinstance(node, Ps1Variable)
and node.name.lower() == var_name
and node.scope == var_scope
)
def _counter_delta(iterator, var_name: str, var_scope: Ps1ScopeModifier) -> int | None:
"""
Return the constant per-iteration change a for-loop iterator applies to the loop variable, or
`None` when the iterator is not a nonzero constant step on that single variable (`$i++`, `$i--`,
`$i += k`, `$i -= k`).
"""
if isinstance(iterator, Ps1UnaryExpression) and iterator.operator in ('++', '--'):
if _is_counter_variable(iterator.operand, var_name, var_scope):
return 1 if iterator.operator == '++' else -1
return None
if isinstance(iterator, Ps1AssignmentExpression) and iterator.operator in ('+=', '-='):
if not _is_counter_variable(iterator.target, var_name, var_scope):
return None
step = unwrap_integer(iterator.value)
if step is None:
return None
delta = step.value if iterator.operator == '+=' else -step.value
return delta or None
return None
def _counter_condition(cond, var_name: str, var_scope: Ps1ScopeModifier):
"""
Return `(predicate, bound)` where `predicate` maps an integer loop-variable value to the truth
of the for-loop condition and `bound` is the constant it is compared against, or `None` when the
condition is not a comparison between the loop variable and a constant integer (`$i <cmp> C` or
`C <cmp> $i`). The bound lets the caller size a simulation cap to the loop's real trip count.
"""
if not isinstance(cond, Ps1BinaryExpression):
return None
op_fn = COMPARISON_OPS.get(cond.operator.lower())
if op_fn is None:
return None
left_int = unwrap_integer(cond.left)
right_int = unwrap_integer(cond.right)
if _is_counter_variable(cond.left, var_name, var_scope) and right_int is not None:
bound = right_int.value
return (lambda value: bool(op_fn(value, bound))), bound
if _is_counter_variable(cond.right, var_name, var_scope) and left_int is not None:
bound = left_int.value
return (lambda value: bool(op_fn(bound, value))), bound
return None
def _simulate_empty_for_terminal(node: Ps1ForLoop) -> tuple[Ps1Variable, int] | None:
"""
For an empty-bodied `for` loop driven by a single integer counter, return `(variable, terminal)`
giving the value the counter holds once the loop exits, or `None` when the loop is not a
provably-terminating linear counter (non-constant initializer/bound, `for (;;)`, a
non-constant-step iterator, or a condition that never turns false). The counter is stepped
exactly as PowerShell evaluates the loop — check the condition, then apply the iterator — so the
terminal value is exact, including the zero-iteration case where the counter keeps its initial
value.
"""
init = node.initializer
if not isinstance(init, Ps1AssignmentExpression) or init.operator != '=':
return None
if not isinstance(init.target, Ps1Variable):
return None
init_int = unwrap_integer(init.value)
if init_int is None:
return None
variable = init.target
var_name = variable.name.lower()
var_scope = variable.scope
delta = _counter_delta(node.iterator, var_name, var_scope)
if delta is None:
return None
condition = _counter_condition(node.condition, var_name, var_scope)
if condition is None:
return None
predicate, bound = condition
# A terminating linear counter reaches the bound within `distance / |step|` iterations; a couple
# extra guard against off-by-one and the exact-hit (`-ne`/`-eq`) cases. Exceeding this proves the
# condition never turns false (a wrong-direction step), so the loop is infinite and left intact.
# The absolute cap prevents pathological samples (e.g. bound = 2 billion) from hanging the pass.
cap = min(abs(bound - init_int.value) // abs(delta) + 2, 100_000)
value = init_int.value
iterations = 0
while predicate(value):
value += delta
iterations += 1
if iterations > cap:
return None
return variable, value
def _body_breaks_unconditionally(body: list[Statement]) -> bool:
"""
Return `True` if the last statement in the body is an unlabeled break and the body contains no
continue statements at any nesting depth. Such a loop body executes exactly once.
"""
if not body:
return False
last = body[-1]
if not isinstance(last, Ps1BreakStatement) or last.label is not None:
return False
for stmt in body[:-1]:
for node in stmt.walk():
if isinstance(node, (Ps1BreakStatement, Ps1ContinueStatement)):
return False
return True
_NO_LITERAL = object()
def _switch_literal(node):
"""
Extract the constant `int`/`str`/`bool` value a switch value or clause condition compares with,
or `_NO_LITERAL` when it is not a compile-time constant.
"""
node = unwrap_parens(node)
if isinstance(node, (Ps1IntegerLiteral, Ps1RealLiteral, Ps1StringLiteral)):
return node.value
if is_builtin_variable(node, {'true'}):
return True
if is_builtin_variable(node, {'false'}):
return False
return _NO_LITERAL
def _switch_clause_body(body: list[Statement]) -> tuple[list[Statement], bool] | None:
"""
Return the statements of a matched switch clause together with a flag indicating whether the
clause terminates the switch (a trailing `break`). Returns `None` when the body contains a
top-level `break`/`continue` that is not a single trailing `break`, since inlining it would
retarget the jump to an enclosing loop.
"""
stmts = list(body)
stop = False
if stmts and isinstance(stmts[-1], Ps1BreakStatement):
stmts = stmts[:-1]
stop = True
for stmt in stmts:
if isinstance(stmt, (Ps1BreakStatement, Ps1ContinueStatement)):
return None
return stmts, stop
def _is_pure_constant(node) -> bool:
"""
Return `True` when an expression is a side-effect-free constant that can be removed as a
standalone statement. Only matches numeric literals and the built-in constants `$Null`,
`$True`, and `$False` — string literals are excluded because they may represent intentional
pipeline output.
"""
if isinstance(node, (Ps1IntegerLiteral, Ps1RealLiteral)):
return True
if is_builtin_variable(node):
return True
if isinstance(node, Ps1ParenExpression):
return _is_pure_constant(node.expression)
if isinstance(node, Ps1UnaryExpression) and node.operator in ('+', '-'):
return _is_pure_constant(node.operand)
return False
class Ps1DeadCodeElimination(Transformer):
"""
Remove unreachable code guarded by constant boolean conditions and resolve switch statements
on constant values.
"""
def visit(self, node: Node):
for parent in list(node.walk()):
role = classify_body(parent)
if role is None or role is BodyRole.OPAQUE:
continue
body = get_body(parent)
new_body = self._prune_body(body, role, isinstance(parent, Ps1Script))
if new_body is not body:
body.clear()
body.extend(new_body)
for stmt in new_body:
stmt.parent = parent
self.mark_changed()
def _prune_body(
self, body: list[Statement], role: BodyRole = BodyRole.NESTED,
is_script_root: bool = False,
) -> list[Statement]:
# First pass: apply control-flow pruning (dead branches, empty loops, try/trap removal).
# prune_output must be computed from what actually survives this pass: a branch like
# `if ($false) {}` is a non-expression statement that looks like a side effect before
# pruning but produces nothing afterwards. Computing prune_output from the original body
# would cause the flag to stay True even after the apparent anchor is eliminated,
# incorrectly silencing the body's observable return value.
intermediate: list[Statement] = []
changed = False
for stmt in body:
replacement = self._try_prune(stmt)
if replacement is not None:
intermediate.extend(replacement)
changed = True
else:
intermediate.append(stmt)
# Second pass: drop bare pure-constant output statements (integers, booleans, $null) whose
# value is not observed. A NESTED body has no observable return value — all constants are
# prunable. The script root has no pipeline return value, but a script consisting entirely of
# pure constants must be preserved (otherwise `42` becomes empty). So the script root only
# prunes when at least one non-constant statement survives the first pass.
# A non-script ROOT body (function body, bare `&{}`) may use a bare constant as its
# implicit return value, and determining whether some other statement "covers" that return is
# subtle (e.g. Write-Host has a side effect but does not produce pipeline output). The
# junk-removal pass handles ROOT emit-safety with a proper `_output_survives` check; here we
# stay conservative and never prune constants from non-script ROOT bodies.
if role is BodyRole.NESTED:
prune_output = True
elif is_script_root:
prune_output = any(
not (isinstance(s, Ps1ExpressionStatement) and _is_pure_constant(s.expression))
for s in intermediate
)
else:
prune_output = False
result: list[Statement] = []
for stmt in intermediate:
if isinstance(stmt, Ps1ExpressionStatement) and _is_pure_constant(stmt.expression):
if prune_output:
changed = True
continue
result.append(stmt)
return result if changed else body
def _try_prune(self, stmt: Statement) -> list[Statement] | None:
if isinstance(stmt, Ps1WhileLoop):
return self._prune_while(stmt)
if isinstance(stmt, Ps1DoLoop):
return self._prune_do_loop(stmt)
if isinstance(stmt, Ps1ForLoop):
return self._prune_for(stmt)
if isinstance(stmt, Ps1IfStatement):
return self._prune_if(stmt)
if isinstance(stmt, Ps1SwitchStatement):
return self._prune_switch(stmt)
if isinstance(stmt, Ps1TryCatchFinally):
return self._prune_try(stmt)
if isinstance(stmt, Ps1TrapStatement):
return self._prune_trap(stmt)
return None
@staticmethod
def _prune_while(node: Ps1WhileLoop) -> list[Statement] | None:
truth = is_truthy(node.condition)
if truth is False:
return []
if node.body is not None and _body_breaks_unconditionally(node.body.body):
body = list(node.body.body[:-1])
if truth is True or node.condition is None:
return body
return [Ps1IfStatement(clauses=[(node.condition, Block(body=body))])]
return None
@staticmethod
def _prune_do_loop(node: Ps1DoLoop) -> list[Statement] | None:
if node.body is not None:
trivially_exits = (
is_truthy(node.condition) is True if node.is_until
else is_truthy(node.condition) is False
)
if trivially_exits:
body = node.body.body
if _body_breaks_unconditionally(body):
return list(body[:-1])
for stmt in body:
for child in stmt.walk():
if isinstance(child, (Ps1BreakStatement, Ps1ContinueStatement)):
return None
return list(body)
if _body_breaks_unconditionally(node.body.body):
return list(node.body.body[:-1])
return None
@staticmethod
def _prune_for(node: Ps1ForLoop) -> list[Statement] | None:
truth = _evaluate_for_condition(node)
if truth is None:
truth = is_truthy(node.condition)
if truth is False:
result: list[Statement] = []
if node.initializer is not None:
result.append(Ps1ExpressionStatement(expression=node.initializer))
return result
if node.body is not None and _body_breaks_unconditionally(node.body.body):
result = []
if node.initializer is not None:
result.append(Ps1ExpressionStatement(expression=node.initializer))
body = list(node.body.body[:-1])
if truth is True or node.condition is None:
result.extend(body)
else:
result.append(Ps1IfStatement(clauses=[(node.condition, Block(body=body))]))
return result
if node.body is None or not node.body.body:
terminal = _simulate_empty_for_terminal(node)
if terminal is not None:
variable, value = terminal
target = Ps1Variable(name=variable.name, scope=variable.scope)
assignment = Ps1AssignmentExpression(
target=target, operator='=', value=_make_int_literal(value))
return [Ps1ExpressionStatement(expression=assignment)]
return None
@staticmethod
def _prune_if(node: Ps1IfStatement) -> list[Statement] | None:
kept_clauses: list[tuple] = []
for condition, block in node.clauses:
truth = is_truthy(condition)
if truth is True:
return list(block.body)
if truth is False:
continue
kept_clauses.append((condition, block))
kept_clauses.extend(node.clauses[node.clauses.index((condition, block)) + 1:])
break
else:
if node.else_block is not None:
return list(node.else_block.body)
return []
if len(kept_clauses) == len(node.clauses):
return None
node.clauses[:] = kept_clauses
return [node]
@staticmethod
def _prune_switch(node: Ps1SwitchStatement) -> list[Statement] | None:
if node.regex or node.wildcard or node.file:
return None
value = _switch_literal(node.value)
if value is _NO_LITERAL:
return None
default_body: list[Statement] | None = None
result: list[Statement] = []
matched = False
for condition, block in node.clauses:
if condition is None:
default_body = block.body
continue
cond_val = _switch_literal(condition)
if cond_val is _NO_LITERAL:
# A non-constant clause condition might match at runtime; cannot resolve statically.
return None
if switch_matches(value, cond_val, case_sensitive=node.case_sensitive):
body = _switch_clause_body(block.body)
if body is None:
return None
stmts, stop = body
result.extend(stmts)
matched = True
if stop:
return result
if matched:
return result
if default_body is not None:
body = _switch_clause_body(default_body)
if body is None:
return None
return body[0]
return []
@staticmethod
def _prune_try(node: Ps1TryCatchFinally) -> list[Statement] | None:
"""
Resolve a `try`/`catch`/`finally` whose `try` body cannot produce observable side effects.
An empty (or absent) `try` block raises nothing, so every `catch` clause is unreachable and
drops away; the `finally` block always runs, so its statements are hoisted in place of the
whole construct. A non-empty `try` body that is "harmless" (all statements are either pure
expressions or unresolvable bareword commands that would throw without side effects) combined
with all-empty `catch` clauses is likewise a no-op — the entire construct is replaced with
any pure-constant statements from the try body (preserving integer/boolean literals that may
be a function's implicit return value) followed by the `finally` body when present.
"""
try_body = node.try_block.body if node.try_block is not None else []
if not try_body:
finally_body = node.finally_block.body if node.finally_block is not None else []
return list(finally_body)
if not _try_body_is_harmless(try_body):
return None
for clause in node.catch_clauses:
if clause.body is not None and clause.body.body:
return None
finally_body = node.finally_block.body if node.finally_block is not None else []
output_stmts = [
stmt for stmt in try_body
if isinstance(stmt, Ps1ExpressionStatement) and is_side_effect_free(stmt.expression)
]
return output_stmts + list(finally_body)
@staticmethod
def _prune_trap(node: Ps1TrapStatement) -> list[Statement] | None:
"""
Remove a `trap` handler whose body produces no observable output. A trap only runs when the
code it guards throws a terminating error; injected-noise traps (`trap { continue }`, an
empty `trap {}`, `trap { break }`) merely swallow or re-raise without emitting anything, so
deleting them is invisible unless an error actually propagates. A body that emits output (a
real logging handler such as `trap { Write-Host 'err' }`) is not side-effect-free and keeps
the trap intact. This is the one removal not provable under strict semantics — it relies on
the guarded code never throwing — and is deliberately gated on a strict no-output body.
"""
body = node.body.body if node.body is not None else []
for stmt in body:
if isinstance(stmt, (Ps1BreakStatement, Ps1ContinueStatement)):
if stmt.label is not None:
return None
continue
if isinstance(stmt, Ps1ExpressionStatement):
if stmt.expression is None or is_side_effect_free(stmt.expression):
continue
return None
return []
Classes
class Ps1DeadCodeElimination-
Remove unreachable code guarded by constant boolean conditions and resolve switch statements on constant values.
Expand source code Browse git
class Ps1DeadCodeElimination(Transformer): """ Remove unreachable code guarded by constant boolean conditions and resolve switch statements on constant values. """ def visit(self, node: Node): for parent in list(node.walk()): role = classify_body(parent) if role is None or role is BodyRole.OPAQUE: continue body = get_body(parent) new_body = self._prune_body(body, role, isinstance(parent, Ps1Script)) if new_body is not body: body.clear() body.extend(new_body) for stmt in new_body: stmt.parent = parent self.mark_changed() def _prune_body( self, body: list[Statement], role: BodyRole = BodyRole.NESTED, is_script_root: bool = False, ) -> list[Statement]: # First pass: apply control-flow pruning (dead branches, empty loops, try/trap removal). # prune_output must be computed from what actually survives this pass: a branch like # `if ($false) {}` is a non-expression statement that looks like a side effect before # pruning but produces nothing afterwards. Computing prune_output from the original body # would cause the flag to stay True even after the apparent anchor is eliminated, # incorrectly silencing the body's observable return value. intermediate: list[Statement] = [] changed = False for stmt in body: replacement = self._try_prune(stmt) if replacement is not None: intermediate.extend(replacement) changed = True else: intermediate.append(stmt) # Second pass: drop bare pure-constant output statements (integers, booleans, $null) whose # value is not observed. A NESTED body has no observable return value — all constants are # prunable. The script root has no pipeline return value, but a script consisting entirely of # pure constants must be preserved (otherwise `42` becomes empty). So the script root only # prunes when at least one non-constant statement survives the first pass. # A non-script ROOT body (function body, bare `&{}`) may use a bare constant as its # implicit return value, and determining whether some other statement "covers" that return is # subtle (e.g. Write-Host has a side effect but does not produce pipeline output). The # junk-removal pass handles ROOT emit-safety with a proper `_output_survives` check; here we # stay conservative and never prune constants from non-script ROOT bodies. if role is BodyRole.NESTED: prune_output = True elif is_script_root: prune_output = any( not (isinstance(s, Ps1ExpressionStatement) and _is_pure_constant(s.expression)) for s in intermediate ) else: prune_output = False result: list[Statement] = [] for stmt in intermediate: if isinstance(stmt, Ps1ExpressionStatement) and _is_pure_constant(stmt.expression): if prune_output: changed = True continue result.append(stmt) return result if changed else body def _try_prune(self, stmt: Statement) -> list[Statement] | None: if isinstance(stmt, Ps1WhileLoop): return self._prune_while(stmt) if isinstance(stmt, Ps1DoLoop): return self._prune_do_loop(stmt) if isinstance(stmt, Ps1ForLoop): return self._prune_for(stmt) if isinstance(stmt, Ps1IfStatement): return self._prune_if(stmt) if isinstance(stmt, Ps1SwitchStatement): return self._prune_switch(stmt) if isinstance(stmt, Ps1TryCatchFinally): return self._prune_try(stmt) if isinstance(stmt, Ps1TrapStatement): return self._prune_trap(stmt) return None @staticmethod def _prune_while(node: Ps1WhileLoop) -> list[Statement] | None: truth = is_truthy(node.condition) if truth is False: return [] if node.body is not None and _body_breaks_unconditionally(node.body.body): body = list(node.body.body[:-1]) if truth is True or node.condition is None: return body return [Ps1IfStatement(clauses=[(node.condition, Block(body=body))])] return None @staticmethod def _prune_do_loop(node: Ps1DoLoop) -> list[Statement] | None: if node.body is not None: trivially_exits = ( is_truthy(node.condition) is True if node.is_until else is_truthy(node.condition) is False ) if trivially_exits: body = node.body.body if _body_breaks_unconditionally(body): return list(body[:-1]) for stmt in body: for child in stmt.walk(): if isinstance(child, (Ps1BreakStatement, Ps1ContinueStatement)): return None return list(body) if _body_breaks_unconditionally(node.body.body): return list(node.body.body[:-1]) return None @staticmethod def _prune_for(node: Ps1ForLoop) -> list[Statement] | None: truth = _evaluate_for_condition(node) if truth is None: truth = is_truthy(node.condition) if truth is False: result: list[Statement] = [] if node.initializer is not None: result.append(Ps1ExpressionStatement(expression=node.initializer)) return result if node.body is not None and _body_breaks_unconditionally(node.body.body): result = [] if node.initializer is not None: result.append(Ps1ExpressionStatement(expression=node.initializer)) body = list(node.body.body[:-1]) if truth is True or node.condition is None: result.extend(body) else: result.append(Ps1IfStatement(clauses=[(node.condition, Block(body=body))])) return result if node.body is None or not node.body.body: terminal = _simulate_empty_for_terminal(node) if terminal is not None: variable, value = terminal target = Ps1Variable(name=variable.name, scope=variable.scope) assignment = Ps1AssignmentExpression( target=target, operator='=', value=_make_int_literal(value)) return [Ps1ExpressionStatement(expression=assignment)] return None @staticmethod def _prune_if(node: Ps1IfStatement) -> list[Statement] | None: kept_clauses: list[tuple] = [] for condition, block in node.clauses: truth = is_truthy(condition) if truth is True: return list(block.body) if truth is False: continue kept_clauses.append((condition, block)) kept_clauses.extend(node.clauses[node.clauses.index((condition, block)) + 1:]) break else: if node.else_block is not None: return list(node.else_block.body) return [] if len(kept_clauses) == len(node.clauses): return None node.clauses[:] = kept_clauses return [node] @staticmethod def _prune_switch(node: Ps1SwitchStatement) -> list[Statement] | None: if node.regex or node.wildcard or node.file: return None value = _switch_literal(node.value) if value is _NO_LITERAL: return None default_body: list[Statement] | None = None result: list[Statement] = [] matched = False for condition, block in node.clauses: if condition is None: default_body = block.body continue cond_val = _switch_literal(condition) if cond_val is _NO_LITERAL: # A non-constant clause condition might match at runtime; cannot resolve statically. return None if switch_matches(value, cond_val, case_sensitive=node.case_sensitive): body = _switch_clause_body(block.body) if body is None: return None stmts, stop = body result.extend(stmts) matched = True if stop: return result if matched: return result if default_body is not None: body = _switch_clause_body(default_body) if body is None: return None return body[0] return [] @staticmethod def _prune_try(node: Ps1TryCatchFinally) -> list[Statement] | None: """ Resolve a `try`/`catch`/`finally` whose `try` body cannot produce observable side effects. An empty (or absent) `try` block raises nothing, so every `catch` clause is unreachable and drops away; the `finally` block always runs, so its statements are hoisted in place of the whole construct. A non-empty `try` body that is "harmless" (all statements are either pure expressions or unresolvable bareword commands that would throw without side effects) combined with all-empty `catch` clauses is likewise a no-op — the entire construct is replaced with any pure-constant statements from the try body (preserving integer/boolean literals that may be a function's implicit return value) followed by the `finally` body when present. """ try_body = node.try_block.body if node.try_block is not None else [] if not try_body: finally_body = node.finally_block.body if node.finally_block is not None else [] return list(finally_body) if not _try_body_is_harmless(try_body): return None for clause in node.catch_clauses: if clause.body is not None and clause.body.body: return None finally_body = node.finally_block.body if node.finally_block is not None else [] output_stmts = [ stmt for stmt in try_body if isinstance(stmt, Ps1ExpressionStatement) and is_side_effect_free(stmt.expression) ] return output_stmts + list(finally_body) @staticmethod def _prune_trap(node: Ps1TrapStatement) -> list[Statement] | None: """ Remove a `trap` handler whose body produces no observable output. A trap only runs when the code it guards throws a terminating error; injected-noise traps (`trap { continue }`, an empty `trap {}`, `trap { break }`) merely swallow or re-raise without emitting anything, so deleting them is invisible unless an error actually propagates. A body that emits output (a real logging handler such as `trap { Write-Host 'err' }`) is not side-effect-free and keeps the trap intact. This is the one removal not provable under strict semantics — it relies on the guarded code never throwing — and is deliberately gated on a strict no-output body. """ body = node.body.body if node.body is not None else [] for stmt in body: if isinstance(stmt, (Ps1BreakStatement, Ps1ContinueStatement)): if stmt.label is not None: return None continue if isinstance(stmt, Ps1ExpressionStatement): if stmt.expression is None or is_side_effect_free(stmt.expression): continue return None return []Ancestors
Methods
def visit(self, node)-
Expand source code Browse git
def visit(self, node: Node): for parent in list(node.walk()): role = classify_body(parent) if role is None or role is BodyRole.OPAQUE: continue body = get_body(parent) new_body = self._prune_body(body, role, isinstance(parent, Ps1Script)) if new_body is not body: body.clear() body.extend(new_body) for stmt in new_body: stmt.parent = parent self.mark_changed()
Inherited members