feat: validate foreach control regions

This commit is contained in:
lda
2026-09-04 07:46:29 +07:00 Verified
parent f23e760b5f
commit b073407441
6 changed files with 123 additions and 14 deletions
@@ -6,6 +6,7 @@ from wf_core.analysis.control_regions import (
ControlRegionIssueKind,
analyze_control_regions,
)
from wf_core.validation.issues import ValidationIssueCode
def _workflow(
@@ -56,6 +57,21 @@ def _condition(node_id: str) -> dict[str, object]:
}
_CONTROL_REGION_CODES = {code.value for code in ControlRegionIssueKind}
def _public_control_errors(workflow: Workflow) -> list[tuple[str, str]]:
return [
(issue.code.value, issue.path)
for issue in workflow.validate_structure().errors
if issue.code.value in _CONTROL_REGION_CODES
]
def _assert_no_public_control_errors(workflow: Workflow) -> None:
assert _public_control_errors(workflow) == []
def test_closed_root_cycle_has_one_empty_control_region() -> None:
workflow = _workflow(
start="a",
@@ -70,6 +86,7 @@ def test_closed_root_cycle_has_one_empty_control_region() -> None:
assert analysis.issues == ()
assert analysis.owner_stack_by_node == {"a": (), "b": ()}
_assert_no_public_control_errors(workflow)
def test_foreach_cycle_with_possible_return_is_valid() -> None:
@@ -89,6 +106,7 @@ def test_foreach_cycle_with_possible_return_is_valid() -> None:
assert analysis.issues == ()
assert analysis.owner_stack_by_node["a"] == ("f",)
assert analysis.owner_stack_by_node["f"] == ()
_assert_no_public_control_errors(workflow)
def test_conditional_foreach_paths_can_both_return() -> None:
@@ -109,6 +127,7 @@ def test_conditional_foreach_paths_can_both_return() -> None:
assert analysis.issues == ()
assert analysis.owner_stack_by_node["condition"] == ("f",)
assert analysis.owner_stack_by_node["work"] == ("f",)
_assert_no_public_control_errors(workflow)
def test_nested_foreach_assigns_static_owner_stacks() -> None:
@@ -142,6 +161,7 @@ def test_nested_foreach_assigns_static_owner_stacks() -> None:
"after": (),
}
assert analysis.issues == ()
_assert_no_public_control_errors(workflow)
def test_reentering_completed_foreach_keeps_one_static_region() -> None:
@@ -163,6 +183,7 @@ def test_reentering_completed_foreach_keeps_one_static_region() -> None:
assert analysis.owner_stack_by_node["f"] == ()
assert analysis.owner_stack_by_node["work"] == ("f",)
assert analysis.owner_stack_by_node["again"] == ()
_assert_no_public_control_errors(workflow)
def test_external_entry_into_foreach_body_is_region_conflict() -> None:
@@ -184,6 +205,12 @@ def test_external_entry_into_foreach_body_is_region_conflict() -> None:
(issue.kind, issue.path) for issue in analysis.issues
]
assert "b" not in analysis.owner_stack_by_node
matching = [
issue
for issue in workflow.validate_structure().errors
if issue.code == ValidationIssueCode.FOREACH_REGION_CONFLICT
]
assert matching[0].path == "nodes[b]"
def test_foreach_body_escape_is_region_conflict() -> None:
@@ -204,6 +231,12 @@ def test_foreach_body_escape_is_region_conflict() -> None:
(issue.kind, issue.path) for issue in analysis.issues
]
assert "after" not in analysis.owner_stack_by_node
matching = [
issue
for issue in workflow.validate_structure().errors
if issue.code == ValidationIssueCode.FOREACH_REGION_CONFLICT
]
assert matching[0].path == "nodes[after]"
def test_skipping_inner_foreach_owner_is_invalid_return() -> None:
@@ -224,6 +257,12 @@ def test_skipping_inner_foreach_owner_is_invalid_return() -> None:
assert (ControlRegionIssueKind.INVALID_FOREACH_RETURN, "edges[2]") in [
(issue.kind, issue.path) for issue in analysis.issues
]
matching = [
issue
for issue in workflow.validate_structure().errors
if issue.code == ValidationIssueCode.INVALID_FOREACH_RETURN
]
assert matching[0].path == "edges[2]"
def test_entering_sibling_foreach_body_is_region_conflict() -> None:
@@ -245,6 +284,12 @@ def test_entering_sibling_foreach_body_is_region_conflict() -> None:
assert (ControlRegionIssueKind.FOREACH_REGION_CONFLICT, "nodes[b2]") in [
(issue.kind, issue.path) for issue in analysis.issues
]
matching = [
issue
for issue in workflow.validate_structure().errors
if issue.code == ValidationIssueCode.FOREACH_REGION_CONFLICT
]
assert matching[0].path == "nodes[b2]"
def test_empty_foreach_body_is_rejected() -> None:
@@ -262,6 +307,12 @@ def test_empty_foreach_body_is_rejected() -> None:
assert (ControlRegionIssueKind.EMPTY_FOREACH_BODY, "edges[0]") in [
(issue.kind, issue.path) for issue in analysis.issues
]
matching = [
issue
for issue in workflow.validate_structure().errors
if issue.code == ValidationIssueCode.EMPTY_FOREACH_BODY
]
assert matching[0].path == "edges[0]"
def test_closed_foreach_body_cycle_has_no_return() -> None:
@@ -281,6 +332,12 @@ def test_closed_foreach_body_cycle_has_no_return() -> None:
assert (ControlRegionIssueKind.FOREACH_BODY_NO_RETURN, "nodes[f]") in [
(issue.kind, issue.path) for issue in analysis.issues
]
matching = [
issue
for issue in workflow.validate_structure().errors
if issue.code == ValidationIssueCode.FOREACH_BODY_NO_RETURN
]
assert matching[0].path == "nodes[f]"
def test_foreach_body_cannot_target_end_token() -> None:
@@ -299,6 +356,12 @@ def test_foreach_body_cannot_target_end_token() -> None:
assert (ControlRegionIssueKind.INVALID_FOREACH_TERMINAL, "edges[1]") in [
(issue.kind, issue.path) for issue in analysis.issues
]
matching = [
issue
for issue in workflow.validate_structure().errors
if issue.code == ValidationIssueCode.INVALID_FOREACH_TERMINAL
]
assert matching[0].path == "edges[1]"
def test_foreach_body_cannot_target_explicit_end_node() -> None:
@@ -321,6 +384,12 @@ def test_foreach_body_cannot_target_explicit_end_node() -> None:
assert (ControlRegionIssueKind.INVALID_FOREACH_TERMINAL, "edges[1]") in [
(issue.kind, issue.path) for issue in analysis.issues
]
matching = [
issue
for issue in workflow.validate_structure().errors
if issue.code == ValidationIssueCode.INVALID_FOREACH_TERMINAL
]
assert matching[0].path == "edges[1]"
def test_every_unreachable_node_is_reported() -> None:
@@ -345,3 +414,8 @@ def test_every_unreachable_node_is_reported() -> None:
ControlRegionIssueKind.UNREACHABLE_NODE,
"nodes[detached_b]",
) in by_kind_path
public_by_code_path = [
(issue.code.value, issue.path) for issue in workflow.validate_structure().errors
]
assert ("unreachable_node", "nodes[detached_a]") in public_by_code_path
assert ("unreachable_node", "nodes[detached_b]") in public_by_code_path
+11 -6
View File
@@ -137,16 +137,19 @@ def test_collect_policy_requires_completed_with_errors_edge() -> None:
workflow = _workflow(
item_error={"action": "collect", "collect_to": "state.item_errors"},
edges=[
{"from": "each", "outcome": "loop", "to": END},
{"from": "each", "outcome": "loop", "to": "body"},
{"from": "body", "outcome": "ok", "to": "each"},
{"from": "each", "outcome": "done", "to": END},
],
)
report = validate_workflow(workflow)
assert report.errors
assert report.errors[0].code == "missing_outcome_edge"
assert "completed_with_errors" in report.errors[0].message
matching = [
issue for issue in report.errors if issue.code == "missing_outcome_edge"
]
assert matching
assert "completed_with_errors" in matching[0].message
def test_collect_policy_destination_must_be_declared_array_field() -> None:
@@ -199,11 +202,13 @@ def _workflow(
"over": "state.items",
"as": "item",
"item_error": item_error or {"action": "fail"},
}
},
{"id": "body", "type": "node", "node": "noop"},
],
"edges": edges
or [
{"from": "each", "outcome": "loop", "to": END},
{"from": "each", "outcome": "loop", "to": "body"},
{"from": "body", "outcome": "ok", "to": "each"},
{"from": "each", "outcome": "done", "to": END},
],
"node_defs": [],