fix: fail loudly when a fan-out 'items' expression does not resolve to a list (#2957)

A non-list result from the items expression is a wiring error (the
template did not resolve to a collection); silently fanning out over
zero items hides it until a confusing downstream failure. Fail the
step with an error naming the expression instead. An explicit empty
list remains valid input.

Fixes #2956
This commit is contained in:
Huy Do
2026-06-17 03:33:11 +07:00
committed by GitHub
parent f20e8ee6f7
commit 36fd5f6f49
2 changed files with 38 additions and 6 deletions

View File

@@ -22,12 +22,28 @@ class FanOutStep(StepBase):
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
items_expr = config.get("items", "[]")
items = evaluate_expression(items_expr, context)
if not isinstance(items, list):
items = []
max_concurrency = config.get("max_concurrency", 1)
step_template = config.get("step", {})
if not isinstance(items, list):
# A non-list here is a wiring error (the expression did not
# resolve to a collection); silently fanning out over zero
# items hides it. An explicit empty list remains valid input.
return StepResult(
status=StepStatus.FAILED,
error=(
f"Fan-out step {config.get('id', '?')!r}: 'items' must "
f"resolve to a list, got {type(items).__name__} from "
f"{items_expr!r}."
),
output={
"items": [],
"max_concurrency": max_concurrency,
"step_template": step_template,
"item_count": 0,
},
)
return StepResult(
status=StepStatus.COMPLETED,
output={

View File

@@ -1475,9 +1475,9 @@ class TestFanOutStep:
assert result.output["item_count"] == 2
assert result.output["max_concurrency"] == 3
def test_execute_non_list_items_resolves_empty(self):
def test_execute_non_list_items_fails_loudly(self):
from specify_cli.workflows.steps.fan_out import FanOutStep
from specify_cli.workflows.base import StepContext
from specify_cli.workflows.base import StepContext, StepStatus
step = FanOutStep()
ctx = StepContext()
@@ -1487,8 +1487,24 @@ class TestFanOutStep:
"step": {"id": "impl", "command": "speckit.implement"},
}
result = step.execute(config, ctx)
assert result.status == StepStatus.FAILED
assert "'items' must resolve to a list" in (result.error or "")
assert result.output["item_count"] == 0
def test_execute_empty_list_items_is_valid(self):
from specify_cli.workflows.steps.fan_out import FanOutStep
from specify_cli.workflows.base import StepContext, StepStatus
step = FanOutStep()
ctx = StepContext(steps={"tasks": {"output": {"task_list": []}}})
config = {
"id": "parallel",
"items": "{{ steps.tasks.output.task_list }}",
"step": {"id": "impl", "command": "speckit.implement"},
}
result = step.execute(config, ctx)
assert result.status == StepStatus.COMPLETED
assert result.output["item_count"] == 0
assert result.output["items"] == []
def test_validate_missing_fields(self):
from specify_cli.workflows.steps.fan_out import FanOutStep