feat: analyze foreach control regions

This commit is contained in:
lda
2026-09-04 07:15:24 +07:00 Verified
parent 79f3054426
commit 6a5d886962
3 changed files with 614 additions and 0 deletions
+12
View File
@@ -5,9 +5,21 @@ from .context_scopes import (
context_analysis_warnings,
context_fields_by_node,
)
from .control_regions import (
ControlRegionAnalysis,
ControlRegionIssue,
ControlRegionIssueKind,
ForeachOwnerStack,
analyze_control_regions,
)
__all__ = [
"ContextFieldAvailability",
"ControlRegionAnalysis",
"ControlRegionIssue",
"ControlRegionIssueKind",
"ForeachOwnerStack",
"analyze_control_regions",
"context_analysis_warnings",
"context_fields_by_node",
]
+255
View File
@@ -0,0 +1,255 @@
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
from enum import StrEnum
from wf_core.models.steps import EndNode, ForeachNode
from wf_core.models.workflow import Workflow
from wf_core.tokens import END
type ForeachOwnerStack = tuple[str, ...]
class ControlRegionIssueKind(StrEnum):
UNREACHABLE_NODE = "unreachable_node"
FOREACH_REGION_CONFLICT = "foreach_region_conflict"
INVALID_FOREACH_RETURN = "invalid_foreach_return"
INVALID_FOREACH_TERMINAL = "invalid_foreach_terminal"
EMPTY_FOREACH_BODY = "empty_foreach_body"
FOREACH_BODY_NO_RETURN = "foreach_body_no_return"
@dataclass(frozen=True, slots=True)
class ControlRegionIssue:
kind: ControlRegionIssueKind
path: str
message: str
@dataclass(frozen=True, slots=True)
class ControlRegionAnalysis:
owner_stack_by_node: dict[str, ForeachOwnerStack]
issues: tuple[ControlRegionIssue, ...]
def analyze_control_regions(workflow: Workflow) -> ControlRegionAnalysis:
"""Derive one static foreach-owner stack per reachable node use.
Traversal is over ``(node_id, owner_stack)`` states. A ``loop`` edge from
a foreach pushes that controller; an edge targeting the immediate owner is
an item return that resumes the owner in the popped stack; targeting an
older ancestor is a non-local return; targeting ``END``/``EndNode`` inside
a body is an invalid terminal. Reaching the same node under two stacks is
a region conflict. After traversal every unreached node is unreachable and
every reached body state must have a structural path back to its top owner.
"""
nodes_by_id = {node.id: node for node in workflow.nodes}
if workflow.start not in nodes_by_id:
return ControlRegionAnalysis(owner_stack_by_node={}, issues=())
edges_by_node: dict[str, list[tuple[int, object]]] = {}
for index, edge in enumerate(workflow.edges):
edges_by_node.setdefault(edge.from_, []).append((index, edge))
owner_stack_by_node: dict[str, ForeachOwnerStack] = {}
conflicted: set[str] = set()
issues: list[ControlRegionIssue] = []
# Semantic state adjacency for the structural-return check. Return edges
# also link to the resumed owner state so deeper nested returns are part
# of the path search.
adjacency: dict[
tuple[str, ForeachOwnerStack], list[tuple[str, ForeachOwnerStack]]
] = {}
return_owner_by_source: dict[tuple[str, ForeachOwnerStack], str] = {}
ambiguous_tops: set[str] = set()
def mark_ambiguous(stack: ForeachOwnerStack) -> None:
if stack:
ambiguous_tops.add(stack[-1])
def add_adjacency(
source: tuple[str, ForeachOwnerStack],
target: tuple[str, ForeachOwnerStack],
) -> None:
adjacency.setdefault(source, []).append(target)
pending: deque[tuple[str, ForeachOwnerStack]] = deque([(workflow.start, ())])
visited: set[tuple[str, ForeachOwnerStack]] = set()
visited_nodes: set[str] = set()
while pending:
node_id, stack = pending.popleft()
state = (node_id, stack)
if state in visited:
continue
visited.add(state)
node = nodes_by_id.get(node_id)
if node is None:
continue
visited_nodes.add(node_id)
if node_id in conflicted:
continue
recorded = owner_stack_by_node.get(node_id)
if recorded is None:
owner_stack_by_node[node_id] = stack
elif recorded != stack:
# Same node use reached under two control regions: it has no
# single static owner stack. Drop it so later context analysis
# grants no foreach fields, and stop expanding this ambiguous
# state so the conflict does not cascade.
del owner_stack_by_node[node_id]
conflicted.add(node_id)
issues.append(
ControlRegionIssue(
kind=ControlRegionIssueKind.FOREACH_REGION_CONFLICT,
path=f"nodes[{node_id}]",
message=(
f"node {node_id!r} is reachable under two foreach "
"control regions"
),
)
)
for prior_stack in (recorded, stack):
mark_ambiguous(prior_stack)
continue
for edge_index, edge in edges_by_node.get(node_id, []): # type: ignore[attr-defined]
target_id: str = edge.to # type: ignore[attr-defined]
source_is_loop = isinstance(node, ForeachNode) and edge.outcome == "loop" # type: ignore[attr-defined]
if source_is_loop:
if target_id == node_id:
issues.append(
ControlRegionIssue(
kind=ControlRegionIssueKind.EMPTY_FOREACH_BODY,
path=f"edges[{edge_index}]",
message=(
f"foreach {node_id!r} loop targets itself; "
"an iteration body needs a distinct node use"
),
)
)
mark_ambiguous(stack)
continue
target_stack: ForeachOwnerStack = (*stack, node_id)
else:
target_stack = stack
target_node = None if target_id == END else nodes_by_id.get(target_id)
if target_id != END and target_node is None:
# Unknown destinations are owned by ordinary edge validation.
continue
is_terminal = target_id == END or isinstance(target_node, EndNode)
if is_terminal:
if target_stack:
issues.append(
ControlRegionIssue(
kind=ControlRegionIssueKind.INVALID_FOREACH_TERMINAL,
path=f"edges[{edge_index}]",
message=(
f"foreach item path {node_id!r} -> "
f"{target_id!r} targets a workflow terminal "
"from inside a foreach body"
),
)
)
mark_ambiguous(target_stack)
continue
# At this point target_id is a known non-terminal node id.
if target_stack and target_id == target_stack[-1]:
# Immediate-owner back-edge: the item frame completes at its
# owner without executing the controller again. Resume the
# owner in the popped stack for structural analysis.
resumed: tuple[str, ForeachOwnerStack] = (
target_id,
target_stack[:-1],
)
return_owner_by_source[state] = target_id
add_adjacency(state, resumed)
if resumed not in visited:
pending.append(resumed)
continue
if target_id in target_stack:
issues.append(
ControlRegionIssue(
kind=ControlRegionIssueKind.INVALID_FOREACH_RETURN,
path=f"edges[{edge_index}]",
message=(
f"edge {node_id!r} -> {target_id!r} skips the "
"immediate foreach owner"
),
)
)
mark_ambiguous(target_stack)
continue
successor: tuple[str, ForeachOwnerStack] = (target_id, target_stack)
add_adjacency(state, successor)
pending.append(successor)
for node in workflow.nodes:
if node.id not in visited_nodes:
issues.append(
ControlRegionIssue(
kind=ControlRegionIssueKind.UNREACHABLE_NODE,
path=f"nodes[{node.id}]",
message=f"node {node.id!r} is unreachable from start",
)
)
# Structural returnability: every reached body state needs some graph path
# back to its immediate owner. Data decides whether the exit is taken, so
# one possible path is enough. Skip bodies already made ambiguous by a
# region conflict, invalid return/terminal, or empty body.
tops_with_no_return: set[str] = set()
for node_id, stack in list(visited):
if not stack:
continue
if node_id in conflicted:
continue
top = stack[-1]
if top in ambiguous_tops:
continue
if top in tops_with_no_return:
continue
# Breadth-first search over semantic states for a return to `top`.
seen: set[tuple[str, ForeachOwnerStack]] = set()
queue: deque[tuple[str, ForeachOwnerStack]] = deque([(node_id, stack)])
found = False
while queue:
current = queue.popleft()
if current in seen:
continue
seen.add(current)
if return_owner_by_source.get(current) == top:
found = True
break
for successor in adjacency.get(current, []):
if successor not in seen:
queue.append(successor)
if not found:
tops_with_no_return.add(top)
for top in sorted(tops_with_no_return):
# Only report when the owner itself is unambiguous; a conflicted
# owner has no single region to return to.
if top in conflicted:
continue
if top not in owner_stack_by_node and top not in visited_nodes:
continue
issues.append(
ControlRegionIssue(
kind=ControlRegionIssueKind.FOREACH_BODY_NO_RETURN,
path=f"nodes[{top}]",
message=(
f"foreach {top!r} body has no structural path back to "
"its immediate owner"
),
)
)
return ControlRegionAnalysis(
owner_stack_by_node=dict(owner_stack_by_node),
issues=tuple(issues),
)
+347
View File
@@ -0,0 +1,347 @@
from __future__ import annotations
from wf_core import END, Workflow
from wf_core.analysis.control_regions import (
ControlRegionAnalysis,
ControlRegionIssueKind,
analyze_control_regions,
)
def _workflow(
*,
start: str,
nodes: list[dict[str, object]],
edges: list[dict[str, str]],
) -> Workflow:
return Workflow.model_validate(
{
"name": "control-regions",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {
"type": "object",
"properties": {
"items": {"type": "array", "items": {"type": "string"}},
"inner_items": {"type": "array", "items": {"type": "integer"}},
},
},
"output_schema": {"type": "object", "properties": {}},
"start": start,
"nodes": nodes,
"edges": edges,
"node_defs": [],
}
)
def _node(node_id: str) -> dict[str, object]:
return {"id": node_id, "type": "node", "node": "noop"}
def _foreach(node_id: str, *, alias: str = "item") -> dict[str, object]:
return {
"id": node_id,
"type": "foreach",
"over": "state.items",
"as": alias,
"mode": "serial",
}
def _condition(node_id: str) -> dict[str, object]:
return {
"id": node_id,
"type": "condition",
"check": {"op": "exists", "path": "state.items"},
}
def test_closed_root_cycle_has_one_empty_control_region() -> None:
workflow = _workflow(
start="a",
nodes=[_node("a"), _node("b")],
edges=[
{"from": "a", "outcome": "ok", "to": "b"},
{"from": "b", "outcome": "ok", "to": "a"},
],
)
analysis = analyze_control_regions(workflow)
assert analysis.issues == ()
assert analysis.owner_stack_by_node == {"a": (), "b": ()}
def test_foreach_cycle_with_possible_return_is_valid() -> None:
workflow = _workflow(
start="f",
nodes=[_foreach("f"), _node("a")],
edges=[
{"from": "f", "outcome": "loop", "to": "a"},
{"from": "a", "outcome": "again", "to": "a"},
{"from": "a", "outcome": "done", "to": "f"},
{"from": "f", "outcome": "done", "to": END},
],
)
analysis = analyze_control_regions(workflow)
assert analysis.issues == ()
assert analysis.owner_stack_by_node["a"] == ("f",)
assert analysis.owner_stack_by_node["f"] == ()
def test_conditional_foreach_paths_can_both_return() -> None:
workflow = _workflow(
start="f",
nodes=[_foreach("f"), _condition("condition"), _node("work")],
edges=[
{"from": "f", "outcome": "loop", "to": "condition"},
{"from": "condition", "outcome": "true", "to": "work"},
{"from": "condition", "outcome": "false", "to": "f"},
{"from": "work", "outcome": "ok", "to": "f"},
{"from": "f", "outcome": "done", "to": END},
],
)
analysis = analyze_control_regions(workflow)
assert analysis.issues == ()
assert analysis.owner_stack_by_node["condition"] == ("f",)
assert analysis.owner_stack_by_node["work"] == ("f",)
def test_nested_foreach_assigns_static_owner_stacks() -> None:
workflow = _workflow(
start="f1",
nodes=[
_foreach("f1"),
_foreach("f2"),
_node("work"),
_node("tail"),
_node("after"),
],
edges=[
{"from": "f1", "outcome": "loop", "to": "f2"},
{"from": "f2", "outcome": "loop", "to": "work"},
{"from": "work", "outcome": "ok", "to": "f2"},
{"from": "f2", "outcome": "done", "to": "tail"},
{"from": "tail", "outcome": "ok", "to": "f1"},
{"from": "f1", "outcome": "done", "to": "after"},
{"from": "after", "outcome": "ok", "to": END},
],
)
analysis: ControlRegionAnalysis = analyze_control_regions(workflow)
assert analysis.owner_stack_by_node == {
"f1": (),
"f2": ("f1",),
"work": ("f1", "f2"),
"tail": ("f1",),
"after": (),
}
assert analysis.issues == ()
def test_reentering_completed_foreach_keeps_one_static_region() -> None:
workflow = _workflow(
start="again",
nodes=[_condition("again"), _foreach("f"), _node("work")],
edges=[
{"from": "again", "outcome": "true", "to": "f"},
{"from": "f", "outcome": "loop", "to": "work"},
{"from": "work", "outcome": "ok", "to": "f"},
{"from": "f", "outcome": "done", "to": "again"},
{"from": "again", "outcome": "false", "to": END},
],
)
analysis = analyze_control_regions(workflow)
assert analysis.issues == ()
assert analysis.owner_stack_by_node["f"] == ()
assert analysis.owner_stack_by_node["work"] == ("f",)
assert analysis.owner_stack_by_node["again"] == ()
def test_external_entry_into_foreach_body_is_region_conflict() -> None:
workflow = _workflow(
start="start",
nodes=[_condition("start"), _foreach("f"), _node("b")],
edges=[
{"from": "start", "outcome": "true", "to": "f"},
{"from": "start", "outcome": "false", "to": "b"},
{"from": "f", "outcome": "loop", "to": "b"},
{"from": "b", "outcome": "ok", "to": "f"},
{"from": "f", "outcome": "done", "to": END},
],
)
analysis = analyze_control_regions(workflow)
assert (ControlRegionIssueKind.FOREACH_REGION_CONFLICT, "nodes[b]") in [
(issue.kind, issue.path) for issue in analysis.issues
]
assert "b" not in analysis.owner_stack_by_node
def test_foreach_body_escape_is_region_conflict() -> None:
workflow = _workflow(
start="f",
nodes=[_foreach("f"), _node("b"), _node("after")],
edges=[
{"from": "f", "outcome": "loop", "to": "b"},
{"from": "b", "outcome": "ok", "to": "after"},
{"from": "f", "outcome": "done", "to": "after"},
{"from": "after", "outcome": "ok", "to": END},
],
)
analysis = analyze_control_regions(workflow)
assert (ControlRegionIssueKind.FOREACH_REGION_CONFLICT, "nodes[after]") in [
(issue.kind, issue.path) for issue in analysis.issues
]
assert "after" not in analysis.owner_stack_by_node
def test_skipping_inner_foreach_owner_is_invalid_return() -> None:
workflow = _workflow(
start="f1",
nodes=[_foreach("f1"), _foreach("f2"), _node("work")],
edges=[
{"from": "f1", "outcome": "loop", "to": "f2"},
{"from": "f2", "outcome": "loop", "to": "work"},
{"from": "work", "outcome": "ok", "to": "f1"},
{"from": "f1", "outcome": "done", "to": END},
{"from": "f2", "outcome": "done", "to": END},
],
)
analysis = analyze_control_regions(workflow)
assert (ControlRegionIssueKind.INVALID_FOREACH_RETURN, "edges[2]") in [
(issue.kind, issue.path) for issue in analysis.issues
]
def test_entering_sibling_foreach_body_is_region_conflict() -> None:
workflow = _workflow(
start="f1",
nodes=[_foreach("f1"), _foreach("f2"), _node("b1"), _node("b2")],
edges=[
{"from": "f1", "outcome": "loop", "to": "b1"},
{"from": "b1", "outcome": "ok", "to": "b2"},
{"from": "f2", "outcome": "loop", "to": "b2"},
{"from": "b2", "outcome": "ok", "to": "f1"},
{"from": "f1", "outcome": "done", "to": "f2"},
{"from": "f2", "outcome": "done", "to": END},
],
)
analysis = analyze_control_regions(workflow)
assert (ControlRegionIssueKind.FOREACH_REGION_CONFLICT, "nodes[b2]") in [
(issue.kind, issue.path) for issue in analysis.issues
]
def test_empty_foreach_body_is_rejected() -> None:
workflow = _workflow(
start="f",
nodes=[_foreach("f")],
edges=[
{"from": "f", "outcome": "loop", "to": "f"},
{"from": "f", "outcome": "done", "to": END},
],
)
analysis = analyze_control_regions(workflow)
assert (ControlRegionIssueKind.EMPTY_FOREACH_BODY, "edges[0]") in [
(issue.kind, issue.path) for issue in analysis.issues
]
def test_closed_foreach_body_cycle_has_no_return() -> None:
workflow = _workflow(
start="f",
nodes=[_foreach("f"), _node("a"), _node("b")],
edges=[
{"from": "f", "outcome": "loop", "to": "a"},
{"from": "a", "outcome": "ok", "to": "b"},
{"from": "b", "outcome": "ok", "to": "a"},
{"from": "f", "outcome": "done", "to": END},
],
)
analysis = analyze_control_regions(workflow)
assert (ControlRegionIssueKind.FOREACH_BODY_NO_RETURN, "nodes[f]") in [
(issue.kind, issue.path) for issue in analysis.issues
]
def test_foreach_body_cannot_target_end_token() -> None:
workflow = _workflow(
start="f",
nodes=[_foreach("f"), _node("body")],
edges=[
{"from": "f", "outcome": "loop", "to": "body"},
{"from": "body", "outcome": "ok", "to": END},
{"from": "f", "outcome": "done", "to": END},
],
)
analysis = analyze_control_regions(workflow)
assert (ControlRegionIssueKind.INVALID_FOREACH_TERMINAL, "edges[1]") in [
(issue.kind, issue.path) for issue in analysis.issues
]
def test_foreach_body_cannot_target_explicit_end_node() -> None:
workflow = _workflow(
start="f",
nodes=[
_foreach("f"),
_node("body"),
{"id": "stop", "type": "end", "outcome": "ok"},
],
edges=[
{"from": "f", "outcome": "loop", "to": "body"},
{"from": "body", "outcome": "ok", "to": "stop"},
{"from": "f", "outcome": "done", "to": END},
],
)
analysis = analyze_control_regions(workflow)
assert (ControlRegionIssueKind.INVALID_FOREACH_TERMINAL, "edges[1]") in [
(issue.kind, issue.path) for issue in analysis.issues
]
def test_every_unreachable_node_is_reported() -> None:
workflow = _workflow(
start="work",
nodes=[_node("work"), _node("detached_a"), _node("detached_b")],
edges=[
{"from": "work", "outcome": "ok", "to": END},
{"from": "detached_a", "outcome": "ok", "to": "detached_b"},
{"from": "detached_b", "outcome": "ok", "to": "detached_a"},
],
)
analysis = analyze_control_regions(workflow)
by_kind_path = [(issue.kind, issue.path) for issue in analysis.issues]
assert (
ControlRegionIssueKind.UNREACHABLE_NODE,
"nodes[detached_a]",
) in by_kind_path
assert (
ControlRegionIssueKind.UNREACHABLE_NODE,
"nodes[detached_b]",
) in by_kind_path