fix(workflows): if step execute() fails cleanly on a non-list branch#3528
fix(workflows): if step execute() fails cleanly on a non-list branch#3528jawwad-ali wants to merge 3 commits into
Conversation
9ef71fa to
c89db54
Compare
There was a problem hiding this comment.
Pull request overview
Adds runtime validation so malformed if branches fail cleanly instead of crashing workflow execution.
Changes:
- Rejects selected non-list
then/elsebranches. - Adds parameterized tests for strings, mappings, and integers.
Show a summary per file
| File | Description |
|---|---|
src/specify_cli/workflows/steps/if_then/__init__.py |
Adds branch type validation. |
tests/test_workflows.py |
Tests malformed branch handling. |
Review details
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Medium
c89db54 to
5f90f89
Compare
mnriem
left a comment
There was a problem hiding this comment.
Please address Copilot feedback. If not applicable, please explain why
IfThenStep.execute returns config['then']/['else'] directly as StepResult.next_steps with no check that it is a list. The engine does not auto-validate step config before execute(), so an unvalidated run with a non-list branch (a scalar or mapping authoring mistake) passes a non-iterable as next_steps and crashes the whole run. validate() catches this, but execute() should fail the step loudly instead. Add an isinstance(list) guard returning a FAILED StepResult, completing the same guard github#3519 added to the while/do-while steps (and that its comment already references for the 'if' step) and mirroring the switch step's 'cases' guard. Parametrized test feeds a str/dict/int branch and asserts FAILED (fails before: the non-iterable next_steps crashed execution). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The non-list guard turned a valid 'else: null' into a FAILED result on a false condition — but validate() deliberately accepts None as 'no else branch' (test_validate_accepts_valid_else). Normalize a selected None branch to [] before the guard so validation and execution stay consistent; the guard still rejects genuine non-list values (str/dict/int). Test: false condition + else: null now COMPLETES with no next steps (fails before: FAILED 'else must be a list ... NoneType'). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A rebase left two copies of the non-list branch guard in IfThenStep.execute: an earlier block that normalized ANY selected None branch to [] (so a true condition with then: null silently completed instead of failing — validate() rejects a null then) plus a redundant non-list return, followed by the correct branch-specific block. Remove the duplicate earlier block and keep the single guard that normalizes None only for the else branch and rejects every other non-list value. Tests: else: null still COMPLETES (no next steps); then: null now FAILS (not silently normalized). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5f90f89 to
e847931
Compare
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (2)
src/specify_cli/workflows/steps/if_then/init.py:29
- The behavioral guard described by the PR is not part of the current diff: the guard at lines 31–50 is unchanged, while this hunk only swaps two independent assignments and has no runtime effect. The current base also already covers non-list branches at
tests/test_workflows.py:2196–2239, so this PR no longer delivers the stated fix; please rebase and drop the now-redundant changes (or close the PR if the fix landed elsewhere).
branch = config.get("then", [])
branch_name = "then"
else:
branch = config.get("else", [])
branch_name = "else"
tests/test_workflows.py:2160
- This repeats the existing
test_execute_none_else_stays_emptycoverage at lines 2241–2257. Remove the duplicate and keep the established test.
def test_execute_allows_null_else_branch(self):
"""`else: null` is the supported 'no else branch' form (validate accepts
it); a false condition must run cleanly (COMPLETED, no next steps), not
be turned into FAILED by the non-list guard."""
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Medium
| @pytest.mark.parametrize("bad_branch", ["oops", {"a": 1}, 42]) | ||
| def test_execute_rejects_non_list_branch(self, bad_branch): | ||
| """execute() fails cleanly on a non-list then/else branch. The engine | ||
| doesn't auto-validate step config, so a non-iterable next_steps would | ||
| otherwise crash the run — completing the while/do-while guard (#3519) for | ||
| the if step (mirrors the switch step's 'cases' guard).""" | ||
| from specify_cli.workflows.steps.if_then import IfThenStep | ||
| from specify_cli.workflows.base import StepContext, StepStatus | ||
|
|
||
| step = IfThenStep() | ||
| cond = "{{ inputs.scope == 'full' }}" | ||
| res_then = step.execute( | ||
| {"id": "i", "condition": cond, "then": bad_branch}, | ||
| StepContext(inputs={"scope": "full"}), | ||
| ) | ||
| assert res_then.status is StepStatus.FAILED | ||
| assert "'then' must be a list" in (res_then.error or "") | ||
| res_else = step.execute( | ||
| {"id": "i", "condition": cond, "then": [], "else": bad_branch}, | ||
| StepContext(inputs={"scope": "backend"}), | ||
| ) | ||
| assert res_else.status is StepStatus.FAILED | ||
| assert "'else' must be a list" in (res_else.error or "") |
|
Please address Copilot feedback |
|
Closing this as superseded. Since I opened it, the |
What
IfThenStep.executereturnsconfig['then']/['else']directly asStepResult.next_stepswith no check that it's a list:The engine does not auto-validate step config before
execute(), so an unvalidated run with a non-list branch (a scalar or mapping authoring mistake) passes a non-iterable asnext_stepsand crashes the whole run.validate()catches this, butexecute()should fail the step loudly rather than take down the run.Fix
Add an
isinstance(..., list)guard returning aFAILEDStepResult. This completes the same guard #3519 added to thewhile/do-whilesteps — whose comment already references theifstep as a peer — and mirrors theswitchstep'scasesguard.Test
Parametrized
test_execute_rejects_non_list_branchfeeds astr/dict/intbranch and assertsFAILED— fails before (the non-iterablenext_stepscrashed execution), passes after.🤖 Written with the assistance of Claude Code (AI). Bug self-found; fix/tests verified locally (fail-before / pass-after), ruff clean.