Skip to content

fix(workflows): reject a switch expression that is never evaluated - #4295

Open
ntdatt812 wants to merge 1 commit into
github:mainfrom
ntdatt812:fix/switch-expression-never-evaluated
Open

fix(workflows): reject a switch expression that is never evaluated#4295
ntdatt812 wants to merge 1 commit into
github:mainfrom
ntdatt812:fix/switch-expression-never-evaluated

Conversation

@ntdatt812

Copy link
Copy Markdown
Contributor

The defect

SwitchStep.validate checks only that expression is present (steps/switch/__init__.py:105). The field goes through the same evaluate_expression as a condition, so one written without braces comes back as its own source text.

Measured on main (27f50f7), inputs.mode = "review", cases review / build, plus a default:

expression: inputs.mode
  validate  -> []
  status    -> COMPLETED
  output    -> {'matched_case': '__default__', 'expression_value': 'inputs.mode'}

expression: {{ inputs.mode }}      (control)
  status    -> COMPLETED
  output    -> {'matched_case': 'review', 'expression_value': 'review'}

It matches no case key, falls through to default on every run — or, with no default:, dispatches nothing at all — and still reports COMPLETED. That is exactly the "silent empty result + COMPLETED" wiring bug this file's own cases: guard was written to prevent, quoting its comment, on the field one line above it.

if, while and do-while all run condition_is_never_evaluated and condition_has_malformed_expression_block on their condition. switch ran neither on its expression.

The fix

Reuse those two predicates rather than write a third scan, with switch-appropriate wording.

Only those two apply, deliberately. A switch matches on strings, so a composite key is legitimate here even though the same shape is a fault in a boolean condition:

expression flagged? why
inputs.mode yes never evaluated
{{ inputs.x yes unclosable block
{{ inputs.missing | default('oops }} yes raw-close fallback truncates it
{{ inputs.mode }} no the ordinary form
{{ inputs.a }}-{{ inputs.b }} no composite case key
true, "" no ordinary case keys

The last two rows are the condition-specific exemptions condition_is_never_evaluated already carries. They are harmless on a condition and actively correct here, which is what makes these two predicates safe to reuse on a non-boolean field. There is a test pinning that boundary so a later narrowing cannot quietly reject composite keys.

This is independent of #4292, deliberately: that PR adds a predicate for a condition holding more than one block, which is precisely the shape a switch is allowed to have. It is not applied here.

Verification

Windows, Python 3.11.

tests/test_workflows.py -k Switch                       25 passed  (22 before)
tests/test_workflows.py + test_condition_expression_block.py
  + tests/test_extensions.py                            22 failed, 1731 passed
main, same three files                                  22 failed, 1728 passed

Identical 22 failures on both sides, diff clean — the pre-existing symlink and bash-parity classes on unelevated Windows. 1728 → 1731 is exactly the three cases added.

Mutation-checked, after confirming the edit applied: disabling the never-evaluated branch fails 2 of the 3 new cases, and the composite-key case stays green under it — which is the point, since it does not depend on the branch and still guards the other side.

Tests

tests/test_workflows.py::TestSwitchStep — three cases: the braceless form (asserting the current runtime behaviour first, then the validator), both unclosable shapes, and the composite-key/literal boundary that must stay accepted.

`SwitchStep.validate` checked only that `expression` is present. It goes through
the same `evaluate_expression` as a condition, so one written without braces
comes back as its own source text:

  expression: inputs.mode        ->  expression_value: "inputs.mode"
                                     matched_case:     "__default__"
                                     status:           COMPLETED

It matches no case key, falls through to `default` on every run — or dispatches
nothing at all when there is no default — and still reports COMPLETED. That is
the "silent empty result + COMPLETED" wiring bug this file's own `cases:` guard
was written to prevent, on the field one line above it.

`if`, `while` and `do-while` already run these two predicates on their
`condition`. This reuses them rather than writing a third scan.

Only those two apply. A switch matches on strings, so a composite key such as
`{{ inputs.a }}-{{ inputs.b }}` is legitimate here even though the same shape
would be a fault in a boolean condition — there is a test pinning that, and a
literal `true` and the empty string stay accepted as ordinary case keys for the
same reason.
@ntdatt812
ntdatt812 requested a review from mnriem as a code owner August 24, 2026 08:53
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.

1 participant