You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix(workflows): reject a switch expression that is never evaluated - #4295
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:
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.
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.
AI disclosure
Per CONTRIBUTING: this pull request (code, tests and description) was developed with Claude Code as a coding agent.
The reason will be displayed to describe this comment to others. Learn more.
Right: reusing the condition predicate was wrong for a switch. A condition is coerced by bool(), so any braceless text is always true there. A switch matches its value against case keys, and a case key is a literal, so expression: review is a valid (if constant) switch and whitespace strips to the "" key.
Fixed in 2554a12. switch_expression_is_never_evaluated flags braceless text only when it opens by walking into a root _build_namespace supplies (inputs.mode, item[0], context.run_id, ...), and keeps the verbatim-unclosable {{ case.
test_a_literal_expression_stays_accepted runs review, whitespace, approve me and a bare inputs through execute() first, asserts the case each one actually dispatches, then asserts validate() accepts it. inputsX.mode is accepted too: a name that merely starts like a root is not a reference into it.
test_every_namespace_root_written_without_a_block_is_rejected covers every root, plus a filter, a comparison and surrounding whitespace.
@mnriem Copilot's point is addressed in 2554a12 (details in the inline reply), and the branch is rebased onto current main. Ready for re-review.
Evidence:
TestSwitchStep: 24 passed.
Mutations, each one reverted afterwards: restoring the condition predicate fails the literal test; making the braceless branch always false fails 2 tests; dropping the ./[ requirement after the root fails 1.
uvx ruff@0.15.0 check src tests: clean.
Full tests/test_workflows.py + tests/unit on Windows: 22 failures, all symlink-privilege errors (WinError 1314). The same test fails identically on a clean checkout of main.
AI disclosure, per CONTRIBUTING: this change, its tests and this comment were written with Claude Code as a coding agent; the runs above were executed locally.
Thanks @ntdatt812 — the switch-specific check addresses the original concern about ordinary literals such as review, and the added boundary coverage is useful.
One valid literal case is still rejected: expression: inputs.mode with a declared inputs.mode case. execute() matches that case, but the new validator rejects it because the text resembles a namespace reference. Please preserve that valid literal behavior and add a regression checking that validation accepts what execution matches.
Please also refresh the PR description to reflect the new switch-specific helper rather than reuse of both condition predicates. Your Claude Code disclosure is present; please complete it with the model(s) and settings/mode used.
This is triage-nice-to-have. After those corrections, it needs CI and re-review on the updated head.
Drafted for @mnriem by GitHub Copilot (model: GPT-6 Astra).
Rebased onto main (25d43a94). Was CONFLICTING, now clean.
The rebase applied without a textual conflict, but it left the branch broken in a way that only running the tests showed: the five tests here imported SwitchStep from specify_cli.workflows.steps.switch, and the package is workflows.step (singular). The import path was stale from before that rename, so every test in this pull request errored on collection while the diff looked fine. Fixed in place.
uv run --with pytest python -m pytest tests/test_workflows.py: 707 passed. The 5 failures are the symlink tests, which need a privilege Windows does not grant here and fail identically on main.
AI assistance disclosed: this change and this comment were written with an AI coding agent, reviewed and verified by me before posting.
@mnriem Addressed in 9b79e81 — answered in thread on expressions.py.
Copilot's point was correct and narrowing: the guard judged the expression text alone, so expression: inputs.mode was rejected even with an inputs.mode: case declared. That switch really does dispatch that case on every run, because execute matches the resolved value — for a braceless expression, that very text, stripped — against str(case_key). The predicate now takes the declared keys and flags a namespace reference only when none of them declares it, which is the only shape that can do nothing but fall through to default.
The new test runs execute() first and asserts the dispatched case, then withdraws that one key and requires the error back, so the exemption cannot disarm the guard. I mutated the predicate and the call site separately to confirm the test goes red both ways.
One note on the red check: pytest (windows-latest, 3.14) failed on tests/extensions/test_extension_agent_context.py::TestBundledUpdaterPathValidation::test_powershell_script_discovers_nested_plan with subprocess.TimeoutExpired after 30 s spawning powershell.exe. windows-latest, 3.13 passed on the same commit and this diff touches only switch validation, so it is a runner timeout rather than this change. This push re-runs it.
AI assistance disclosed: written with Claude Code; I reviewed and ran the tests and lint myself.
This branch also catches an unclosed block after a valid block, such as {{ inputs.a }}-{{ inputs.b: the first block is interpolated, so the result is not necessarily the literal source text and may match a case. Even a wholly unclosed block can match a declared literal case, and a switch may have no default. The message's “no usable block,” “none of which declares it,” and “falls through ... on every run” claims are therefore misleading. Describe the unevaluated reference without predicting the dispatched branch, and print the opening {{ delimiter literally (the current f-string renders {).
Correct raw-close fallback message for evaluation failures
The raw-close fallback evaluates the truncated block before switch case matching; for {{ inputs.missing | default('oops }} it can raise ValueError instead of matching any case. This message incorrectly promises a match, and the f-string prints { rather than the opening {{. Say that evaluation is attempted and recommend balancing the delimiters and quotes, as the condition validators do.
Test distinct errors for unclosed and raw-close forms
tests/test_workflows.py:3818
This only checks that both malformed forms produce some 'expression' error, so sending the raw-close form down the “never evaluated” path would still pass. These inputs need different guidance: the unclosed tail is emitted verbatim, while the raw-close form tries to evaluate a truncated block. Assert the relevant message for each form so a regression in branch selection is caught.
`expression:` goes through the same `evaluate_expression` as a condition, so one
written without braces comes back as its own source text: `expression: inputs.mode`
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. `if`, `while` and
`do-while` already reject that shape on their `condition`; this is the same fault on
the same evaluator, one step type over, and the hole github#4292 closed one construct over.
A switch matches on strings, which moves both boundaries a condition has:
- A braceless literal is a valid case key. `expression: review` dispatches the
`review:` case and whitespace strips to the `""` key, so only text that opens by
walking into a namespace root is flagged, not every string without braces.
- A composite key such as `{{ inputs.a }}-{{ inputs.b }}` is legitimate here even
though the same shape is a fault in a boolean condition.
- The declared keys settle what the text cannot. `inputs.mode` reads like a
reference, but against a declared `inputs.mode:` case it is a literal the switch
dispatches on every run -- `execute` compares the resolved value, which for a
braceless expression is that very text, stripped, against `str(case_key)`. Only a
reference matching no declared key can do nothing but fall through.
`_NAMESPACE_REFERENCE` sits beside `_NAMESPACE_ROOTS` in `expressions/namespace.py`
so the regex cannot drift from the tuple it is built from.
Rebased by hand rather than with `git rebase`: `expressions.py` became a package
upstream, so the old commits edited a file that no longer exists. The predicate now
lives in `expressions/diagnostics.py` next to `condition_is_never_evaluated`, and
nothing of the previous diff was dropped in the move.
Tests run `execute()` first and assert the case each input really dispatches before
asserting what `validate()` says about it. Reverting the predicate, inverting it, or
passing no keys at the call site each turn a test red; I checked all three.
Rebuilt on current main in 0e59963e — mergeable again. It could not be rebased mechanically, so it is worth saying what moved.
src/specify_cli/workflows/expressions.py is now a package, and git rebase failed with a delete/modify conflict: both of my commits edited a file that no longer exists. Replaying the patch would have meant resolving that by hand anyway, so I reapplied the change onto the new layout instead and squashed the two commits into one, since the second was a review fix to the first and the split no longer corresponds to anything in the tree.
Where it landed:
switch_expression_is_never_evaluated → expressions/diagnostics.py, next to condition_is_never_evaluated, which is the predicate it exists to be the switch-shaped counterpart of.
_NAMESPACE_REFERENCE → expressions/namespace.py, directly below the _NAMESPACE_ROOTS tuple it is built from, so the regex cannot drift from the roots. diagnostics.py already reaches for namespace._NAMESPACE_ROOTS in _unresolvable_leaf, so this follows the access pattern that was already there rather than inventing one.
Re-exported from expressions/__init__.py and added to __all__, alongside the other condition predicates.
Nothing from the reviewed diff was dropped in the move. I checked main first: switch_expression_is_never_evaluated does not exist there, so the gap this closes is still open.
Verification on the new layout:
TestSwitchStep: 26 passed, including the 7 this PR adds.
Three mutations, each turning a test red: remove the declared-key check (the pre-review behaviour), invert it so the guard never fires, and pass () at the call site instead of the real keys.
tests/unit + tests/extensions + tests/test_workflows.py: 1669 passed, 9 failed. All nine are the Windows baseline — 5 symlink tests needing privilege, and 4 bash-script tests under tests/extensions/ that fail the same way on origin/main. uvx ruff@0.15.0 check src tests clean.
AI assistance disclosed: written with Claude Code; I reviewed and ran the tests and lint myself.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Validation misses negative-index expressions, and direct execution bypasses configuration checks, allowing invalid switches to select the default or raise instead of returning a failed result.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
author-awaitingWaiting on author responseauthor-needs-disclosureAI use, or the agent/model/settings behind it, not disclosed per CONTRIBUTINGauthor-needs-testsReal change but missing a regression test — add one that fails before / passes aftertriage-nice-to-haveVerdict: evidence-backed fix or greenlit feature — land after review
3 participants
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
SwitchStep.validatechecks only thatexpressionis present (steps/switch/__init__.py:105). The field goes through the sameevaluate_expressionas a condition, so one written without braces comes back as its own source text.Measured on
main(27f50f7),inputs.mode = "review", casesreview/build, plus adefault:It matches no case key, falls through to
defaulton every run — or, with nodefault:, dispatches nothing at all — and still reports COMPLETED. That is exactly the "silent empty result + COMPLETED" wiring bug this file's owncases:guard was written to prevent, quoting its comment, on the field one line above it.if,whileanddo-whileall runcondition_is_never_evaluatedandcondition_has_malformed_expression_blockon theircondition.switchran neither on itsexpression.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:
inputs.mode{{ inputs.x{{ inputs.missing | default('oops }}{{ inputs.mode }}{{ inputs.a }}-{{ inputs.b }}true,""The last two rows are the condition-specific exemptions
condition_is_never_evaluatedalready 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.
Identical 22 failures on both sides,
diffclean — 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.AI disclosure
Per CONTRIBUTING: this pull request (code, tests and description) was developed with Claude Code as a coding agent.