diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py index 38a29890ae..8711eb033e 100644 --- a/src/specify_cli/workflows/expressions.py +++ b/src/specify_cli/workflows/expressions.py @@ -224,6 +224,31 @@ 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 _interpolate_expressions(template: str, namespace: dict[str, Any]) -> str: """Substitute every top-level ``{{ ... }}`` block in *template*, quote-aware. @@ -249,20 +274,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 +702,121 @@ 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 + open_at = stripped.find("{{") + if open_at == -1: + return True + # An opening ``{{`` the substituter cannot close is no better than a missing + # one. ``_interpolate_expressions`` closes a block with the same quote-aware + # scan used here, so ``{{ inputs.count > 100`` -- and the reversed + # ``}} inputs.count > 100 {{``, whose only ``{{`` is last -- come back + # verbatim, while ``{{ inputs.x == '}}'`` falls to the raw-close branch and + # leaves residual text (``False'``). All three are non-empty strings that + # ``bool()`` then makes true. A plain ``find("}}")`` would miss the third, + # and would also have to re-derive quote handling this module already owns. + return _find_block_close(stripped, open_at) == -1 + + +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() + return json.dumps("{{ " + core + " }}", 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..4b5428abe8 100644 --- a/src/specify_cli/workflows/steps/do_while/__init__.py +++ b/src/specify_cli/workflows/steps/do_while/__init__.py @@ -5,6 +5,10 @@ from typing import Any from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import ( + condition_is_never_evaluated, + format_condition_correction, +) class DoWhileStep(StepBase): @@ -88,6 +92,20 @@ 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} has no complete '{{{{ }}}}' block, so it is " + "never evaluated and is always true. Wrap the expression: " + + format_condition_correction(config["condition"]) + "." + ) 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..2c154fa956 100644 --- a/src/specify_cli/workflows/steps/if_then/__init__.py +++ b/src/specify_cli/workflows/steps/if_then/__init__.py @@ -5,7 +5,11 @@ 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_is_never_evaluated, + format_condition_correction, + evaluate_condition, +) class IfThenStep(StepBase): @@ -79,6 +83,20 @@ 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} has no complete '{{{{ }}}}' block, so it is " + "never evaluated and is always true. Wrap the expression: " + + format_condition_correction(config["condition"]) + "." + ) 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..9319b99c3d 100644 --- a/src/specify_cli/workflows/steps/while_loop/__init__.py +++ b/src/specify_cli/workflows/steps/while_loop/__init__.py @@ -5,7 +5,11 @@ 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_is_never_evaluated, + format_condition_correction, + evaluate_condition, +) class WhileStep(StepBase): @@ -97,6 +101,20 @@ 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} has no complete '{{{{ }}}}' block, so it is " + "never evaluated and is always true. Wrap the expression: " + + format_condition_correction(config["condition"]) + "." + ) 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..7089ea58d7 --- /dev/null +++ b/tests/unit/test_condition_expression_block.py @@ -0,0 +1,221 @@ +"""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_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 + # The only '}}' sits inside a string operand, so the quote-aware scan finds + # no close. The raw-close fallback then evaluates a truncated body and + # leaves residual text ("False'"), which bool() makes true just the same. + "{{ inputs.x == '}}'", +] + + +@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 + + +@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" }}'