fix(workflows): reject non-string/non-boolean 'condition' in if/while/do-while steps (#3706)

* fix(workflows): reject non-string 'condition' in if/while/do-while steps

`if_then`, `while_loop`, and `do_while` validate() confirm `condition` is
present but never that it is a string. execute() feeds it to
`evaluate_condition()`, which returns a non-string as-is and takes `bool()`
of it -- so `condition: [1, 2]` (a list authoring mistake) silently resolves
to `True`, branching wrongly / spinning the loop to `max_iterations`, with no
error reported.

Reject a present-but-non-string `condition` at validation, mirroring the
existing prompt/shell/command 'must be a string' guards. `"true"`/`"false"`
and expressions like `"{{ ... }}"` are strings, so they stay valid.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(workflows): describe the evaluate_expression/evaluate_condition split accurately

Address review feedback: the guard comments attributed the non-string
pass-through to evaluate_condition(), which always returns a bool. It is
evaluate_expression() (called by evaluate_condition) that returns a non-string
unchanged; evaluate_condition then applies bool() to that value.

Reword all four sites (if/while/do-while guards + the mirror test comment) to
name the two stages correctly. Comments only -- no behaviour change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(workflows): keep a literal bool 'condition' valid

Self-review catch: the guard rejected EVERY non-string, which broke an input
that previously worked. An unquoted ``condition: false`` is idiomatic YAML and
resolves exactly today -- evaluate_expression passes the bool through and
evaluate_condition's bool() is a no-op (verified: evaluate_condition(False) is
False, (True) is True). The if/while steps even default ``condition`` to the
bool ``False`` themselves, so bool is the field's natural type, not an
authoring mistake.

Accept (str, bool) and reject only the genuinely silent-coercion types
(list/dict/int/float, e.g. condition: [1, 2] is always True). Message updated
to "must be a string or boolean"; the bad-value tests drop True and gain 1.5,
and each step gains a positive bool case.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali jawwad
2026-07-28 21:40:36 +05:00
committed by GitHub
parent 054fb7723d
commit a9c9905400
4 changed files with 117 additions and 0 deletions

View File

@@ -70,6 +70,24 @@ class DoWhileStep(StepBase):
f"Do-while step {config.get('id', '?')!r} is missing "
f"'condition' field."
)
elif not isinstance(config["condition"], (str, bool)):
# The engine re-evaluates 'condition' via evaluate_condition() after
# each iteration. That call first delegates to
# evaluate_expression() -- which returns a non-string unchanged --
# and then coerces the result with bool(). So a list/dict/number
# condition silently resolves to its truthiness (e.g.
# condition: [1, 2] is always truthy, looping to max_iterations)
# with no error. Reject those at validation, mirroring the
# prompt/shell/command 'must be a string' checks.
#
# A literal ``bool`` stays valid: an unquoted ``condition: false``
# is idiomatic YAML and evaluate_condition() already resolves it
# exactly (bool passthrough, then a no-op bool()). "true"/"false"
# and an expression like "{{ ... }}" stay valid too.
errors.append(
f"Do-while step {config.get('id', '?')!r}: 'condition' must be a "
f"string or boolean, got {type(config['condition']).__name__}."
)
max_iter = config.get("max_iterations")
if max_iter is not None:
# bool is a subclass of int, so isinstance(True, int) is True and

View File

@@ -61,6 +61,24 @@ class IfThenStep(StepBase):
errors.append(
f"If step {config.get('id', '?')!r} is missing 'condition' field."
)
elif not isinstance(config["condition"], (str, bool)):
# execute() feeds 'condition' to evaluate_condition(), which first
# delegates to evaluate_expression() -- that returns a non-string
# unchanged -- and then coerces the result with bool(). So a
# list/dict/number condition silently resolves to its truthiness
# (e.g. condition: [1, 2] is always True) with no error, branching
# wrongly on an authoring mistake. Reject those at validation,
# mirroring the prompt/shell/command 'must be a string' checks.
#
# A literal ``bool`` stays valid: an unquoted ``condition: false``
# is idiomatic YAML, evaluate_condition() already resolves it
# exactly (bool passthrough, then a no-op bool()), and this step
# itself defaults ``condition`` to ``False``. "true"/"false" and an
# expression like "{{ ... }}" are strings, so they stay valid too.
errors.append(
f"If step {config.get('id', '?')!r}: 'condition' must be a "
f"string or boolean, got {type(config['condition']).__name__}."
)
if "then" not in config:
errors.append(
f"If step {config.get('id', '?')!r} is missing 'then' field."

View File

@@ -79,6 +79,24 @@ class WhileStep(StepBase):
f"While step {config.get('id', '?')!r} is missing "
f"'condition' field."
)
elif not isinstance(config["condition"], (str, bool)):
# execute() feeds 'condition' to evaluate_condition(), which first
# delegates to evaluate_expression() -- that returns a non-string
# unchanged -- and then coerces the result with bool(). So a
# list/dict/number condition silently resolves to its truthiness
# (e.g. condition: [1, 2] is always truthy, spinning the loop to
# max_iterations) with no error. Reject those at validation,
# mirroring the prompt/shell/command 'must be a string' checks.
#
# A literal ``bool`` stays valid: an unquoted ``condition: false``
# is idiomatic YAML, evaluate_condition() already resolves it
# exactly (bool passthrough, then a no-op bool()), and this step
# itself defaults ``condition`` to ``False``. "true"/"false" and an
# expression like "{{ ... }}" are strings, so they stay valid too.
errors.append(
f"While step {config.get('id', '?')!r}: 'condition' must be a "
f"string or boolean, got {type(config['condition']).__name__}."
)
max_iter = config.get("max_iterations")
if max_iter is not None:
# bool is a subclass of int, so isinstance(True, int) is True and

View File

@@ -2490,6 +2490,33 @@ class TestIfThenStep:
errors = step.validate({"id": "test", "then": []})
assert any("missing 'condition'" in e for e in errors)
@pytest.mark.parametrize("bad", [["a", "b"], {"k": "v"}, 5, 1.5])
def test_validate_rejects_non_string_condition(self, bad):
# A list/dict/number condition is returned unchanged by
# evaluate_expression, and evaluate_condition then bool()-coerces it, so
# it silently resolves to its truthiness (e.g. [1, 2] is always True)
# instead of erroring on the authoring mistake.
from specify_cli.workflows.steps.if_then import IfThenStep
step = IfThenStep()
errors = step.validate({"id": "test", "condition": bad, "then": []})
assert any("'condition' must be a" in e for e in errors), bad
@pytest.mark.parametrize(
"good",
[
"true", "false", "{{ inputs.flag }}",
True, False, # unquoted YAML bool: resolved exactly, and it is the
# default this step itself uses -- must stay valid
],
)
def test_validate_accepts_string_or_bool_condition(self, good):
from specify_cli.workflows.steps.if_then import IfThenStep
step = IfThenStep()
errors = step.validate({"id": "test", "condition": good, "then": []})
assert not any("'condition' must be a" in e for e in errors), good
@pytest.mark.parametrize("bad_branch", [{"id": "x"}, "oops", 5])
def test_execute_non_list_then_fails_loudly(self, bad_branch):
"""A non-list ``then`` must fail the step, not crash the run.
@@ -2880,6 +2907,24 @@ class TestWhileStep:
assert any("missing 'condition'" in e for e in errors)
# max_iterations is optional (defaults to 10)
@pytest.mark.parametrize("bad", [["a", "b"], {"k": "v"}, 5, 1.5])
def test_validate_rejects_non_string_condition(self, bad):
from specify_cli.workflows.steps.while_loop import WhileStep
step = WhileStep()
errors = step.validate({"id": "test", "condition": bad, "steps": []})
assert any("'condition' must be a" in e for e in errors), bad
@pytest.mark.parametrize("good", [True, False, "true", "{{ inputs.go }}"])
def test_validate_accepts_string_or_bool_condition(self, good):
# ``condition: false`` unquoted is idiomatic YAML and is this step's own
# default, so a literal bool must not be rejected.
from specify_cli.workflows.steps.while_loop import WhileStep
step = WhileStep()
errors = step.validate({"id": "test", "condition": good, "steps": []})
assert not any("'condition' must be a" in e for e in errors), good
def test_validate_invalid_max_iterations(self):
from specify_cli.workflows.steps.while_loop import WhileStep
@@ -2994,6 +3039,24 @@ class TestDoWhileStep:
assert any("missing 'condition'" in e for e in errors)
# max_iterations is optional (defaults to 10)
@pytest.mark.parametrize("bad", [["a", "b"], {"k": "v"}, 5, 1.5])
def test_validate_rejects_non_string_condition(self, bad):
from specify_cli.workflows.steps.do_while import DoWhileStep
step = DoWhileStep()
errors = step.validate({"id": "test", "condition": bad, "steps": []})
assert any("'condition' must be a" in e for e in errors), bad
@pytest.mark.parametrize("good", [True, False, "true", "{{ inputs.go }}"])
def test_validate_accepts_string_or_bool_condition(self, good):
# ``condition: false`` unquoted is idiomatic YAML; evaluate_condition
# resolves a literal bool exactly, so it must not be rejected.
from specify_cli.workflows.steps.do_while import DoWhileStep
step = DoWhileStep()
errors = step.validate({"id": "test", "condition": good, "steps": []})
assert not any("'condition' must be a" in e for e in errors), good
def test_validate_steps_not_list(self):
from specify_cli.workflows.steps.do_while import DoWhileStep