fix(workflows): reject a condition that has no {{ }} block - #4182
fix(workflows): reject a condition that has no {{ }} block#4182ntdatt812 wants to merge 5 commits into
Conversation
`evaluate_condition` resolves its argument through `evaluate_expression`,
which only substitutes `{{ ... }}` blocks. A string with no such block
comes back unchanged and — unless it reads `true`/`false` — is then
coerced by `bool()`. So a condition authored without the braces is never
evaluated at all:
evaluate_condition("inputs.count > 100", ctx) -> True
evaluate_condition("{{ inputs.count > 100 }}", ctx) -> False
with `inputs.count == 5` in both cases. An `if` step always takes `then`,
and a `while`/`do-while` step always runs to `max_iterations` — ten agent
invocations for a loop the author expected to stop.
This is the same silent-truthiness authoring mistake the three step
validators already reject for a list/dict/number condition, and it is
easier to make: GitHub Actions accepts a bare expression in `if:`, so the
brace-less form is a habit to bring here.
Adds `condition_is_never_evaluated()` and wires it into the `if`,
`while` and `do-while` validators, so the mistake surfaces at validation
with the corrected form spelled out. Boolean literals, real bools, empty
strings and any string containing `{{` stay valid — runtime behaviour is
unchanged.
There was a problem hiding this comment.
Pull request overview
Adds validation to reject brace-less workflow conditions that would otherwise always evaluate as true.
Changes:
- Adds a shared condition-validation helper.
- Integrates validation into
if,while, anddo-whilesteps. - Adds runtime and validator regression tests.
Show a summary per file
| File | Description |
|---|---|
src/specify_cli/workflows/expressions.py |
Adds condition detection helper. |
src/specify_cli/workflows/steps/if_then/__init__.py |
Validates if conditions. |
src/specify_cli/workflows/steps/while_loop/__init__.py |
Validates while conditions. |
src/specify_cli/workflows/steps/do_while/__init__.py |
Validates do-while conditions. |
tests/unit/test_condition_expression_block.py |
Adds regression coverage. |
Review details
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Files reviewed: 5/5 changed files
- Comments generated: 4
- Review effort level: Balanced
| stripped = condition.strip() | ||
| if not stripped or stripped.lower() in ("true", "false"): | ||
| return False | ||
| return "{{" not in stripped |
| f"If step {config.get('id', '?')!r}: 'condition' " | ||
| f"{config['condition']!r} has no '{{{{ }}}}' block, so it is never " | ||
| "evaluated and is always true. Wrap the expression: " | ||
| '"{{ ' + str(config["condition"]).strip() + ' }}".' |
| f"While step {config.get('id', '?')!r}: 'condition' " | ||
| f"{config['condition']!r} has no '{{{{ }}}}' block, so it is never " | ||
| "evaluated and is always true. Wrap the expression: " | ||
| '"{{ ' + str(config["condition"]).strip() + ' }}".' |
| f"Do-while step {config.get('id', '?')!r}: 'condition' " | ||
| f"{config['condition']!r} has no '{{{{ }}}}' block, so it is never " | ||
| "evaluated and is always true. Wrap the expression: " | ||
| '"{{ ' + str(config["condition"]).strip() + ' }}".' |
Two gaps in the condition validator, both raised in review.
An opening `{{` with no `}}` after it is never substituted either:
_interpolate_expressions takes its `raw_close == -1` branch and appends
the tail verbatim. So `condition: "{{ inputs.count > 100"` -- and the
reversed `"}} inputs.count > 100 {{"`, whose only `{{` is last -- come
back unchanged and are coerced to true exactly like a brace-less string.
The helper now looks for a complete block rather than an opening one.
The suggested correction was interpolated into a double-quoted scalar,
so a condition containing a double quote produced YAML that does not
parse: `condition: "{{ inputs.name == "zzz" }}"` raises a ParserError.
format_condition_correction() now picks the quoting from the content and
drops a stray delimiter instead of nesting a second one, so the message
stays paste-ready. All three validators share it.
Tests: 30 more cases -- the incomplete forms, and a YAML round trip over
conditions holding single quotes, double quotes, both, and backslashes,
asserting each correction loads back exactly and is not re-flagged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Both review points were correct — thanks. Fixed in c0c291c. 1. Unterminated / reversed delimiters slipped through Confirmed against the interpolator rather than assumed.
The helper now requires a complete block: it finds the first 2. The correction was not valid YAML Reproduced exactly as described — Added
Tests — the file goes from 40 to 70 cases. The new ones cover the incomplete forms, and run every correction through
|
| # An opening ``{{`` with no ``}}`` anywhere after it is never substituted | ||
| # either: ``_interpolate_expressions`` takes its ``raw_close == -1`` branch | ||
| # and appends the tail verbatim. So ``{{ inputs.count > 100`` -- and the | ||
| # reversed ``}} inputs.count > 100 {{``, whose only ``{{`` is last -- come | ||
| # back unchanged and are just as silently true as a brace-less string. | ||
| return stripped.find("}}", open_at + 2) == -1 |
| if '"' not in wrapped and "\\" not in wrapped: | ||
| return '"' + wrapped + '"' | ||
| if "'" not in wrapped: | ||
| return "'" + wrapped + "'" | ||
| return '"' + wrapped.replace("\\", "\\\\").replace('"', '\\"') + '"' |
mnriem
left a comment
There was a problem hiding this comment.
Please address Copilot feedback
…h json.dumps
Both follow-up review points were right.
The completeness check used a plain `find("}}")`, but the substituter closes a
block with a quote-aware scan. So `condition: "{{ inputs.x == '}}'"` looked
complete to the validator while `_interpolate_expressions` found no close, fell
to its raw-close branch, evaluated a truncated body and left residual text
(`False'`) -- a non-empty string, hence true. Rather than restate the quote
rules a third time, the scan moves out of `_interpolate_expressions` into
`_find_block_close`, which the validator now calls: the check and the
substitution it predicts can no longer disagree. A `}}` that is genuinely
inside a string argument still does not close early, so
`{{ inputs.text | default('}}') }}` and `{{ inputs.x == '}}' }}` stay accepted.
The correction's quoting enumerated the characters it escaped, and the
enumeration was short: a condition loaded from a YAML literal block can carry a
newline, which a double-quoted scalar folds, so the corrected form did not
round-trip. `json.dumps` decides it instead -- every JSON string is a valid
YAML double-quoted scalar and it escapes quotes, backslashes, newlines and the
other control characters. `ensure_ascii=False` keeps a non-ASCII operand
readable rather than expanding it into numeric escapes.
Tests: 70 -> 83. The quoted-delimiter condition joins the incomplete-block set,
and the round-trip set gains multiline, newline-with-quote, tab, carriage
return and non-ASCII operands. All four new cases fail on the previous commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@mnriem — both follow-up findings addressed in 1. The Confirmed exactly as described:
Rather than restate the quote rules a third time in this module, I lifted the scan out of 2. Hand-rolled quoting missed control characters Also reproduced — a condition carrying a newline did not round-trip:
Switched to Tests 70 → 83. The quoted-delimiter condition joins the incomplete-block set; the round-trip set gains multiline, newline-with-quote, tab, carriage return and a non-ASCII operand. All four new cases fail on the previous commit ( Verification on this head: |
There was a problem hiding this comment.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
src/specify_cli/workflows/expressions.py:730
- Non-empty whitespace is not false at runtime.
evaluate_condition()strips only while checking thetrue/falsekeywords and then falls through tobool(result), sobool(" ")isTrue. Excluding it here lets every validator accept a condition that silently takesthenor loops tomax_iterations, exactly the failure this helper is intended to reject. Please distinguish the truly empty string from non-empty whitespace (and update the tests that currently mark whitespace valid), or normalize whitespace in the runtime evaluator.
This issue also appears on line 765 of the same file.
stripped = condition.strip()
if not stripped or stripped.lower() in ("true", "false"):
return False
src/specify_cli/workflows/expressions.py:768
- The correction only removes delimiters at the edges, but the validator also flags malformed delimiters in the middle. For example,
prefix {{ inputs.readyis flagged and corrected to"{{ prefix {{ inputs.ready }}"; likewiseinputs.ready }} suffixretains the inner}}. These suggestions do not evaluate the intended condition and may subsequently evade this validator because they contain a complete outer/first block. Please either sanitize stray delimiters outside quoted operands or avoid offering an automatic correction for malformed-block cases.
core = str(condition).strip()
core = re.sub(r"^\s*(\{\{|\}\})\s*", "", core)
core = re.sub(r"\s*(\{\{|\}\})\s*$", "", core).strip()
return json.dumps("{{ " + core + " }}", ensure_ascii=False)
- Files reviewed: 5/5 changed files
- Comments generated: 0 new
- Review effort level: Balanced
…nesting a block
Two review findings, both reproduced against the code before changing it.
**1. Non-empty whitespace was excluded, and it should not have been.**
The docstring claimed a whitespace condition "coerces to False, which is a
definite answer". That is true only of the empty string. Measured:
evaluate_condition("") -> False
evaluate_condition(" ") -> True
evaluate_condition("\t\n ") -> True
`evaluate_condition` strips only while testing the true/false keywords, then
falls through to `bool()` on the raw string -- and
`test_condition_whitespace_only_string_stays_truthy` pins that on purpose. So
`condition: " "` is exactly the silent always-true this helper exists to
catch, and it was sailing through. Fixed at validation time rather than in the
evaluator, because that runtime behaviour is deliberate.
The empty string stays excluded: it really does coerce to False.
**2. The correction only removed edge delimiters, so it could nest one.**
"prefix {{ inputs.ready" -> "{{ prefix {{ inputs.ready }}"
The suggestion carried an unclosed inner block, and because its *outer* block
was complete, `condition_is_never_evaluated` waved the corrected form straight
back through. Same for a trailing `}}`.
`_strip_stray_delimiters` now removes every delimiter, and is quote-aware for
the reason the rest of this module is: `inputs.x == '}}'` holds a delimiter as
data, and a blanket `re.sub` would eat it and change what the condition
compares. `_find_top_level` could not be reused -- it counts `{`/`}` as bracket
depth, so it never reports a `{{` as a token at all.
"prefix {{ inputs.ready" -> "{{ prefix inputs.ready }}"
"inputs.ready }} suffix" -> "{{ inputs.ready suffix }}"
"{{ inputs.x == '}}'" -> "{{ inputs.x == '}}' }}" (data kept)
'{{ inputs.name == "a b"' -> '{{ inputs.name == "a b" }}' (spacing kept)
Whitespace collapses only where a delimiter was removed; inside a quoted
operand it is untouched.
Tests: the two fixtures that asserted whitespace was valid are corrected, and
five cases added for interior delimiters, quoted delimiters and quoted spacing.
87 pass in tests/unit/test_condition_expression_block.py.
tests/test_workflows.py is 20 failed / 903 passed both with and without this
change -- all twenty are symlink tests that need Windows Developer Mode, and
the counts are identical with the diff stashed.
|
Both suppressed findings in the latest Copilot pass are real. Fixed in 1. Non-empty whitespace was excluded, and it should not have been My docstring claimed a whitespace condition "coerces to
That existing test is why I fixed it at validation time rather than normalizing in the evaluator, which was Copilot's alternative: normalizing would contradict a behaviour the suite intentionally holds. The empty string stays excluded, because it really does coerce to 2. The correction removed only edge delimiters, so it could nest one The suggestion carried an unclosed inner block, and because the outer block was complete,
Whitespace collapses only where a delimiter was removed; inside a quoted operand nothing is touched. One consequence I would rather name than leave for you to find. For a whitespace-only condition there is nothing to wrap, so the message ends Verification.
@mnriem — this is on top of the two findings you raised, which went in as |
There was a problem hiding this comment.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/specify_cli/workflows/expressions.py:752
- This does not always mean the condition is “never evaluated.”
_interpolate_expressionsfalls back to the first raw}}when the quote-aware scan fails and evaluates the truncated body. For example,{{ inputs.missing | default('oops }}reaches_apply_filterand raisesValueError, while this helper returnsTrue, causing all three validators to report that it is always true. Please distinguish genuinely uninterpolated text from malformed blocks that take the raw-close evaluation path, and report the latter as malformed rather than always true.
return _find_block_close(stripped, open_at) == -1
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Balanced
| core = _strip_stray_delimiters(str(condition)).strip() | ||
| return json.dumps("{{ " + core + " }}", ensure_ascii=False) |
…luated
Third review finding, and like the first two it reproduces. `condition_is_never_evaluated`
returned True for any `{{` the quote-aware scan could not close -- but
`_interpolate_expressions` does not treat those alike. Its own comment spells out
two sub-cases, and only one is "never evaluated":
* no raw `}}` in the tail -> the text is emitted verbatim, so bool() makes it
true. Genuinely uninterpolated.
* a raw `}}` further along -> that is used as the close and the truncated body
*is* evaluated.
Measured:
{{ inputs.count > 100 -> True (never evaluated)
}} inputs.count > 100 {{ -> True (never evaluated)
{{ inputs.x == '}}' -> True (raw-close path)
{{ inputs.missing | default('oops }} -> raises ValueError
That last one made the old message wrong on both halves: it is evaluated, and it
does not end up true -- it ends the run in `_apply_filter`.
Adds `condition_has_malformed_expression_block` and gives it its own branch in the
three validators, because the two faults need opposite advice: one says "you forgot
the braces", the other says "your delimiters or quotes do not balance". The two
predicates are mutually exclusive, pinned by a test over every fixture.
The malformed branch deliberately offers **no** paste-ready correction. The fault is
unbalanced quoting, so the quote-aware stripper cannot tell operand from delimiter --
for `{{ inputs.missing | default('oops }}` it emits `"{{ inputs.missing | default('oops }} }}"`,
which is not a fix. This is the same "avoid offering an automatic correction for
malformed-block cases" the reviewer raised earlier; it applies exactly here.
Also renders a blank correction as `"{{ }}"` rather than the double-spaced `"{{ }}"`
that concatenation produced for a whitespace-only condition.
106 pass in tests/unit/test_condition_expression_block.py. Across
tests/test_workflows.py + tests/unit the run is 22 failed / 1189 passed, and 22
failed / 1170 passed with this diff stashed -- identical failures, all Windows
symlink cases, none touching conditions or expressions.
|
Third finding, and it holds too. Fixed in
That last one made the message wrong on both halves: it is evaluated, and it does not end up true — it ends the run in Added The malformed branch deliberately offers no correction, which closes the loop on your earlier point about not auto-correcting malformed-block cases — it applies exactly here. The fault is unbalanced quoting, so the quote-aware stripper cannot tell operand from delimiter: for which is not a fix. Naming the fault beats handing back something that looks authoritative and is not. That also resolves the loose end I flagged last round: a blank correction now renders Verification. 106 pass in @mnriem — that is three review rounds of findings now addressed ( |
There was a problem hiding this comment.
Review details
Suppressed comments (1)
src/specify_cli/workflows/expressions.py:787
- Only the first expression block is inspected. A condition such as
{{ inputs.name }} {{ inputs.missing | default('oops }}is accepted because the first block closes, although the later malformed block raisesValueErrorat runtime (the behavior is already pinned intests/test_workflows.py:301-319). Scan every opener so malformed later blocks fail validation too.
open_at = stripped.find("{{")
if open_at == -1:
return False
if _find_block_close(stripped, open_at) != -1:
return False
return stripped.find("}}", open_at + 2) != -1
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Balanced
| open_at = stripped.find("{{") | ||
| if open_at == -1: | ||
| return True | ||
| # An opening ``{{`` the substituter cannot close is no better than a missing | ||
| # one -- but only when the substituter really does leave it alone. | ||
| # ``_interpolate_expressions`` has two sub-cases when its quote-aware scan | ||
| # fails, and they do not behave alike: with no raw ``}}`` in the tail the | ||
| # block is emitted verbatim (never evaluated, so ``bool()`` makes it true), | ||
| # while a raw ``}}`` further along is used as the close and the truncated | ||
| # body *is* evaluated. Only the first is "never evaluated"; see | ||
| # ``condition_has_malformed_expression_block`` for the second. | ||
| if _find_block_close(stripped, open_at) != -1: | ||
| return False | ||
| return stripped.find("}}", open_at + 2) == -1 |
Defect
evaluate_conditionresolves its argument throughevaluate_expression, which only substitutes{{ ... }}blocks. A string with no such block comes back unchanged, and — unless it readstrue/false— is then coerced bybool(). So a condition authored without the braces is never evaluated at all.With
inputs.count == 5:Every brace-less condition is true, whatever it says. An
ifstep always takesthen; awhile/do-whilestep never terminates on its condition and runs tomax_iterations— ten agent invocations for a loop the author expected to stop after one.Nothing reports it. The workflow validates, runs, and takes the wrong branch silently.
Why this is worth a validation error
The three step validators already reject a list/dict/number condition, and the comment there states the reason exactly:
A brace-less string is the same failure mode, and a likelier mistake: GitHub Actions accepts a bare expression in
if:(if: github.event_name == 'push'), so an author arriving from Actions writes the brace-less form by habit — and unlike[1, 2],condition: inputs.count > 100looks completely correct on the page.Fix
condition_is_never_evaluated()inexpressions.py, wired into theif,whileanddo-whilevalidators. The error names the problem and hands back the corrected form:Validation only — no runtime behaviour changes. Still valid, and covered by tests:
"{{ ... }}"in any position,"true"/"false"in any case, realbools, empty and whitespace strings, and every non-string type (already handled by the branch above).Tests
New
tests/unit/test_condition_expression_block.py, 40 cases:FalsevsTrue— so the defect stays documented even if the validator changesThe same 22 pre-existing failures in both runs — all
symlink_toon Windows without the privilege (OSError: [WinError 1314]), unrelated to this change. Everything added is the +40 new tests.