You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 31928a4
Browse filesBrowse the repository at this point in the historyBrowse files
fix(workflows): validate to_json keys and filter arity before evaluating
Both findings from the latest review round hid the real authoring mistake
behind a less useful error.
to_json relied on sort_keys=True for determinism, so a mapping with
mixed key types raised an ordering TypeError that the generic handler
reported as "not JSON-serializable", while a mapping with a lone integer
key was silently coerced to "1" and could collide with an existing "1"
key. JSON objects have string keys, so keys are validated before
json.dumps is reached and the message names the key type instead. The
walk is iterative with a seen set, leaving circular structures for
json.dumps to report.
split(',', 1) evaluated the argument expression first. The evaluator has
no comma operator, so the fragment evaluated to None and the reported
error was "expected a string separator, got NoneType" -- the extra
argument never surfaced. Arity for the single-argument filters is now
counted on the top-level comma split, before evaluation, so the message
names the filter and the argument count.
Documented in workflows/README.md, workflows/ARCHITECTURE.md, and
docs/reference/workflows.md, with a regression test for each behavior.
Assisted-by: opencode (model: mimo-v2.6-flash-free, autonomous)
| `contains`| `{{ text \| contains('sub') }}` | Substring or membership check |
600
600
| `map` | `{{ list \| map('attr') }}` | Extract an attribute from each item |
601
601
| `from_json`| `{{ out \| from_json }}` | Parse a JSON string into a typed value |
602
-
| `to_json`| `{{ obj \| to_json }}` | Serialize a value to a JSON string — the inverse of `from_json` |
602
+
| `to_json`| `{{ obj \| to_json }}` | Serialize a value to a JSON string — the inverse of `from_json`; mapping keys must be strings |
603
603
| `upper` | `{{ text \| upper }}` | Uppercase a string |
604
604
| `lower` | `{{ text \| lower }}` | Lowercase a string |
605
605
| `split` | `{{ csv \| split(',') }}` | Split a string on a separator into a list of strings |
606
606
| `length` | `{{ items \| length }}` | Number of elements in a list, or characters in a string |
607
607
608
608
Notes on the newer filters:
609
609
610
-
- **Types are validated, not coerced.** `upper` and `lower` accept strings only, `split` requires both a string value and a non-empty string separator, and `length` accepts lists and strings but rejects mappings. Anything else raises a `ValueError` naming the problem. Coercion is deliberately not performed: a type mismatch nearly always means the workflow is wired to the wrong variable, and a coerced result would hide that. A filter given the wrong number of arguments (`| upper('x')`, `| split` with no separator) is reported as a known filter misused, which is distinct from an entirely unknown filter name. The older filters (`join`, `map`, `contains`) are more permissive and unchanged: `join` stringifies unsupported values and `map`/`contains` return fallbacks rather than raising.
610
+
- **Types are validated, not coerced.** `upper` and `lower` accept strings only, `split` requires both a string value and a non-empty string separator, and `length` accepts lists and strings but rejects mappings. Anything else raises a `ValueError` naming the problem. Coercion is deliberately not performed: a type mismatch nearly always means the workflow is wired to the wrong variable, and a coerced result would hide that. A filter given the wrong number of arguments (`| upper('x')`, `| split` with no separator, `| split(',', 1)`) is reported as a known filter misused, which is distinct from an entirely unknown filter name; arity is checked before the argument expression is evaluated, so the extra argument is what gets named. The older filters (`join`, `map`, `contains`) are more permissive and unchanged: `join` stringifies unsupported values and `map`/`contains` return fallbacks rather than raising.
611
611
- **`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.
612
612
- **`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.
613
+
- **`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".
613
614
- **Trailing comparisons after a filter are rejected.** The parser splits on the top-level `|` before looking for operators, so `{{ items | length > 0 }}` raises rather than evaluating. The count does not exist until `length` runs, so there is no way to write that comparison; the supported branching form is the filter's own truthiness in a `condition:`, since `length` returns `0` for an empty input:
**Filter argument strictness (new filters).** The five filters added in #4766 validate their supported inputs and raise `ValueError` naming the problem rather than leaking a Python `TypeError`/`AttributeError`: `upper`/`lower` accept only strings, `split` requires a string value and a non-empty string separator, `length` accepts only lists and strings, and `to_json` requires a JSON-serializable value and additionally rejects non-finite floats (`NaN`, `Infinity`, `-Infinity`), which `json.dumps` would otherwise emit as bare tokens that are not valid JSON. Coercion is deliberately rejected for these filters: a type mismatch almost always means the pipeline is wired to the wrong variable, and a coerced result (e.g. `"3"` for an int) would hide that. A filter used with the wrong arity — `| upper('x')`, `| split` with no separator, `| join` bare — is reported as a known filter misused, distinct from an entirely unknown name.
134
+
**Filter argument strictness (new filters).** The five filters added in #4766 validate their supported inputs and raise `ValueError` naming the problem rather than leaking a Python `TypeError`/`AttributeError`: `upper`/`lower` accept only strings, `split` requires a string value and a non-empty string separator, `length` accepts only lists and strings, and `to_json` requires a JSON-serializable value whose mapping keys are strings, and additionally rejects non-finite floats (`NaN`, `Infinity`, `-Infinity`), which `json.dumps` would otherwise emit as bare tokens that are not valid JSON. Keys are checked before serialization because `json.dumps` would otherwise coerce `1` to `"1"` (colliding with an existing `"1"` key), while `sort_keys=True` raises an ordering `TypeError` on mixed key types — both surfacing as a generic "not JSON-serializable" that hides the authoring mistake. Coercion is deliberately rejected for these filters: a type mismatch almost always means the pipeline is wired to the wrong variable, and a coerced result (e.g. `"3"` for an int) would hide that. Arity is checked before the argument expression is evaluated, so a filter used with the wrong number of arguments — `| upper('x')`, `| split` with no separator, `| split(',', 1)`, `| join` bare — is reported as a known filter misused, distinct from an entirely unknown name.
135
135
136
136
The older filters are more permissive and are unchanged: `join` stringifies unsupported value shapes and elements, and `map`/`contains` return fallback values for unsupported inputs rather than raising.
0 commit comments