Skip to content

Commit 36fd5f6

Browse files
authored
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
1 parent f20e8ee commit 36fd5f6

2 files changed

Lines changed: 38 additions & 6 deletions

File tree

src/specify_cli/workflows/steps/fan_out/__init__.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,28 @@ class FanOutStep(StepBase):
2222
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
2323
items_expr = config.get("items", "[]")
2424
items = evaluate_expression(items_expr, context)
25-
if not isinstance(items, list):
26-
items = []
27-
2825
max_concurrency = config.get("max_concurrency", 1)
2926
step_template = config.get("step", {})
3027

28+
if not isinstance(items, list):
29+
# A non-list here is a wiring error (the expression did not
30+
# resolve to a collection); silently fanning out over zero
31+
# items hides it. An explicit empty list remains valid input.
32+
return StepResult(
33+
status=StepStatus.FAILED,
34+
error=(
35+
f"Fan-out step {config.get('id', '?')!r}: 'items' must "
36+
f"resolve to a list, got {type(items).__name__} from "
37+
f"{items_expr!r}."
38+
),
39+
output={
40+
"items": [],
41+
"max_concurrency": max_concurrency,
42+
"step_template": step_template,
43+
"item_count": 0,
44+
},
45+
)
46+
3147
return StepResult(
3248
status=StepStatus.COMPLETED,
3349
output={

tests/test_workflows.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1475,9 +1475,9 @@ def test_execute_with_items(self):
14751475
assert result.output["item_count"] == 2
14761476
assert result.output["max_concurrency"] == 3
14771477

1478-
def test_execute_non_list_items_resolves_empty(self):
1478+
def test_execute_non_list_items_fails_loudly(self):
14791479
from specify_cli.workflows.steps.fan_out import FanOutStep
1480-
from specify_cli.workflows.base import StepContext
1480+
from specify_cli.workflows.base import StepContext, StepStatus
14811481

14821482
step = FanOutStep()
14831483
ctx = StepContext()
@@ -1487,8 +1487,24 @@ def test_execute_non_list_items_resolves_empty(self):
14871487
"step": {"id": "impl", "command": "speckit.implement"},
14881488
}
14891489
result = step.execute(config, ctx)
1490+
assert result.status == StepStatus.FAILED
1491+
assert "'items' must resolve to a list" in (result.error or "")
1492+
assert result.output["item_count"] == 0
1493+
1494+
def test_execute_empty_list_items_is_valid(self):
1495+
from specify_cli.workflows.steps.fan_out import FanOutStep
1496+
from specify_cli.workflows.base import StepContext, StepStatus
1497+
1498+
step = FanOutStep()
1499+
ctx = StepContext(steps={"tasks": {"output": {"task_list": []}}})
1500+
config = {
1501+
"id": "parallel",
1502+
"items": "{{ steps.tasks.output.task_list }}",
1503+
"step": {"id": "impl", "command": "speckit.implement"},
1504+
}
1505+
result = step.execute(config, ctx)
1506+
assert result.status == StepStatus.COMPLETED
14901507
assert result.output["item_count"] == 0
1491-
assert result.output["items"] == []
14921508

14931509
def test_validate_missing_fields(self):
14941510
from specify_cli.workflows.steps.fan_out import FanOutStep

0 commit comments

Comments
 (0)