Module refinery.lib.scripts.ps1.analysis.cfg
PowerShell's contribution to the shared control-flow substrate: which node types are which control-flow shape, and where the parts of each are stored.
Everything structural lives in refinery.lib.scripts.analysis.cfg. What is here is the dispatch, the
accessors, and the places PowerShell's answer differs from the shape JavaScript established:
switchdoes not fall through, it keeps matching. Every clause is tested against the value and every matching one runs, so an arm's exits may reach any later arm rather than only the next. That isArmFlow.CUMULATIVE, and reading it as C-style fallthrough would drop the path in which the first and third clauses match and the second does not. It also enumerates its input, socontinueinside it advances to the next input value rather than to an enclosing loop, and adefaultclause does not make it exhaustive: over an empty collection no clause runs at all.if/elseifis one flat statement, not a nest, so the chain of tests is built throughbranch_chainand eachelseifcondition gets a node of its own.trapis a scope-wide handler, not a guarded block: it catches for the whole body it is declared in — including statements written above it and one written inside a nested block — andcontinueinside it resumes at the statement after the one that threw. See_Builder.build.exitis a terminator likereturn, modelled as one: control does not continue in this body. That it leaves the script rather than the function is a claim about the caller's body, which a per-body graph does not make; keeping the caller's paths is the conservative reading.- A label is a field of the construct, not a statement wrapping it, so
park_labelis called from the dispatch rather than throughlabelled, and the:the lexer keeps on the declaration is stripped before a jump can be matched against it.
Ps1Code also splits one body across begin/process/end/dynamicparam blocks, so owner.body
alone is not what an advanced function runs.
Expand source code Browse git
"""
PowerShell's contribution to the shared control-flow substrate: which node types are which
control-flow shape, and where the parts of each are stored.
Everything structural lives in `refinery.lib.scripts.analysis.cfg`. What is here is the dispatch, the
accessors, and the places PowerShell's answer differs from the shape JavaScript established:
- **`switch` does not fall through, it keeps matching.** Every clause is tested against the value and
every matching one runs, so an arm's exits may reach any *later* arm rather than only the next.
That is `refinery.lib.scripts.analysis.cfg.ArmFlow.CUMULATIVE`, and reading it as C-style
fallthrough would drop the path in which the first and third clauses match and the second does not.
It also *enumerates its input*, so `continue` inside it advances to the next input value rather
than to an enclosing loop, and a `default` clause does not make it exhaustive: over an empty
collection no clause runs at all.
- **`if`/`elseif` is one flat statement**, not a nest, so the chain of tests is built through
`branch_chain` and each `elseif` condition gets a node of its own.
- **`trap` is a scope-wide handler**, not a guarded block: it catches for the whole body it is
declared in — including statements written above it and one written inside a nested block — and
`continue` inside it resumes at the statement after the one that threw. See `_Builder.build`.
- **`exit` is a terminator like `return`**, modelled as one: control does not continue in this body.
That it leaves the *script* rather than the function is a claim about the caller's body, which a
per-body graph does not make; keeping the caller's paths is the conservative reading.
- **A label is a field of the construct**, not a statement wrapping it, so `park_label` is called
from the dispatch rather than through `labelled`, and the `:` the lexer keeps on the declaration
is stripped before a jump can be matched against it.
`Ps1Code` also splits one body across `begin`/`process`/`end`/`dynamicparam` blocks, so `owner.body`
alone is not what an advanced function runs.
"""
from __future__ import annotations
from typing import Sequence
from refinery.lib.scripts import Block, Node
from refinery.lib.scripts.analysis.cfg import (
ArmFlow,
CfgBuilder,
CfgNode,
ControlFlowGraph,
ControlFlowModel,
build_control_flow,
)
from refinery.lib.scripts.ps1.ast import get_named_blocks, string_value
from refinery.lib.scripts.ps1.model import (
Ps1BreakStatement,
Ps1Code,
Ps1ContinueStatement,
Ps1DoLoop,
Ps1ExitStatement,
Ps1ForEachLoop,
Ps1ForLoop,
Ps1IfStatement,
Ps1Jump,
Ps1ReturnStatement,
Ps1Script,
Ps1ScriptBlock,
Ps1SwitchStatement,
Ps1ThrowStatement,
Ps1TrapStatement,
Ps1TryCatchFinally,
Ps1WhileLoop,
)
#: The nodes that own a control-flow graph of their own, beside the script itself. A script block is
#: PowerShell's anonymous function: it is a *value* where it is written and runs somewhere else
#: entirely — as the body of `ForEach-Object`, through `&` or `.`, or as the body of a function,
#: which is the only script block a `Ps1FunctionDefinition` has and why keying on the definition
#: instead adds no graph but leaves every other block without one. A block with no graph is climbed
#: out of, so its statements are reported as running where the block is *written*: they claim to
#: precede everything after that point and lose their order among themselves.
#:
#: This is the same partition `refinery.lib.scripts.ps1.analysis.model.Ps1SemanticModel.scope_of`
#: makes, and the same boundary `_declared_traps` stops at — one body, one scope, one graph.
FUNCTION_NODES = (Ps1ScriptBlock,)
_LOOP_NODES = (
Ps1WhileLoop,
Ps1DoLoop,
Ps1ForLoop,
Ps1ForEachLoop,
)
def _jump_label(statement: Ps1Jump) -> str | None:
"""
The label a `break` or `continue` names, or `None` when it names none or names one this cannot
read. PowerShell allows the label to be any expression — `break $name` is legal — and a label
that is not a static string is one no lexical target can be matched against, so it is treated as
unlabelled, which resolves to the innermost enclosing target and is the conservative reading.
"""
return string_value(statement.label) if statement.label is not None else None
def _construct_label(label: str | None) -> str | None:
"""
The name a labelled loop or `switch` declares, without the `:` the lexer keeps on the token.
A jump names its target without the colon, so the declaration and the reference are two
spellings of one name and comparing them unnormalized never matches — which resolves every
labelled jump to nothing and sends it out of the body instead of out of the construct it names.
"""
return label[1:] if label is not None and label.startswith(':') else label
def _declared_traps(statements: Sequence[Node]) -> list[Ps1TrapStatement]:
"""
Every `trap` declared anywhere in *statements*, in source order, without descending into a
nested body that owns a control-flow graph of its own.
A `trap` is not confined to the braces it is written in: one inside an `if` body catches for the
whole scope. Scanning only the top level of the body would leave such a handler with no edge
from any statement and its own body with no node at all, which reads as a handler that never
runs and a payload that is never evaluated.
"""
found: list[Ps1TrapStatement] = []
stack = list(reversed(statements))
while stack:
node = stack.pop()
if isinstance(node, Ps1TrapStatement):
found.append(node)
continue
if isinstance(node, Ps1Code):
continue
stack.extend(reversed(node.children()))
return found
class _Builder(CfgBuilder):
"""
The PowerShell dispatch over `refinery.lib.scripts.analysis.cfg.CfgBuilder`.
"""
def __init__(self, owner: Node):
super().__init__(owner)
self._resumes: list[CfgNode] | None = None
def build(self) -> ControlFlowGraph:
"""
The body, with every `trap` it declares installed as a handler over the whole of it.
A `trap` is not a guarded block. It is declared somewhere in a body and catches for that
body entire, including the statements written above it, so it cannot be pushed at the point
it appears the way a `try` is — it is pushed before the body is walked at all. Several traps
in one scope are peers and are chained the way `guarded` chains several `catch` clauses,
because which one runs depends on the type of the error and none of them is guaranteed.
A trap does not guard itself: an error raised inside a trap body leaves the scope rather
than re-entering the handler it was raised in, so the trap bodies are built before the
handler is pushed and only the guarded statements are built under it. Pushing it first gives
every node of every trap body an exceptional edge back to a handler that already has a normal
edge into it, which is a cycle through the trap that no run can take — and reads as a body
that repeats. `refinery.lib.scripts.analysis.cfg.CfgBuilder.guarded` pops before building its
`catch` bodies for the same reason.
`continue` inside a trap resumes at the statement following the one that threw, which is a
shape this graph cannot express exactly. Every resumption point is therefore linked to
*every* node the guarded body created, which is the over-approximation: it claims more paths
than exist, where claiming fewer would let an analysis call a statement after a trap
unreachable, or let a store before one look dead because the resumption that reads it was
never modelled.
"""
statements = self.body_statements(self.cfg.owner)
traps = _declared_traps(statements)
entries: list[CfgNode] = []
for trap in traps:
handler = self.node(trap)
if entries:
self.exceptional_edge(entries[-1], handler)
entries.append(handler)
resumes: list[CfgNode] = []
for trap, handler in zip(traps, entries):
self._resumes = []
resumes += self._body(trap.body, [handler]) + self._resumes
self._resumes = None
guarded_from = len(self.cfg.nodes)
if entries:
self._handlers.append(entries[0])
frontier = self.sequence(statements, [self.cfg.entry])
if entries:
self._handlers.pop()
self.link(frontier, self.cfg.exit)
landing = [node for node in self.cfg.nodes[guarded_from:] if node.element is not None]
for resume in resumes:
self.add_edge(resume, self.cfg.exit)
for node in landing:
self.add_edge(resume, node)
return self.cfg
def body_statements(self, owner: Node) -> list[Node]:
"""
The statements *owner* runs, in the order it runs them.
An advanced function fills `begin`/`process`/`end`/`dynamicparam` instead of `body`, and
reading only `body` for one would report an empty graph for a function that runs a great
deal — the same trap `refinery.lib.scripts.ps1.ast.get_named_blocks` exists to warn about.
`dynamicparam` is pulled to the front of the sequence because the engine evaluates it during
parameter binding, before `begin`, while that accessor reports the blocks in the order they
are declared in.
"""
if not isinstance(owner, Ps1Code):
return []
statements: list[Node] = []
blocks = get_named_blocks(owner)
for block in sorted(blocks, key=lambda block: block is not owner.dynamicparam_block):
statements.extend(block.body)
statements.extend(owner.body)
return statements
def statement(self, statement: Node, frontier: list[CfgNode]) -> list[CfgNode]:
if isinstance(statement, Block):
return self.sequence(statement.body, frontier)
if isinstance(statement, _LOOP_NODES):
self.park_label(_construct_label(statement.label))
if isinstance(statement, Ps1IfStatement):
return self._if(statement, frontier)
if isinstance(statement, Ps1WhileLoop):
return self.loop_head_tested(statement, statement.body, frontier)
if isinstance(statement, Ps1DoLoop):
return self.loop_tail_tested(statement, statement.body, frontier)
if isinstance(statement, Ps1ForLoop):
return self.loop_counted(
statement.initializer,
statement.condition,
statement.iterator,
statement.body,
frontier,
)
if isinstance(statement, Ps1ForEachLoop):
return self.loop_head_tested(statement, statement.body, frontier)
if isinstance(statement, Ps1SwitchStatement):
return self._switch(statement, frontier)
if isinstance(statement, Ps1TryCatchFinally):
return self._try(statement, frontier)
if isinstance(statement, Ps1TrapStatement):
return list(frontier)
if isinstance(statement, Ps1ExitStatement):
return self.terminate(statement, frontier, exceptional=False)
if isinstance(statement, Ps1ThrowStatement):
return self.terminate(statement, frontier, exceptional=True)
if isinstance(statement, Ps1ReturnStatement):
return self.terminate(statement, frontier, exceptional=False)
if isinstance(statement, Ps1BreakStatement):
return self.jump_out(statement, _jump_label(statement), frontier)
if isinstance(statement, Ps1ContinueStatement):
label = _jump_label(statement)
resumes = self._resumes
if resumes is not None and not self.has_continue_target(label):
return self._resume(resumes, statement, frontier)
return self.jump_back(statement, label, frontier)
return self.opaque(statement, frontier)
def _resume(
self, resumes: list[CfgNode], statement: Node, frontier: list[CfgNode],
) -> list[CfgNode]:
"""
A `continue` inside a `trap` body, which is not a back-jump: it resumes the guarded body at
the statement after the one that threw. The node is recorded for `build` to link to every
landing point once that body exists, and control does not fall through it here.
Reading it as a loop back-jump instead resolves it to nothing and wires it to the body exit,
which deletes the one path the trap exists to create — and leaves the whole resumption model
unexercised, because this is the spelling that resumes.
"""
node = self.node(statement)
self.link(frontier, node)
resumes.append(node)
return []
def _if(self, statement: Ps1IfStatement, frontier: list[CfgNode]) -> list[CfgNode]:
"""
The chain, keyed so that the whole statement is the first test's node: a caller that locates
the `if` itself must reach a node, and the first condition is where control first arrives.
"""
clauses: list[tuple[Node, Node | None]] = []
for index, (condition, block) in enumerate(statement.clauses):
clauses.append((statement if index == 0 else condition, block))
if not clauses:
return self.opaque(statement, frontier)
return self.branch_chain(clauses, statement.else_block, frontier)
def _switch(self, statement: Ps1SwitchStatement, frontier: list[CfgNode]) -> list[CfgNode]:
"""
The clauses as arms of an iterated, cumulative dispatch.
A `default` clause does not make the construct exhaustive the way it does in a language
whose `switch` tests one value: PowerShell's enumerates its input, and over an empty
collection no clause runs at all — not even `default`. Reporting it exhaustive drops the
edge that leaves the switch without entering any arm, which is what makes the statement
after it look unreachable.
"""
arms: list[Sequence[Node]] = [
list(block.body) if block is not None else [] for _, block in statement.clauses
]
self.park_label(_construct_label(statement.label))
return self.dispatch(
statement,
arms,
frontier,
arm_flow=ArmFlow.CUMULATIVE,
exhaustive=False,
iterated=True,
)
def _try(self, statement: Ps1TryCatchFinally, frontier: list[CfgNode]) -> list[CfgNode]:
"""
PowerShell allows several typed `catch` clauses where JavaScript allows one binding, so the
clauses are chained: the guarded block reaches the first, and each clause reaches the next,
because which one runs depends on the exception type and none of them is guaranteed.
"""
clauses = statement.catch_clauses
return self.guarded(
statement.try_block,
[(clause, clause.body) for clause in clauses],
statement.finally_block,
list(statement.finally_block.body) if statement.finally_block is not None else (),
frontier,
)
def build_ps1_control_flow(root: Ps1Script) -> dict[int, ControlFlowGraph]:
"""
One control-flow graph per script block — see `FUNCTION_NODES` — and one for the script itself.
"""
return build_control_flow(root, _Builder, FUNCTION_NODES)
def build_control_flow_model(root: Ps1Script) -> ControlFlowModel:
"""
The `refinery.lib.scripts.analysis.cfg.ControlFlowModel` for a script root.
"""
return ControlFlowModel(build_ps1_control_flow(root))
Global variables
var FUNCTION_NODES-
This is the same partition
Ps1SemanticModel.scope_of()makes, and the same boundary_declared_trapsstops at — one body, one scope, one graph.
Functions
def build_ps1_control_flow(root)-
One control-flow graph per script block — see
FUNCTION_NODES— and one for the script itself.Expand source code Browse git
def build_ps1_control_flow(root: Ps1Script) -> dict[int, ControlFlowGraph]: """ One control-flow graph per script block — see `FUNCTION_NODES` — and one for the script itself. """ return build_control_flow(root, _Builder, FUNCTION_NODES) def build_control_flow_model(root)-
The
ControlFlowModelfor a script root.Expand source code Browse git
def build_control_flow_model(root: Ps1Script) -> ControlFlowModel: """ The `refinery.lib.scripts.analysis.cfg.ControlFlowModel` for a script root. """ return ControlFlowModel(build_ps1_control_flow(root))