Skip to content

Commit ed12de0

Browse files
committed
fix(workflows): fail command-step before dispatch on non-mapping options
Address Copilot review on #3496. The prior guard silently skipped the merge for a non-mapping ``options`` and still called ``_try_dispatch``, so with an installed CLI a malformed step could return ``COMPLETED`` instead of the clean field-specific failure the PR claimed. The regression test also masked this by forcing ``shutil.which`` to ``None``, which made the observed ``FAILED`` unrelated to ``options``. Changes: - ``execute()`` returns a field-named ``FAILED`` result *before* dispatch when ``options`` is not a mapping. Workflow-level defaults are preserved in ``output.options`` and ``dispatched`` is ``False``. - ``test_execute_non_mapping_options_fails_before_dispatch`` (renamed) now mocks ``_try_dispatch`` to a *success* result so an accidental dispatch would surface as ``COMPLETED``, and asserts the dispatch mock is never called and the error string names ``'options'``. - ``test_validate_rejects_non_mapping_options`` covers ``None`` (a bare YAML ``options:``), matching the documented behavior. Refs #3493.
1 parent 3c9aa1f commit ed12de0

2 files changed

Lines changed: 53 additions & 6 deletions

File tree

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

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,25 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
6161
if model and isinstance(model, str) and "{{" in model:
6262
model = evaluate_expression(model, context)
6363

64-
# Merge options (workflow defaults ← step overrides)
64+
# Merge options (workflow defaults ← step overrides). Same rationale as
65+
# 'input': a malformed options fails the step *before dispatch* rather
66+
# than being silently ignored — a silent skip would let an installed
67+
# CLI report ``COMPLETED`` for a malformed step, and ``dict.update``
68+
# would still crash on a list.
6569
options = dict(context.default_options)
6670
step_options = config.get("options", {})
67-
# Same rationale as 'input': a malformed options fails the step rather
68-
# than being silently ignored (which would let an invalid step run and
69-
# apparently complete).
7071
if not isinstance(step_options, dict):
7172
return StepResult(
7273
status=StepStatus.FAILED,
74+
output={
75+
"command": command,
76+
"integration": integration,
77+
"model": model,
78+
"options": options,
79+
"input": resolved_input,
80+
"dispatched": False,
81+
"exit_code": 1,
82+
},
7383
error=(
7484
f"Command step {config.get('id', '?')!r}: 'options' must be a "
7585
f"mapping, got {type(step_options).__name__}."

tests/test_workflows.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -960,11 +960,13 @@ def test_validate_rejects_non_mapping_input_and_options(self):
960960
step = CommandStep()
961961
# execute() does input.items() / options.update(); a non-mapping must be
962962
# reported by validate(), not crash at run time (like switch 'cases').
963+
# ``None`` is included because a bare YAML ``options:`` parses as ``None``.
963964
for bad in (None, "args", ["a", "b"], 5):
964965
errs = step.validate({"id": "c", "command": "/x", "input": bad})
965966
assert any("'input' must be a mapping" in e for e in errs), bad
966-
errs = step.validate({"id": "c", "command": "/x", "options": 42})
967-
assert any("'options' must be a mapping" in e for e in errs)
967+
for bad in (None, "foo", [1, 2], 42):
968+
errs = step.validate({"id": "c", "command": "/x", "options": bad})
969+
assert any("'options' must be a mapping" in e for e in errs), bad
968970
# a valid mapping config is still accepted
969971
assert step.validate({"id": "c", "command": "/x", "input": {"args": "y"}, "options": {"k": 1}}) == []
970972
# execute() has no auto-validation guarantee (the engine may skip
@@ -980,6 +982,41 @@ def test_validate_rejects_non_mapping_input_and_options(self):
980982
assert res_opt.status is StepStatus.FAILED
981983
assert "'options' must be a mapping" in (res_opt.error or "")
982984

985+
def test_execute_non_mapping_options_fails_before_dispatch(self):
986+
"""execute() must fail *before dispatch* when validate() is bypassed.
987+
988+
Silently discarding a non-mapping ``options`` and continuing would
989+
let an installed CLI return exit code 0 and mark the malformed step
990+
``COMPLETED``. Force ``_try_dispatch`` to a success result so an
991+
accidental dispatch would surface as ``COMPLETED`` rather than
992+
being masked by an unrelated CLI-missing failure.
993+
"""
994+
from unittest.mock import patch, MagicMock
995+
from specify_cli.workflows.steps.command import CommandStep
996+
from specify_cli.workflows.base import StepContext, StepStatus
997+
998+
step = CommandStep()
999+
ctx = StepContext(
1000+
default_integration="claude",
1001+
default_options={"max-tokens": 8000},
1002+
project_root="/tmp",
1003+
)
1004+
dispatch_ok = MagicMock(
1005+
return_value={"exit_code": 0, "stdout": "", "stderr": ""}
1006+
)
1007+
with patch.object(CommandStep, "_try_dispatch", dispatch_ok):
1008+
result = step.execute(
1009+
{"id": "t", "command": "/speckit.plan", "options": [1, 2]},
1010+
ctx,
1011+
)
1012+
assert result.status == StepStatus.FAILED
1013+
assert "'options' must be a mapping" in (result.error or "")
1014+
# Dispatch must not be attempted for a malformed options value.
1015+
assert not dispatch_ok.called
1016+
# Workflow defaults preserved; malformed step-level override discarded.
1017+
assert result.output["options"] == {"max-tokens": 8000}
1018+
assert result.output["dispatched"] is False
1019+
9831020
def test_step_override_integration(self):
9841021
from unittest.mock import patch
9851022
from specify_cli.workflows.steps.command import CommandStep

0 commit comments

Comments
 (0)