mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
feat(workflows): add from_json expression filter (#2961)
* feat(workflows): add from_json expression filter Step outputs captured as strings could never become typed values in templates - the filter set was default/join/map/contains only, so e.g. a fan-out items: could never consume a step's JSON stdout. Add an arg-less from_json pipe filter with parse-or-raise semantics: invalid JSON or non-string input raises a clear ValueError rather than passing through silently. Fixes #2960 * fix(expressions): make from_json strict — reject any arguments Address review (#2961): from_json('x') and from_json() previously fell through to a silent passthrough of the unparsed value. Reject any parenthesized form with a clear error so mis-wired templates fail loudly. Rename test to ...parses_object (JSON under test is an object) and add coverage for the strict no-arguments behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(workflows): document the from_json expression filter Address Copilot review: the user-facing filter references omitted the newly added `from_json` filter. Add it to the ARCHITECTURE.md filter table (with the `{{ steps.emit.output.stdout | from_json }}` example) and to the filter enumerations in workflows/README.md and docs/reference/workflows.md so the docs match the evaluator's capabilities. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(workflows): make from_json strictness reject trailing tokens; fix docstring Address Copilot review: - Strictness only rejected parenthesized forms, so typos like `| from_json)` or `| from_json extra` still fell through to the unknown-filter path and silently returned the unparsed value. Match on the leading filter token and require the whole filter to be exactly `from_json`, so every mis-wired form raises. Extend the rejection test to cover the trailing-token cases. - The module docstring claimed "no imports", which is misleading now that the module imports `json`. Reword to state the actual sandbox guarantee: templates cannot do file I/O, import modules, or run arbitrary code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -289,6 +289,59 @@ class TestExpressions:
|
||||
ctx = StepContext(inputs={"text": "hello world"})
|
||||
assert evaluate_expression("{{ inputs.text | contains('world') }}", ctx) is True
|
||||
|
||||
def test_filter_from_json_parses_object(self):
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
ctx = StepContext(
|
||||
steps={"emit": {"output": {"stdout": '{"items": [1, 2, 3]}'}}}
|
||||
)
|
||||
result = evaluate_expression("{{ steps.emit.output.stdout | from_json }}", ctx)
|
||||
assert result == {"items": [1, 2, 3]}
|
||||
|
||||
def test_filter_from_json_invalid_json_raises(self):
|
||||
import pytest
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
ctx = StepContext(steps={"emit": {"output": {"stdout": "not json"}}})
|
||||
with pytest.raises(ValueError, match="from_json: invalid JSON"):
|
||||
evaluate_expression("{{ steps.emit.output.stdout | from_json }}", ctx)
|
||||
|
||||
def test_filter_from_json_non_string_raises(self):
|
||||
import pytest
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
ctx = StepContext(steps={"emit": {"output": {"exit_code": 0}}})
|
||||
with pytest.raises(ValueError, match="expected a JSON string"):
|
||||
evaluate_expression("{{ steps.emit.output.exit_code | from_json }}", ctx)
|
||||
|
||||
def test_filter_from_json_rejects_malformed_forms(self):
|
||||
# `from_json` is strict: no arguments and no trailing tokens. Every
|
||||
# mis-wired form — parenthesized, accidental arg, or trailing
|
||||
# garbage — must raise rather than silently fall through to the
|
||||
# unknown-filter path and return the unparsed value.
|
||||
import pytest
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
ctx = StepContext(steps={"emit": {"output": {"stdout": '{"a": 1}'}}})
|
||||
bad_forms = (
|
||||
"from_json()",
|
||||
"from_json('x')",
|
||||
"from_json ()",
|
||||
"from_json ('x')",
|
||||
"from_json)",
|
||||
"from_json extra",
|
||||
"from_json 'x'",
|
||||
)
|
||||
for bad in bad_forms:
|
||||
with pytest.raises(ValueError, match="from_json: expected"):
|
||||
evaluate_expression(
|
||||
"{{ steps.emit.output.stdout | " + bad + " }}", ctx
|
||||
)
|
||||
|
||||
def test_condition_evaluation(self):
|
||||
from specify_cli.workflows.expressions import evaluate_condition
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
Reference in New Issue
Block a user