Repository navigation
feat(workflows): parse workflow expressions with a Lark LALR grammar - #5171
markuswondrak wants to merge 8 commits into
Conversation
…r hook Preparation for a grammar-based engine; behavior-neutral for the hand-written engine. - filters.py: FILTER_TABLE (function, argument form, usage text) is the single source; _REGISTERED_FILTERS, _ZERO_ARG_FILTERS and the "expected one of ..." message are derived from it. The message text is byte-identical to the previous literal in engine_current. - engine contract: _syntax_error(text) returns a parse-error message or None. engine_current has no syntax layer and always returns None. - diagnostics.py: prefers _syntax_error over the leaf walk in _unresolvable_term and after the incomplete-operand check in _wrapping_would_not_repair, so a parser's message (with position) reaches the condition diagnostics. No test changes; 1150 passed in the expression and condition test files. Assisted-by: OpenCode (model: claude-opus-5.5, autonomous)
Replace the hand-written block-body parser with a Lark (LALR) grammar and select it in engine.py. The expression language is unchanged (github#5060). - LALR parser with keyword terminal priority. - The ambiguous-precedence guards are a grammar property: a filter chain only applies to an operator-free head, so a pipe after a comparison or after `not` is a parse error ("ambiguous filter precedence"). - The PATH terminal is built from namespace._PLAIN_SEGMENT / _INDEXED_SEGMENT, so path syntax has one definition; the existing drift test now covers the grammar too. - _collect_leaves / _evaluator_rejects are a single static tree walk; the probe run and _leaf_sink are gone from the contract. - _syntax_error returns the parser's message with column, which the condition diagnostics use. - Filters dispatch through FILTER_TABLE. Deliberate test changes, each with its reason in the test: - [1,2,3][1] and an unclosed quote in a condition raise ValueError instead of silently yielding None / False. - Trailing tokens after a filter name, and a comparison after a filter chain, are parse errors ("invalid expression") instead of dispatcher messages; still ValueError. - Three diagnostics fixture rows expect the parser's message. - The probe-only tests are replaced (leaf-walk transparency of groups) or removed (_leaf_sink); imports of engine internals are dropped. Assisted-by: OpenCode (model: claude-opus-5.5, autonomous) Assisted-by: GitHub Copilot CLI (model: Claude Sonnet 5.5, supervised) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Lark engine evaluated both operands of `and`/`or`, which regressed the behavior added in github#4923: a guard such as `inputs.payload != '' and (inputs.payload | from_json | length)` ran `from_json` on an empty string again. Return as soon as the left operand decides the result. Validation walks the parse tree, so skipped operands are still checked there. Assisted-by: GitHub Copilot CLI (model: Claude Sonnet 5.5, supervised) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Lark engine is the only engine behind engine.py, so delete engine_current.py and everything that only it used: the `_leaf_sink` export, `_split_top_level_commas`, `_is_single_list_literal`, `_is_wrapped_in_parens`, `_EXPR_PATTERN` and `_ZERO_ARG_FILTERS`. Rename engine_lark_a2 to engine_lark, pin `lark>=1.3.1,<2.0.0`, and drop the prototype wording from docstrings and test comments. Assisted-by: GitHub Copilot CLI (model: Claude Sonnet 5.5, supervised) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…k engine
Review of the Lark engine against the former hand-written engine found
inputs that now fail or report the wrong problem:
- Number literals accepted by int()/float() before (`.5`, `5.`, `+5`,
`1_000`, `1.5e3`) failed to lex. `1e3` stays invalid, as before.
- `a == not b` no longer parsed; `not` is accepted on the right of a
comparison again and negates only its operand.
- Filters evaluated their arguments before checking the call form, so a
failing argument hid a misused or unknown filter. The documented order
(form first) is restored.
- Condition validation reported an operator after a filter chain
(`items | length > 0`) as "missing an operand", and `a || b` as
"ambiguous filter precedence". The parser's expected-token set now
separates the two.
Documented as deliberate and pinned by tests:
- All comparison operators share one precedence level, left to right
(`a == b > 0` is `(a == b) > 0`). The former engine's scan order gave
them accidental priorities.
- Prose in `{{ }}` and path keys outside `[\w-]` are syntax errors
instead of a None lookup.
Assisted-by: GitHub Copilot CLI (model: Claude Opus 5.5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Please address Copilot feedback and CI/CD errors |
Regenerated with the command documented in CONTRIBUTING.md so the dependency-audit job finds lark in the committed snapshot. The command upgrades, so a few unrelated pins moved to their latest patch releases. Assisted-by: GitHub Copilot (model: Claude Sonnet 5.5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`inputs.mode not 'done'` was reported as an ambiguous filter precedence mistake although it has no filter. The message was inferred from the terminals the parser expected at the failure, and `|` is expected after any plain operand, not only after a filter chain. The grammar now parses `|` as a postfix operator that binds tightest, so a filter chain is a node wherever it appears. `_parse` refuses a tree in which an operator has such a node as a direct operand and names the leftmost one. Parentheses keep their group node, so `(items | length) > 0` stays valid. Accepted inputs and their ASTs are unchanged; malformed text without a misplaced chain now gets the plain positional syntax error. Assisted-by: GitHub Copilot (model: Claude Opus 5.5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Review round 1: both findings addressed
Full suite: 13399 passed, 437 skipped. AI disclosure: posted on behalf of @markuswondrak by GitHub Copilot CLI. Snapshot refresh: Claude Sonnet 5.5, autonomous. Precedence fix, tests and this comment: Claude Opus 5.5, autonomous; the grammar-based approach was chosen by @markuswondrak. |
|
Please address Copilot feedback |
…rect validation docs The operator-first filter-precedence error now names the offending operator and suggests parenthesizing the filtered operand, matching the chain-first message. ARCHITECTURE.md no longer claims condition expressions are syntax-checked at validation time; validators check only delimiters and layout. Assisted-by: GitHub Copilot (model: Claude Sonnet 5.5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
f683475 to
8c6ed22
Compare
|
Review round update, commit 8c6ed22.
Workflow and expression tests pass locally (3210 passed, 30 skipped); AI disclosure: posted on behalf of @markuswondrak by GitHub Copilot CLI (model: Claude Sonnet 5.5, autonomous). The code, documentation, commit and this comment were AI-generated; the contributor has not independently reviewed this round's change. |



Description
Closes #5060. This is the third PR of the delivery plan in the issue, after #5114 (package split) and #5130 (gap tests).
It replaces the hand-written workflow expression parser with a Lark LALR grammar (
engine_lark.py) and deletesengine_current.py. Only the block-body parser changes. The template scanner, namespace resolution, filters, comparison semantics and diagnostics stay in our code.|as a postfix operator that binds tightest, so a filter chain is a tree node wherever it appears, and_parserefuses a tree in which an operator has one as a direct operand.count > limit | default(5)anditems | length > 0both fail with "ambiguous filter precedence", naming the operator. Parentheses keep their group node, so(items | length) > 0is valid. Malformed text without a filter, such asinputs.mode not 'done', gets the plain positional syntax error.namespace._PLAIN_SEGMENTand_INDEXED_SEGMENT._collect_leavesand_evaluator_rejectswalk the parse tree, so the probe evaluation and_leaf_sinkare gone._syntax_errorreports the offending token and column.FILTER_TABLE, which also generates the usage text.lark>=1.3.1,<2.0.0is added to the runtime dependencies. It is pure Python with no transitive dependencies. The grammar is an inline string, and the parser is built once per pair of path patterns.and/orstill skip the right operand when the left one decides, as added in fix(workflows): skip the right side of and/or when the left side decides #4923.Deliberate behavior changes
Each change is documented in
workflows/ARCHITECTURE.mdanddocs/reference/workflows.md, and pinned by tests.a == b > 0is(a == b) > 0. The former engine's operator scan order gave them accidental priorities (==/!=looser than</>/in, repeated comparisons grouped from the right). None of the expressions in this repository combines two comparisons withoutand/or.ValueErrorinstead of resolving toNoneor"". This covers prose inside{{ }}, path keys with characters outside[\w-],[1,2,3][1], and an unclosed quote in a condition.ValueError.Forms the former engine accepted that keep working: number literals in every
int()/float()form it read (.5,5.,+5,1_000,1.5e3), andnoton the right of a comparison (a == not b). As before, a filter's call form is checked before its arguments are evaluated.Testing
uv run specify --help(via the worktree venv:.venv/bin/specify --help)uv sync --extra test && .venv/bin/python -m pytest: 13399 passed, 437 skippedRegression evidence for the parity fixes in the last commit: with only
engine_lark.pyreverted to the previous commit, the new tests fail (22 failures), and they pass with the change. The precedence, strict-input and malformed-number tests pass on both, because they pin the intended behavior. The short-circuit fix is covered by the existingTestExpressions::test_boolean_short_circuits: 3 of its 4 cases fail without the fix.Regression evidence for the filter precedence fix (review round 1): with only
engine_lark.pyreverted, 8 of the new tests fail, and they pass with the change. The nesting and valid-form tests pass on both, because they pin the intended behavior. A fuzz comparison of 300,000 token sequences against the previous engine found the same accepted inputs and identical ASTs; messages changed only for malformed text.AI Disclosure
AI disclosure: AI-generated code, tests, docs and PR text, written under the direction of @markuswondrak, who chose the design and the behavior decisions:
Each commit carries its own
Assisted-by:trailer.