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

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

Conversation

@ntdatt812

@ntdatt812 ntdatt812 commented Aug 24, 2026 •

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.

AI disclosure

Per CONTRIBUTING: this pull request (code, tests and description) was developed with Claude Code as a coding agent.

@ntdatt812
ntdatt812 requested a review from mnriem as a code owner August 24, 2026 08:53
@mnriem
mnriem requested a balanced review from Copilot August 31, 2026 18:33

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.

🟡 Changes recommended

The validator incorrectly rejects valid non-boolean string literals used as switch values.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds switch-expression validation to catch unevaluated or malformed expression blocks.

Changes:

  • Reuses existing condition-expression predicates for switch validation.
  • Adds regression and boundary tests.
File summaries
File Description
src/specify_cli/workflows/steps/switch/__init__.py Validates switch expressions.
tests/test_workflows.py Covers invalid and accepted expression forms.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

# Only these two checks 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.
elif condition_is_never_evaluated(config["expression"]):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@ntdatt812
ntdatt812 force-pushed the fix/switch-expression-never-evaluated branch from 37ea908 to 2554a12 Compare September 11, 2026 01:32
@ntdatt812

Copy link
Copy Markdown
Contributor Author

@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.

@mnriem mnriem added the triage-nice-to-have Verdict: evidence-backed fix or greenlit feature — land after review label Sep 14, 2026
@mnriem

mnriem commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

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

@mnriem mnriem added author-needs-disclosure AI use, or the agent/model/settings behind it, not disclosed per CONTRIBUTING author-awaiting Waiting on author response author-needs-tests Real change but missing a regression test — add one that fails before / passes after labels Sep 14, 2026
@ntdatt812
ntdatt812 force-pushed the fix/switch-expression-never-evaluated branch from 2554a12 to 34c6fa9 Compare September 24, 2026 02:14
@ntdatt812

Copy link
Copy Markdown
Contributor Author

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.

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.

Copilot review overview

🟡 Changes recommended

The validation currently rejects valid literal switch keys, and the diagnostics and tests need correction.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 2 Medium severity

Open (2)

@mnriem

mnriem commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

@ntdatt812

Copy link
Copy Markdown
Contributor Author

@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.

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.

Copilot review overview

🔵 Needs a closer look

The new diagnostics misstate possible runtime behavior, and one test does not verify which diagnostic is produced.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
Resolved since last review (1)
Previously missed (3)

In code that hasn't changed since last review

Medium severity Clarify unevaluated reference error without predicting dispatch

src/​specify_cli/​workflows/​step/​switch/​__init__.py:143

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

Low severity Correct raw-close fallback message for evaluation failures

src/​specify_cli/​workflows/​step/​switch/​__init__.py:150

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.

Low severity 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.

@mnriem

mnriem commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

`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.
@ntdatt812
ntdatt812 force-pushed the fix/switch-expression-never-evaluated branch from 9b79e81 to 0e59963 Compare October 29, 2026 03:49
@ntdatt812

Copy link
Copy Markdown
Contributor Author

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.

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.

🟡 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.

3 open issues

Review effort: Max

# same text declared as a case key is a literal the switch really does dispatch, so
# `switch_expression_is_never_evaluated` checks the declared keys too.
_NAMESPACE_REFERENCE = re.compile(
r"(?:%s)(?:\.[\w-]|\[\d)" % "|".join(_NAMESPACE_ROOTS)
Comment on lines +134 to +137
elif switch_expression_is_never_evaluated(
config["expression"],
config["cases"].keys() if isinstance(config.get("cases"), dict) else (),
):
@mnriem

mnriem commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author-awaiting Waiting on author response author-needs-disclosure AI use, or the agent/model/settings behind it, not disclosed per CONTRIBUTING author-needs-tests Real change but missing a regression test — add one that fails before / passes after triage-nice-to-have Verdict: evidence-backed fix or greenlit feature — land after review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants