Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
314 changes: 161 additions & 153 deletions .github/security-audit-requirements.txt

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion docs/reference/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -1204,7 +1204,7 @@ Notes on the newer filters:
- **`to_json` output is deterministic.** Object keys are sorted and non-ASCII characters are left as-is rather than escaped, so the same value always serializes to the same bytes. That buys reproducibility only — it does **not** make the result safe to pass through a shell, because [interpolation adds no quoting or escaping](#interpolation-and-shell-safety). Do not interpolate unconstrained JSON into a `run` field.
- **`to_json` rejects non-finite floats.** `NaN`, `Infinity`, and `-Infinity` raise a `ValueError` naming `to_json` instead of serializing to bare tokens. None of the three is valid JSON, so emitting them would hand a standards-compliant downstream parser a string it must reject.
- **`to_json` requires string mapping keys.** JSON objects have string keys, so a mapping with any other key type — `{1: "a"}`, `{True: "a"}` — raises a `ValueError` naming the key type instead. Left to `json.dumps`, an integer key would be coerced to `"1"` and collide with an existing `"1"` key, while mixed key types would fail `sort_keys` with an ordering `TypeError` reported only as "not JSON-serializable".
- **Comparing filtered counts requires parentheses.** The parser splits on the top-level `|` before looking for operators, so an unparenthesized trailing comparison such as `{{ inputs.items | length > 0 }}` raises a `ValueError`. Parenthesize the filtered operand to compare its result: `{{ (inputs.items | length) > 0 }}`. For a threshold of at least two:
- **Comparing filtered counts requires parentheses.** A filter chain cannot be an operand of an operator, so an unparenthesized trailing comparison such as `{{ inputs.items | length > 0 }}` raises an "ambiguous filter precedence" `ValueError`. Parenthesize the filtered operand to compare its result: `{{ (inputs.items | length) > 0 }}`. For a threshold of at least two:

```yaml
condition: "{{ (inputs.items | length) >= 2 }}"
Expand All @@ -1218,6 +1218,10 @@ Notes on the newer filters:

`and` and `or` return `true` or `false` and evaluate their right side only when the left side does not decide the result: `and` skips it when the left side is false, and `or` skips it when the left side is true. A condition can therefore guard a filter that would fail on an empty value. `{{ inputs.payload != '' and (inputs.payload | from_json | length) }}` is false for an empty optional `payload` instead of failing the run on invalid JSON. When the right side is evaluated, its errors still fail the run.

Operators bind from loosest to tightest as `or`, `and`, `not`, then the comparisons. All comparisons (`==`, `!=`, `>`, `<`, `>=`, `<=`, `in`, `not in`) share one level and are evaluated left to right; none has priority over another. `{{ a == b > 0 }}` therefore means `{{ (a == b) > 0 }}`. To combine comparisons, use parentheses or `and`/`or`.

The body of every `{{ }}` block must be a valid expression. Text that is not one, such as prose (`{{ some text }}`), a key with characters other than letters, digits, `_` and `-` (`{{ inputs.weird:key }}`), or arithmetic (`{{ count + 1 }}`), raises a `ValueError` naming the offending token. Numbers may be written as `5`, `-5`, `+5`, `1_000`, `0.5`, `.5`, `5.`, or `1.5e3`.

Example:

```yaml
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ dependencies = [
"json5>=0.13.0",
"mcp>=2.2.0,<3.0.0",
"pydantic>=2.13.0,<3.0.0",
"lark>=1.3.1,<2.0.0",
]

[project.scripts]
Expand Down
4 changes: 1 addition & 3 deletions src/specify_cli/workflows/expressions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
This package is split by responsibility:

- ``namespace``/``filters``/``operators``/``template`` — shared leaves;
- ``engine_current`` — the hand-written evaluator behind the contract;
- ``engine_lark`` — the Lark (LALR) evaluator behind the contract;
- ``engine`` — the selection point (the one line that swaps the engine);
- ``api`` — the public evaluation entry points;
- ``diagnostics`` — condition validation and remediation.
Expand Down Expand Up @@ -36,7 +36,6 @@
_collect_leaves,
_evaluator_rejects,
_has_incomplete_operand,
_leaf_sink,
)
from .filters import _REGISTERED_FILTERS
from .template import _strip_stray_delimiters
Expand All @@ -57,7 +56,6 @@
"_has_incomplete_operand",
"_has_unbalanced_bracket",
"_has_unbalanced_quote",
"_leaf_sink",
"_strip_stray_delimiters",
"_unresolvable_term",
]
17 changes: 17 additions & 0 deletions src/specify_cli/workflows/expressions/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
_first_unclosable_block,
_has_incomplete_operand,
_is_single_expression,
_syntax_error,
)
from .template import _split_top_level, _strip_stray_delimiters

Expand Down Expand Up @@ -261,6 +262,15 @@ def _unresolvable_term(text: str) -> str | None:
if not stripped:
return "an operand is empty"

# A grammar-based engine can name the parse fault directly, position and
# character included. Prefer that over the leaf walk: text that does not
# parse has no leaves, and reporting "no unresolvable term" for it would
# tell the author their expression is fine. ``None`` for the hand-written
# engine, which resolves unknown shapes to ``None`` instead.
syntax = _syntax_error(stripped)
if syntax is not None:
return syntax

for leaf in _collect_leaves(stripped):
reason = _unresolvable_leaf(leaf)
if reason is not None:
Expand All @@ -285,6 +295,13 @@ def _wrapping_would_not_repair(core: str) -> str | None:
return "its brackets do not balance"
if _has_incomplete_operand(core):
return "an operator in it is missing an operand"
# After the incomplete-operand check, not before: the four "missing an
# operand" shapes are also parse failures, and the specific message is the
# better one for them. Everything left that does not parse (arithmetic,
# stray characters, a second literal) gets the engine's own message.
syntax = _syntax_error(core)
if syntax is not None:
return syntax
unresolvable = _unresolvable_term(core)
if unresolvable is not None:
return unresolvable
Expand Down
6 changes: 3 additions & 3 deletions src/specify_cli/workflows/expressions/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from __future__ import annotations

from .engine_current import (
from .engine_lark import (
_COMPARISON_OPERATORS,
_WORD_OPERATORS,
_collect_leaves,
Expand All @@ -18,7 +18,7 @@
_first_unclosable_block,
_has_incomplete_operand,
_is_single_expression,
_leaf_sink,
_syntax_error,
)

__all__ = [
Expand All @@ -30,5 +30,5 @@
"_first_unclosable_block",
"_has_incomplete_operand",
"_is_single_expression",
"_leaf_sink",
"_syntax_error",
]
Loading
Loading