Skip to content

fix(workflows): split expression operators across any whitespace - #4801

Open
huiq777 wants to merge 1 commit into
github:mainfrom
huiq777:fix/workflow-expression-whitespace
Open

huiq777 wants to merge 1 commit into
github:mainfrom
huiq777:fix/workflow-expression-whitespace

Conversation

@huiq777

@huiq777 huiq777 commented Sep 30, 2026

Copy link
Copy Markdown

Description

If a workflow condition wraps onto a second line after or, and, in, not in or not, it evaluates to false, with no error.

The evaluator finds word operators by their surrounding spaces (" or ", " and ", " in ", " not in ", expr.startswith("not ")). An operator next to a newline or tab is never split on. The whole expression is then looked up as one dot path and resolves to None.

YAML keeps that line break in the usual way of wrapping a long condition. A | block keeps every break, and > keeps the break before a more-indented continuation line:

steps:
  - id: route
    type: if
    condition: >-
      {{ inputs.skip_review or
         inputs.scope == 'docs' }}
    then:
      - id: fast-path
        type: shell
        run: "echo took the then branch"
    else:
      - id: review
        type: shell
        run: "echo took the else branch"

With skip_review: false and scope: docs the condition is true, but main runs the else branch. specify workflow run validates the file without a warning:

$ specify workflow run wf.yml          # main
  ▸ [route] if …
  ▸ [review] shell …
Status: completed

$ specify workflow run wf.yml          # this branch
  ▸ [route] if …
  ▸ [fast-path] shell …
Status: completed

The same thing happens to while / do-while conditions and to any other {{ }} expression wrapped this way.

Fix: _evaluate_simple_expression now collapses each run of whitespace outside a quoted string to a single space before parsing, as Jinja2 treats any whitespace between tokens alike.

  • Quoted operands keep their text exactly, so inputs.title == 'two spaces' still compares against two spaces.
  • Every entry point goes through this function: the typed fast path, interpolation, filter arguments and the validator's probes. They all see the same normalized text.

The condition gate had pinned the old behaviour. For the bare condition inputs.x == 1\nand inputs.name == 'abc', it withheld the "Wrap the expression" correction, because the wrapped form read False (see the comment in MULTI_POSITION_UNFIXABLE). Wrapping now repairs it, so that case moves to a new test. The test checks that the correction is offered and that the wrapped form evaluates True.

This is a sibling of #4417 (a parenthesised group read as a dot path and resolving to None). The failure mode is the same; the trigger is different.

Testing

  • Tested locally with uv run specify --help
  • Ran existing tests with uv sync && uv run pytest
  • Tested with a sample project (if applicable)

Details:

  • New tests in tests/test_workflows.py::TestExpressions:
    • test_operators_separated_by_any_whitespace: 9 cases covering or / and at the end of a line, a |-style break before or, a tab, not, in, not in split both ways, and CRLF inside a group.
    • test_condition_wrapped_across_lines_in_yaml: the folded scalar above, through yaml.safe_load and evaluate_condition.
    • test_whitespace_inside_quoted_operand_is_kept: a guard for quoted text.
  • New in tests/unit/test_condition_expression_block.py: test_a_condition_wrapped_across_lines_gets_a_paste_ready_correction (If/While/DoWhile).
  • Red on main, green here: with expressions.py reverted, the new cases fail (10 in test_workflows.py and 3 in the gate test). The quoted-text guard passes on both, as intended.
  • The end-to-end run above used the workflow shown.
  • Full suite: 8984 passed, 249 skipped, 14 failed on the first run.
    • 3 were the gate cases above, now updated.
    • 11 were *_python_parity template tests (test_resolve_template_python_parity.py, test_check_prerequisites_python_parity.py, and others). They pass when rerun on their own, both on this branch and on main, and they don't go through the workflow evaluator.
    • After the update, tests/test_workflows.py and tests/unit/test_condition_expression_block.py pass: 1074 tests.
  • uvx ruff@0.15.0 check src tests (the CI pin): all checks passed.

AI Disclosure

  • I did not use AI assistance for this contribution
  • I did use AI assistance (fill in the disclosure below)

AI disclosure: Opened on behalf of @huiq777 with Claude Code (model: Claude Opus 5.5, default settings, autonomous). Claude Code found the bug while probing the expression evaluator for siblings of #4416, #4417 and #4572. It wrote the fix, the tests and this description, and ran the reproduction and the test suite shown above. The commit carries an Assisted-by: trailer.

🤖 Generated with Claude Code

The evaluator matched word operators by their surrounding spaces
(" or ", " and ", " in ", " not in ", a leading "not "), so an operator
next to a newline or tab was never split on. A condition wrapped after
its operator in YAML keeps that line break -- a `|` block keeps every
one, and `>` keeps the break before a more-indented continuation line --
so `{{ inputs.skip_review or\n   inputs.scope == 'docs' }}` was resolved
as a single dot path, came back None, and an `if` step silently took its
`else` branch. Workflow validation accepted it.

Collapse each run of whitespace outside quoted strings to one space
before parsing, as Jinja2 treats any whitespace between tokens alike.
Quoted operands keep their text exactly.

The condition gate had pinned the old behaviour: it withheld the "wrap
the expression" correction for `inputs.x == 1\nand inputs.name == 'abc'`
because the wrapped form read False. Wrapping now repairs it, so the
case moves from MULTI_POSITION_UNFIXABLE to a test that the correction
is offered and evaluates True.

Assisted-by: Claude Code (model: Claude Opus 5.5, autonomous)
Co-Authored-By: Claude Opus 5.5 <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.

Copilot review overview

🟢 Approval recommended

The focused implementation preserves quoted content and has comprehensive regression coverage.

Review effort: Balanced
Findings: None

What changed in this PR

Normalizes unquoted whitespace so multiline workflow expressions parse consistently.

Changes:

  • Collapse whitespace outside quoted strings.
  • Add operator, YAML, quote-preservation, and remediation regressions.
  • Static review only; tests were not independently rerun.
File Description
src/​specify_cli/​workflows/​expressions.py Adds quote-aware whitespace normalization.
tests/​test_workflows.py Covers multiline operators and quoted whitespace.
tests/​unit/​test_condition_expression_block.py Verifies multiline-condition remediation.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants