diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py index 38a29890ae..35106758bf 100644 --- a/src/specify_cli/workflows/expressions.py +++ b/src/specify_cli/workflows/expressions.py @@ -224,6 +224,59 @@ def _is_single_expression(stripped: str) -> bool: return True +def _find_block_close(text: str, start: int) -> int: + """Index of the ``}}`` closing the block opened by the ``{{`` at *start*, or -1. + + Quote-aware, so a literal ``}}`` inside a string argument + (``{{ inputs.text | default('}}') }}``) does not close the block early -- + the same rule ``_is_single_expression`` applies. Shared with + ``condition_is_never_evaluated`` so the validator cannot disagree with the + substitution it is predicting. + """ + quote: str | None = None + i = start + 2 + n = len(text) + while i < n: + ch = text[i] + if quote is not None: + if ch == quote: + quote = None + elif ch in ("'", '"'): + quote = ch + elif ch == "}" and i + 1 < n and text[i + 1] == "}": + return i + i += 1 + return -1 + + +def _first_unclosable_block(text: str) -> str | None: + """How ``_interpolate_expressions`` will fail on the first block it cannot + close with the quote-aware scan, or ``None`` when every block closes. + + Returns ``"evaluated"`` when a raw ``}}`` still follows the opener -- the + interpolator falls back to it and evaluates the truncated body, which reaches + the filter parser and raises ``ValueError``. Returns ``"verbatim"`` when no + ``}}`` follows at all -- the tail is emitted unchanged, so it survives into the + result as truthy text. + + Walks blocks exactly the way ``_interpolate_expressions`` does, continuing past + each block that *does* close. Checking only the first opener let a later + unterminated block through both validators: ``{{ true }} and {{ inputs.ready`` + closes its first block, so the scan stopped and reported no fault, while + interpolation leaves ``and {{ inputs.ready`` in the result and ``bool()`` makes + the condition always true. + """ + i = 0 + while True: + start = text.find("{{", i) + if start == -1: + return None + close = _find_block_close(text, start) + if close == -1: + return "evaluated" if text.find("}}", start + 2) != -1 else "verbatim" + i = close + 2 + + def _interpolate_expressions(template: str, namespace: dict[str, Any]) -> str: """Substitute every top-level ``{{ ... }}`` block in *template*, quote-aware. @@ -249,20 +302,7 @@ def _interpolate_expressions(template: str, namespace: dict[str, Any]) -> str: break out.append(template[i:start]) # Scan for the block-closing ``}}`` that is outside any string literal. - j = start + 2 - quote: str | None = None - close = -1 - while j < n: - ch = template[j] - if quote is not None: - if ch == quote: - quote = None - elif ch in ("'", '"'): - quote = ch - elif ch == "}" and j + 1 < n and template[j + 1] == "}": - close = j - break - j += 1 + close = _find_block_close(template, start) if close == -1: # No quote-aware close. Two sub-cases, both kept identical to the old # regex so a malformed template is never silently hidden: @@ -690,3 +730,152 @@ def evaluate_condition(condition: str, context: Any) -> bool: if lower == "true": return True return bool(result) + + +def condition_is_never_evaluated(condition: Any) -> bool: + """True when a string *condition* is silently treated as always-true text. + + ``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 an expression + authored without the braces, e.g. ``condition: inputs.count > 100``, is + never evaluated at all: it is a non-empty string, so the ``if`` step always + takes ``then`` and a ``while``/``do-while`` step always runs to + ``max_iterations``. + + That is the same silent-truthiness authoring mistake the step validators + already reject for a list/dict/number condition, and it is easy to write: + GitHub Actions accepts a bare expression in ``if:``. + + The empty string is excluded — it coerces to ``False``, which is a definite + answer rather than a silent always-true. Non-empty whitespace is *not* + excluded: ``bool(" ")`` is true, and ``evaluate_condition`` strips only + while testing the ``true``/``false`` keywords before falling through to + ``bool()`` on the raw string. That runtime behaviour is pinned deliberately + by ``test_condition_whitespace_only_string_stays_truthy``, so the authoring + mistake has to be caught here instead: ``condition: " "`` always takes + ``then``. + """ + if not isinstance(condition, str): + return False + if condition == "": + return False + stripped = condition.strip() + if not stripped: + return True + if stripped.lower() in ("true", "false"): + return False + if "{{" not in stripped: + 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. + return _first_unclosable_block(stripped) == "verbatim" + + +def condition_has_malformed_expression_block(condition: Any) -> bool: + """True when *condition* holds a ``{{`` block the quote-aware scan cannot close, + but which ``_interpolate_expressions`` still evaluates through its raw-close + fallback. + + This is a different fault from the one + ``condition_is_never_evaluated`` reports, and it deserves a different message. + The block is not skipped: the interpolator takes the first raw ``}}`` after the + opener and evaluates whatever it truncated, so + + {{ inputs.missing | default('oops }} + + reaches ``_apply_filter`` and raises ``ValueError`` at run time. The truncation does + not always raise -- ``{{ inputs.x == '}}'`` evaluates to the residual ``"False'"`` -- + but either way what runs is not what was written, so "never evaluated and always + true" is the wrong report. + + Kept separate from the never-evaluated check rather than folded in, because the + two need opposite advice: one says "you forgot the braces", this one says "your + delimiters or quotes do not balance". + """ + if not isinstance(condition, str): + return False + stripped = condition.strip() + if not stripped or stripped.lower() in ("true", "false"): + return False + return _first_unclosable_block(stripped) == "evaluated" + + +def _strip_stray_delimiters(text: str) -> str: + """Remove every ``{{``/``}}`` that lies outside a quoted operand. + + Quote-aware for the same 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 corrected condition compares against. Whitespace orphaned by a removed + delimiter collapses to one separator so the suggestion still reads as an + expression; whitespace inside a quoted operand is never touched. + + ``_find_top_level`` cannot serve here: it counts ``{`` and ``}`` as bracket + depth, so it never reports a ``{{`` as a top-level token at all. + """ + out: list[str] = [] + quote: str | None = None + i = 0 + n = len(text) + while i < n: + ch = text[i] + if quote is not None: + out.append(ch) + if ch == quote: + quote = None + i += 1 + continue + if ch in ("'", '"'): + quote = ch + out.append(ch) + i += 1 + continue + if text.startswith("{{", i) or text.startswith("}}", i): + i += 2 + while i < n and text[i].isspace(): + i += 1 + while out and out[-1].isspace(): + out.pop() + out.append(" ") + continue + out.append(ch) + i += 1 + return "".join(out) + +def format_condition_correction(condition: Any) -> str: + """Render *condition* wrapped in ``{{ }}`` as a quoted, paste-ready YAML scalar. + + The validators hand this back as the corrected form, so it has to survive a + round trip through a YAML parser. A plain ``"{{ ... }}"`` does not: a + condition holding a double quote (``inputs.name == "zzz"``) closes the + scalar early and the workflow file no longer loads. Quoting is therefore + chosen from the content. That enumeration was incomplete: a condition loaded + from a YAML literal block can carry a newline, which a double-quoted scalar + folds, so the correction did not round-trip. + + ``json.dumps`` decides it instead. Every JSON string is a valid YAML + double-quoted scalar, and it escapes the quotes, backslashes, newlines and + other control characters that hand-rolled quoting has to enumerate. + ``ensure_ascii=False`` keeps non-ASCII operands readable rather than + expanding them into numeric escapes. + + A stray delimiter is dropped rather than nested: ``{{ inputs.count > 100`` + corrects to ``"{{ inputs.count > 100 }}"``, not to a doubled ``{{ {{ ... }} }}``. + Every stray delimiter goes, not only the ones sitting at the edges. Trimming + just the edges left ``prefix {{ inputs.ready`` reading + ``"{{ prefix {{ inputs.ready }}"`` -- an unclosed inner block, and one whose + complete *outer* block then carried the correction straight back through + ``condition_is_never_evaluated`` as if it were valid. + """ + core = _strip_stray_delimiters(str(condition)).strip() + # A blank core has nothing to wrap; render the empty block rather than the + # double-spaced "{{ }}" that string concatenation would otherwise produce. + body = "{{ " + core + " }}" if core else "{{ }}" + return json.dumps(body, ensure_ascii=False) diff --git a/src/specify_cli/workflows/steps/do_while/__init__.py b/src/specify_cli/workflows/steps/do_while/__init__.py index 024ced55b5..84921ef556 100644 --- a/src/specify_cli/workflows/steps/do_while/__init__.py +++ b/src/specify_cli/workflows/steps/do_while/__init__.py @@ -5,6 +5,11 @@ from typing import Any from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import ( + condition_has_malformed_expression_block, + condition_is_never_evaluated, + format_condition_correction, +) class DoWhileStep(StepBase): @@ -88,6 +93,34 @@ def validate(self, config: dict[str, Any]) -> list[str]: f"Do-while step {config.get('id', '?')!r}: 'condition' must be a " f"string or boolean, got {type(config['condition']).__name__}." ) + elif condition_is_never_evaluated(config["condition"]): + # A string condition with no ``{{ }}`` block is never evaluated: + # evaluate_expression() returns it unchanged and bool() then makes + # any non-empty text true. `condition: inputs.count > 100` reads as + # a real comparison but always takes every iteration. This is the same + # silent-truthiness mistake the list/dict branch above rejects, and + # GitHub Actions accepts a bare expression in `if:`, so it is easy + # to write by habit. + errors.append( + f"Do-while step {config.get('id', '?')!r}: 'condition' " + f"{config['condition']!r} is not a single complete '{{{{ }}}}' block, so " + "it is never evaluated as an expression and is always true. Wrap the expression: " + + format_condition_correction(config["condition"]) + "." + ) + elif condition_has_malformed_expression_block(config["condition"]): + # Different fault, different advice. Here the block is *not* skipped: + # _interpolate_expressions cannot close it with its quote-aware scan, so it + # falls back to the first raw close and evaluates whatever that truncated. + # `{{ inputs.missing | default('oops }}` reaches the filter parser and raises + # ValueError at run time, so reporting it as "always true" would be wrong + # twice over: it is evaluated, and it does not end up true. + errors.append( + f"Do-while step {config.get('id', '?')!r}: 'condition' " + f"{config['condition']!r} opens a '{{{{' the interpolator cannot " + "close, so it falls back to the first raw '}}' and evaluates a " + "truncated expression instead of the one written. Balance the " + "delimiters and quotes." + ) max_iter = config.get("max_iterations") if max_iter is not None: # bool is a subclass of int, so isinstance(True, int) is True and diff --git a/src/specify_cli/workflows/steps/if_then/__init__.py b/src/specify_cli/workflows/steps/if_then/__init__.py index 7189ff8150..cb74db7b3d 100644 --- a/src/specify_cli/workflows/steps/if_then/__init__.py +++ b/src/specify_cli/workflows/steps/if_then/__init__.py @@ -5,7 +5,12 @@ from typing import Any from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus -from specify_cli.workflows.expressions import evaluate_condition +from specify_cli.workflows.expressions import ( + condition_has_malformed_expression_block, + condition_is_never_evaluated, + format_condition_correction, + evaluate_condition, +) class IfThenStep(StepBase): @@ -79,6 +84,34 @@ def validate(self, config: dict[str, Any]) -> list[str]: f"If step {config.get('id', '?')!r}: 'condition' must be a " f"string or boolean, got {type(config['condition']).__name__}." ) + elif condition_is_never_evaluated(config["condition"]): + # A string condition with no ``{{ }}`` block is never evaluated: + # evaluate_expression() returns it unchanged and bool() then makes + # any non-empty text true. `condition: inputs.count > 100` reads as + # a real comparison but always takes ``then``. This is the same + # silent-truthiness mistake the list/dict branch above rejects, and + # GitHub Actions accepts a bare expression in `if:`, so it is easy + # to write by habit. + errors.append( + f"If step {config.get('id', '?')!r}: 'condition' " + f"{config['condition']!r} is not a single complete '{{{{ }}}}' block, so " + "it is never evaluated as an expression and is always true. Wrap the expression: " + + format_condition_correction(config["condition"]) + "." + ) + elif condition_has_malformed_expression_block(config["condition"]): + # Different fault, different advice. Here the block is *not* skipped: + # _interpolate_expressions cannot close it with its quote-aware scan, so it + # falls back to the first raw close and evaluates whatever that truncated. + # `{{ inputs.missing | default('oops }}` reaches the filter parser and raises + # ValueError at run time, so reporting it as "always true" would be wrong + # twice over: it is evaluated, and it does not end up true. + errors.append( + f"If step {config.get('id', '?')!r}: 'condition' " + f"{config['condition']!r} opens a '{{{{' the interpolator cannot " + "close, so it falls back to the first raw '}}' and evaluates a " + "truncated expression instead of the one written. Balance the " + "delimiters and quotes." + ) if "then" not in config: errors.append( f"If step {config.get('id', '?')!r} is missing 'then' field." diff --git a/src/specify_cli/workflows/steps/while_loop/__init__.py b/src/specify_cli/workflows/steps/while_loop/__init__.py index e80b93d7f2..feda1b334d 100644 --- a/src/specify_cli/workflows/steps/while_loop/__init__.py +++ b/src/specify_cli/workflows/steps/while_loop/__init__.py @@ -5,7 +5,12 @@ from typing import Any from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus -from specify_cli.workflows.expressions import evaluate_condition +from specify_cli.workflows.expressions import ( + condition_has_malformed_expression_block, + condition_is_never_evaluated, + format_condition_correction, + evaluate_condition, +) class WhileStep(StepBase): @@ -97,6 +102,34 @@ def validate(self, config: dict[str, Any]) -> list[str]: f"While step {config.get('id', '?')!r}: 'condition' must be a " f"string or boolean, got {type(config['condition']).__name__}." ) + elif condition_is_never_evaluated(config["condition"]): + # A string condition with no ``{{ }}`` block is never evaluated: + # evaluate_expression() returns it unchanged and bool() then makes + # any non-empty text true. `condition: inputs.count > 100` reads as + # a real comparison but always takes every iteration. This is the same + # silent-truthiness mistake the list/dict branch above rejects, and + # GitHub Actions accepts a bare expression in `if:`, so it is easy + # to write by habit. + errors.append( + f"While step {config.get('id', '?')!r}: 'condition' " + f"{config['condition']!r} is not a single complete '{{{{ }}}}' block, so " + "it is never evaluated as an expression and is always true. Wrap the expression: " + + format_condition_correction(config["condition"]) + "." + ) + elif condition_has_malformed_expression_block(config["condition"]): + # Different fault, different advice. Here the block is *not* skipped: + # _interpolate_expressions cannot close it with its quote-aware scan, so it + # falls back to the first raw close and evaluates whatever that truncated. + # `{{ inputs.missing | default('oops }}` reaches the filter parser and raises + # ValueError at run time, so reporting it as "always true" would be wrong + # twice over: it is evaluated, and it does not end up true. + errors.append( + f"While step {config.get('id', '?')!r}: 'condition' " + f"{config['condition']!r} opens a '{{{{' the interpolator cannot " + "close, so it falls back to the first raw '}}' and evaluates a " + "truncated expression instead of the one written. Balance the " + "delimiters and quotes." + ) max_iter = config.get("max_iterations") if max_iter is not None: # bool is a subclass of int, so isinstance(True, int) is True and diff --git a/tests/unit/test_condition_expression_block.py b/tests/unit/test_condition_expression_block.py new file mode 100644 index 0000000000..7d9d235902 --- /dev/null +++ b/tests/unit/test_condition_expression_block.py @@ -0,0 +1,292 @@ +"""A string condition with no ``{{ }}`` block is never evaluated (always true).""" + +import pytest +import yaml + +from specify_cli.workflows.base import StepContext +from specify_cli.workflows.expressions import ( + condition_has_malformed_expression_block, + condition_is_never_evaluated, + evaluate_condition, + format_condition_correction, +) +from specify_cli.workflows.steps.do_while import DoWhileStep +from specify_cli.workflows.steps.if_then import IfThenStep +from specify_cli.workflows.steps.while_loop import WhileStep + +STEP_CLASSES = [IfThenStep, WhileStep, DoWhileStep] + + +@pytest.mark.parametrize( + "condition", + ["inputs.count > 100", "inputs.name == 'zzz'", "inputs.count < 3"], +) +def test_brace_less_condition_is_always_true_at_runtime(condition): + """The behaviour the validator now warns about, pinned so it cannot drift.""" + ctx = StepContext(inputs={"count": 5, "name": "abc"}) + # Same expression with braces resolves to its real (false) value... + assert evaluate_condition("{{ " + condition + " }}", ctx) is False + # ...without them it is only non-empty text, so bool() makes it true. + assert evaluate_condition(condition, ctx) is True + + +@pytest.mark.parametrize("step_cls", STEP_CLASSES) +def test_validator_rejects_condition_without_expression_block(step_cls): + config = {"id": "s1", "condition": "inputs.count > 100", "then": [], "steps": []} + errors = [e for e in step_cls().validate(config) if "never evaluated" in e] + assert len(errors) == 1 + assert "inputs.count > 100" in errors[0] + # The message hands back the corrected form. + assert '"{{ inputs.count > 100 }}"' in errors[0] + + +@pytest.mark.parametrize("step_cls", STEP_CLASSES) +@pytest.mark.parametrize( + "condition", + ["{{ inputs.count > 100 }}", "true", "false", "TRUE", True, False, ""], +) +def test_validator_accepts_evaluated_and_literal_conditions(step_cls, condition): + """No false positives: braces, boolean literals and bools stay valid.""" + config = {"id": "s1", "condition": condition, "then": [], "steps": []} + assert not [e for e in step_cls().validate(config) if "never evaluated" in e] + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("inputs.count > 100", True), + ("{{ inputs.count > 100 }}", False), + ("prefix {{ inputs.a }} suffix", False), + ("true", False), + ("False", False), + ("", False), + # `bool(" ")` is true and evaluate_condition strips only around the + # true/false keywords, so whitespace is a silent always-true, not a + # definite False. Only "" coerces to False. + (" ", True), + ("\t\n ", True), + (True, False), + (["a"], False), + (3, False), + ], +) +def test_condition_is_never_evaluated(value, expected): + assert condition_is_never_evaluated(value) is expected + + +# --- An unterminated ``{{`` is the same defect, not a different one ----------- +# +# ``_interpolate_expressions`` substitutes nothing when no ``}}`` follows the +# opening ``{{`` (its ``raw_close == -1`` branch appends the tail verbatim), so +# ``{{ inputs.count > 100`` is returned unchanged and coerced to true exactly +# like a brace-less string. + +BACKSLASH = chr(92) + +NEVER_EVALUATED = [ + "inputs.count > 100", # no delimiter at all + "{{ inputs.count > 100", # opened, never closed + "}} inputs.count > 100 {{", # reversed: the only '{{' is last + # A complete block does not vouch for the rest: interpolation leaves the + # second fragment verbatim, and bool() makes the whole string true. + "{{ true }} and {{ inputs.ready", +] + +# A different fault, and the interpolator treats it differently: the quote-aware +# scan finds no close, but a raw '}}' exists further along, so +# _interpolate_expressions falls back to it and *evaluates* the truncated body. +# These are not "never evaluated" -- one leaves residual text that bool() makes +# true, the other reaches the filter parser and raises. +MALFORMED_BLOCKS = [ + "{{ inputs.x == '}}'", + "{{ inputs.missing | default('oops }}", + # Same, but the faulty block is the second one. + "{{ inputs.name }} {{ inputs.missing | default('oops }}", +] + + +@pytest.mark.parametrize("condition", NEVER_EVALUATED) +def test_incomplete_block_is_silently_true_and_is_flagged(condition): + ctx = StepContext(inputs={"count": 5, "name": "abc"}) + assert evaluate_condition(condition, ctx) is True + assert condition_is_never_evaluated(condition) is True + assert condition_has_malformed_expression_block(condition) is False + + +@pytest.mark.parametrize("condition", MALFORMED_BLOCKS) +def test_raw_close_fallback_is_malformed_not_never_evaluated(condition): + """The block *is* evaluated, so it must not be reported as always true.""" + assert condition_has_malformed_expression_block(condition) is True + assert condition_is_never_evaluated(condition) is False + + +def test_a_malformed_block_can_raise_rather_than_be_true(): + """The concrete case the "always true" wording got wrong. + + `default('oops` swallows the real close, the raw-close fallback hands the + filter parser a truncated argument, and the run dies instead of taking a branch. + """ + ctx = StepContext(inputs={"count": 5}) + with pytest.raises(ValueError): + evaluate_condition("{{ inputs.missing | default('oops }}", ctx) + + +@pytest.mark.parametrize("condition", NEVER_EVALUATED + MALFORMED_BLOCKS) +def test_the_two_faults_are_mutually_exclusive(condition): + assert condition_is_never_evaluated(condition) != condition_has_malformed_expression_block(condition) + + +@pytest.mark.parametrize( + "condition", + [ + "{{ inputs.count > 100 }}", + "{{ inputs.a }} and {{ inputs.b }}", + "{{ inputs.text | default('}}') }}", # literal '}}' inside an argument + "{{ inputs.x == '}}' }}", # quoted '}}' then the real close + ], +) +def test_complete_block_is_not_flagged(condition): + assert condition_is_never_evaluated(condition) is False + + +# --- The suggested correction has to survive a YAML round trip --------------- + +TRICKY_CONDITIONS = [ + "inputs.count > 100", + 'inputs.name == "zzz"', # double quote + "inputs.name == 'zzz'", # single quote + 'inputs.a == "x" and inputs.b == \'y\'', # both + "inputs.path == 'C:" + BACKSLASH + "tmp'", # backslash + 'inputs.path == "C:' + BACKSLASH + 'tmp"', # backslash + quote + '{{ inputs.name == "zzz"', # incomplete + quote + "}} inputs.count > 100 {{", + # A YAML literal block hands the loader a real newline; a folded scalar + # would lose it, so the correction has to escape rather than embed it. + "inputs.x == 1\nand inputs.name == 'abc'", + 'he said "hi"\nthen left', # newline + quote + "inputs.a == 'x\ty'", # tab + "inputs.a == 'x\ry'", # carriage return + "inputs.ten == 'mười'", # non-ASCII operand +] + + +@pytest.mark.parametrize("condition", TRICKY_CONDITIONS) +def test_correction_is_valid_yaml_and_round_trips(condition): + """A correction the author cannot paste into their workflow is no correction.""" + loaded = yaml.safe_load("condition: " + format_condition_correction(condition)) + stripped = condition.strip().lstrip("{}").rstrip("{}").strip() + assert loaded["condition"] == "{{ " + stripped + " }}" + + +@pytest.mark.parametrize("condition", TRICKY_CONDITIONS) +def test_correction_does_not_trip_the_validator_again(condition): + loaded = yaml.safe_load("condition: " + format_condition_correction(condition)) + assert condition_is_never_evaluated(loaded["condition"]) is False + + +@pytest.mark.parametrize("condition", ["{{ inputs.count > 100", "}} a > 1 {{"]) +def test_correction_replaces_a_stray_delimiter_instead_of_nesting_one(condition): + corrected = format_condition_correction(condition) + assert "{{ {{" not in corrected and "}} }}" not in corrected + assert corrected.count("{{") == 1 and corrected.count("}}") == 1 + + +@pytest.mark.parametrize("step_cls", STEP_CLASSES) +@pytest.mark.parametrize("condition", ['inputs.name == "zzz"', "{{ inputs.count > 100"]) +def test_validator_correction_is_yaml_safe(step_cls, condition): + config = {"id": "s1", "condition": condition, "then": [], "steps": []} + errors = [e for e in step_cls().validate(config) if "never evaluated" in e] + assert len(errors) == 1 + suggested = errors[0].split("Wrap the expression: ", 1)[1].rstrip(".") + loaded = yaml.safe_load("condition: " + suggested) + assert condition_is_never_evaluated(loaded["condition"]) is False + + +def test_correction_keeps_non_ascii_readable(): + """ensure_ascii=False: an operand should not turn into numeric escapes.""" + corrected = format_condition_correction("inputs.ten == 'mười'") + assert "mười" in corrected + assert chr(92) + "u" not in corrected + + +def test_whitespace_condition_is_flagged_but_the_empty_string_is_not(): + """Whitespace is the silent always-true this validator exists to catch. + + ``test_condition_whitespace_only_string_stays_truthy`` pins the runtime + behaviour deliberately, so the mistake can only be caught at validation time. + """ + assert evaluate_condition(" ", StepContext()) is True + assert condition_is_never_evaluated(" ") is True + + assert evaluate_condition("", StepContext()) is False + assert condition_is_never_evaluated("") is False + + +@pytest.mark.parametrize( + "condition", + [ + "prefix {{ inputs.ready", + "inputs.ready }} suffix", + "{{ inputs.a }} and {{ inputs.b", + ], +) +def test_correction_removes_an_interior_delimiter_too(condition): + """Trimming only the edges left the correction carrying an inner block. + + ``prefix {{ inputs.ready`` corrected to ``"{{ prefix {{ inputs.ready }}"``, + whose complete outer block then walked back past this very validator. + """ + corrected = format_condition_correction(condition) + inner = yaml.safe_load("condition: " + corrected)["condition"] + assert inner.count("{{") == 1 and inner.count("}}") == 1 + assert inner.startswith("{{ ") and inner.endswith(" }}") + + +def test_correction_keeps_a_delimiter_that_is_quoted_data(): + """``'}}'`` is an operand, not a block, so the stripper must not eat it.""" + corrected = format_condition_correction("{{ inputs.x == '}}'") + inner = yaml.safe_load("condition: " + corrected)["condition"] + assert inner == "{{ inputs.x == '}}' }}" + assert condition_is_never_evaluated(inner) is False + + +def test_correction_preserves_spacing_inside_a_quoted_operand(): + """Whitespace is collapsed only where a delimiter was removed.""" + corrected = format_condition_correction('{{ inputs.name == "a b"') + inner = yaml.safe_load("condition: " + corrected)["condition"] + assert inner == '{{ inputs.name == "a b" }}' + + +@pytest.mark.parametrize("step_cls", STEP_CLASSES) +@pytest.mark.parametrize("condition", MALFORMED_BLOCKS) +def test_validator_reports_malformed_rather_than_always_true(step_cls, condition): + """The two faults need opposite advice, so they must not share a message. + + "never evaluated and is always true" is wrong here on both halves: the + interpolator does evaluate the truncated body, and the result is not + reliably true -- it can raise. + """ + config = {"id": "s1", "condition": condition, "then": [], "steps": []} + errors = [e for e in step_cls().validate(config) if "'condition'" in e] + + assert len(errors) == 1 + assert "never evaluated" not in errors[0] + assert "cannot close" in errors[0] + assert "truncated expression" in errors[0] + + +@pytest.mark.parametrize("step_cls", STEP_CLASSES) +@pytest.mark.parametrize("condition", MALFORMED_BLOCKS) +def test_malformed_message_offers_no_paste_ready_correction(step_cls, condition): + """Deliberately no suggestion for this class. + + The fault is unbalanced delimiters or quotes, so the quote-aware stripper + cannot tell operand from delimiter -- for `{{ inputs.missing | default('oops }}` + it produces `"{{ inputs.missing | default('oops }} }}"`, which is not a fix. + Naming the fault beats handing back something that looks authoritative and + is not. + """ + config = {"id": "s1", "condition": condition, "then": [], "steps": []} + errors = [e for e in step_cls().validate(config) if "'condition'" in e] + assert "Wrap the expression" not in errors[0] + assert errors[0].rstrip().endswith("Balance the delimiters and quotes.")