Skip to content

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

Open
jawwad-ali wants to merge 3 commits into
github:mainfrom
jawwad-ali:fix/conditional-steps-condition-type-guard
Open

fix(workflows): reject non-string 'condition' in if/while/do-while steps#3706
jawwad-ali wants to merge 3 commits into
github:mainfrom
jawwad-ali:fix/conditional-steps-condition-type-guard

Conversation

@jawwad-ali

Copy link
Copy Markdown
Contributor

Problem

IfThenStep, WhileStep, and DoWhileStep validate() confirm that condition is present but never that it is a string.

At run time execute() feeds condition to evaluate_condition():

def evaluate_expression(template, context):
    if not isinstance(template, str):
        return template   # non-string returned as-is
    ...

def evaluate_condition(condition, context):
    result = evaluate_expression(condition, context)
    ...
    return bool(result)

So a non-string condition is returned unchanged and bool()-coerced. A list/dict/number authoring mistake silently resolves to a truthiness instead of erroring:

  • condition: [1, 2]bool([1, 2])always True → if-branch always taken / while-loop spins to max_iterations.
  • condition: {} → always False, etc.

No error is reported, so the mistake is invisible.

Fix

Reject a present-but-non-string condition in each of the three steps' validate(), mirroring the existing prompt/shell/command 'must be a string' guards. "true"/"false" and expressions like "{{ inputs.flag }}" are strings, so they remain valid — no behaviour change for correct workflows.

Verification

  • New parametrized tests ([list, dict, int, bool]) across all three step classes: fail before the guard (12 failures — the non-string conditions pass validation), pass after.
  • test_validate_accepts_string_condition confirms "true"/"false"/"{{ ... }}" still validate.
  • Full TestIfThenStep/TestWhileStep/TestDoWhileStep: 53 passed.
  • ruff check clean on all changed files.

AI-assisted: authored with Claude Code. I traced the runtime path (evaluate_conditionbool()), confirmed the silent-coercion on main, and verified the guards fail-before/pass-after.

`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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds validation to prevent non-string workflow conditions from being silently truthiness-coerced.

Changes:

  • Validates conditions for if, while, and do-while steps.
  • Adds parameterized regression tests for invalid condition types.
Show a summary per file
File Description
if_then/__init__.py Rejects non-string if conditions.
while_loop/__init__.py Rejects non-string while conditions.
do_while/__init__.py Rejects non-string do-while conditions.
tests/test_workflows.py Covers invalid and valid condition values.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 4/4 changed files
  • Comments generated: 4
  • Review effort level: Medium

Comment thread src/specify_cli/workflows/steps/if_then/__init__.py Outdated
Comment thread src/specify_cli/workflows/steps/while_loop/__init__.py Outdated
Comment thread src/specify_cli/workflows/steps/do_while/__init__.py Outdated
Comment thread tests/test_workflows.py Outdated

@mnriem mnriem left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address Copilot feedback

…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>
@jawwad-ali

Copy link
Copy Markdown
Contributor Author

Good catch — fixed in 6a78c02. You're right that evaluate_condition() always returns a bool; the pass-through happens one level down in evaluate_expression(), which evaluate_condition() calls before applying bool(). I reworded all four sites (the if/while/do-while guards and the mirror test comment) to name the two stages explicitly:

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().

Comments only; no behaviour change. ruff@0.15.0 clean and the condition tests still pass (28).

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants