From a434577f3c0084c73bc72c1a21556c7674f24b69 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 01:25:21 +0000 Subject: [PATCH 1/4] =?UTF-8?q?spec(#130):=20add=20on=5Fresult=20`expressi?= =?UTF-8?q?on:`=20matcher=20(=C2=A75.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a general-purpose expression matcher to `on_result` that reuses the §12.2 condition grammar verbatim — no new syntax. Unlocks branching on step fields other than `response` and `exit_code` (e.g. `stdout`, `stderr`, cross-step references) without inventing a parallel grammar. Decouples producing confidence signals (#128 native runner, #130 logprobs — deferred) from consuming them: once the grammar gains numeric operators, `expression: "{{ step.id.confidence }} >= 0.75"` works without further spec work on on_result. Scope kept small deliberately: - `expression:` supports §12.2 operators only (==, !=, contains, starts_with, ends_with). Numeric comparisons and boolean combinators are explicitly out of scope for v0.3 and tracked as a joint §12.2 + §5.4 extension. - Unresolved template variables abort the pipeline (matches §11 contract); silent non-match is deliberately not an option. - Named matchers (contains:, exit_code:, field:+equals:) remain the recommended form when they fit. Cross-reference added in §12.2 so the shared grammar is discoverable from both sides. --- spec/core/s05-step-specification.md | 59 +++++++++++++++++++++++++++++ spec/core/s12-conditions.md | 4 ++ 2 files changed, 63 insertions(+) diff --git a/spec/core/s05-step-specification.md b/spec/core/s05-step-specification.md index ca6bdc7..ecbc603 100644 --- a/spec/core/s05-step-specification.md +++ b/spec/core/s05-step-specification.md @@ -207,8 +207,67 @@ Rules are evaluated in declared order; the first match fires. Used when differen | `is_empty` | Response is blank or whitespace only. | | `exit_code: N` | Process exit code equals N. Valid on `shell:` sources within `context:` steps only. | | `exit_code: any` | Any non-zero exit code. Valid on `shell:` sources within `context:` steps only. | +| `expression: "EXPR"` | Evaluates a §12.2 condition expression against the session's template state. Fires when the expression is true. See below. | | `always` | Unconditionally fires. | +#### `expression:` — general-purpose template-expression matcher + +> **Implementation status:** specified in this section, not yet implemented. Tracked via #130. + +`expression:` is the escape hatch for cases the named matchers do not cover. Its grammar is **identical to** the condition expression grammar defined in §12.2 — no new syntax, no second implementation. The LHS is template-resolved at runtime against the turn log; the RHS is a literal. + +```yaml +# Exit code comparisons beyond `0` / `any` — e.g. distinguish "test failed" (1) from "crash" (>1). +- id: tests + context: + shell: "cargo test" + on_result: + - expression: "{{ step.tests.exit_code }} == 1" + action: pipeline: ./pipelines/triage-failure.yaml + - exit_code: any + action: abort_pipeline + - exit_code: 0 + action: continue + +# Branch on a field that isn't the step's `response` — here, stderr of a preceding context step. +- id: decide + prompt: ./prompts/decide.md + on_result: + - expression: "{{ step.build.stderr }} contains 'rate limit'" + action: pause_for_human + message: "Upstream rate-limited — human input required." + - always + action: continue +``` + +**Evaluation semantics.** + +| Case | Behaviour | +|---|---| +| Expression evaluates to **true** | Branch matches, `action` fires. | +| Expression evaluates to **false** | Branch does not match, evaluation continues with the next branch. | +| LHS template variable **cannot be resolved** (step did not run, unknown field, etc.) | Pipeline aborts with `ail:template/unresolved-variable`. | +| Expression string **fails to parse** at load time | Pipeline load fails with `ail:config/validation-failed`. The grammar is validated at parse time, not at match time. | + +An `on_result` branch that cannot be evaluated is a pipeline bug, not a silent non-match. This is the same contract the rest of the template system enforces (see §11). + +**Relationship to the named matchers.** + +| Form | When to use | +|---|---| +| `contains:`, `matches:`, `starts_with:`, `is_empty` | Matching the step's `response` — the common case. Keeps YAML terse and intent obvious. | +| `exit_code: N` / `exit_code: any` | Matching `exit_code` of a `shell:` source. | +| `field:` + `equals:` | Matching a named field of validated structured output (§26.4). The only matcher that runs against a parsed JSON value rather than a rendered string — prefer it over `expression:` when the step has an `input_schema`. | +| `expression:` | Anything not covered above — comparisons against `stdout`/`stderr`, non-zero exit codes with specific values, comparisons against other steps' fields, future numeric/confidence signals, etc. | +| `always` | Default / catch-all. | + +Prefer a named matcher when one fits — it reads as intent. Reach for `expression:` when no named matcher expresses the condition cleanly. + +**Why `expression:` is a matcher, not an action.** `on_result` may declare multiple branches; each branch is an independent match→action pair. Putting the predicate on the matcher keeps branches orthogonal and evaluable in declared order, matching how `contains:`, `exit_code:`, and `always` already compose. + +> **Planned extensions (not in v0.3).** +> Numeric comparison operators (`<`, `<=`, `>`, `>=`) and boolean combinators (`&&`, `||`) are deliberately **not** in the v0.3 grammar. They are tracked as a joint extension to §12.2 and `expression:` — both must extend together, since `expression:` is defined as "§12.2 with no additions." The primary consumer is confidence-score gating on native LLM runners (#128, #130), e.g. `expression: "{{ step.review.confidence }} >= 0.75"`. Until then, `expression:` supports the §12.2 string-oriented operators only. + #### Supported actions | Action | Effect | diff --git a/spec/core/s12-conditions.md b/spec/core/s12-conditions.md index 1cf95e6..2f8db30 100644 --- a/spec/core/s12-conditions.md +++ b/spec/core/s12-conditions.md @@ -45,6 +45,10 @@ condition: "{{ step.check.response }} ends_with 'done'" - If the LHS template variable cannot be resolved (e.g. references a step that has not run), the pipeline aborts with a `CONDITION_INVALID` error (`ail:condition/invalid`). - If the condition string is not a recognised named condition and does not contain a supported operator, validation fails with `CONFIG_VALIDATION_FAILED` at parse time. +#### Reuse in `on_result: expression:` + +The `expression:` matcher on `on_result` (§5.4) reuses this grammar without modification. Any valid condition expression is a valid `on_result: expression:` expression and vice versa. When the grammar is extended (e.g. numeric operators for confidence-score gating — #130), the extension applies to both sites simultaneously. + #### Template Variables in Conditions All template variables from §11 are available: From 0c4e6601ee277620f7408a47296d818dc2d06382 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 01:31:08 +0000 Subject: [PATCH 2/4] =?UTF-8?q?spec(#130):=20add=20`matches`=20regex=20ope?= =?UTF-8?q?rator=20to=20=C2=A712.2=20grammar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the shared condition/expression grammar with a `matches` operator so `expression:` (§5.4) is a true superset of the named `matches:` matcher — consistent with how `contains` is available at both sites. Regex semantics chosen for predictability: - Rust `regex` crate syntax (linear-time; no backreferences/lookaround) - Unanchored by default — `matches 'PASS'` fires on "tests PASSED"; users add `^`/`$` for exact matching - Case-sensitive by default (differs from `contains`/`starts_with`) because regex gives users explicit control via `(?i)`; this matches standard regex expectations rather than inventing an asymmetric rule - Invalid regex fails at pipeline load, not at match time - YAML single-quoting recommended to avoid backslash escaping traps The named `matches:` in §5.4 is clarified as shorthand for the expression form, locking down its previously-underspecified case sensitivity before implementation work begins. --- spec/core/s05-step-specification.md | 12 +++++++++++- spec/core/s12-conditions.md | 20 +++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/spec/core/s05-step-specification.md b/spec/core/s05-step-specification.md index ecbc603..bf40f70 100644 --- a/spec/core/s05-step-specification.md +++ b/spec/core/s05-step-specification.md @@ -202,7 +202,7 @@ Rules are evaluated in declared order; the first match fires. Used when differen | Operator | Meaning | |---|---| | `contains: "TEXT"` | Response contains literal string (case-insensitive). | -| `matches: "REGEX"` | Response matches regular expression. | +| `matches: "REGEX"` | Response matches regular expression. Case-sensitive by default; use `(?i)` for case-insensitive. Shorthand for `expression: "{{ step..response }} matches '...'"`. See §12.2 for regex semantics. | | `starts_with: "TEXT"` | Response begins with literal string. | | `is_empty` | Response is blank or whitespace only. | | `exit_code: N` | Process exit code equals N. Valid on `shell:` sources within `context:` steps only. | @@ -238,6 +238,16 @@ Rules are evaluated in declared order; the first match fires. Used when differen message: "Upstream rate-limited — human input required." - always action: continue + +# Regex matching on stdout — catch either warning class with one branch. +- id: lint + context: + shell: "cargo clippy" + on_result: + - expression: "{{ step.lint.stdout }} matches '(?i)warning|deprecated'" + action: pause_for_human + - exit_code: 0 + action: continue ``` **Evaluation semantics.** diff --git a/spec/core/s12-conditions.md b/spec/core/s12-conditions.md index 2f8db30..316e2a4 100644 --- a/spec/core/s12-conditions.md +++ b/spec/core/s12-conditions.md @@ -32,18 +32,36 @@ condition: "{{ step.check.response }} ends_with 'done'" | `contains` | Case-insensitive substring check | | `starts_with` | Case-insensitive prefix check | | `ends_with` | Case-insensitive suffix check | +| `matches` | Regular expression match (Rust `regex` crate syntax; unanchored; case-sensitive by default) | #### Syntax Rules - The **left-hand side** is a template expression (e.g. `{{ step.test.exit_code }}`) resolved at runtime. - The **right-hand side** is a literal value. Surrounding single or double quotes are stripped: `'LGTM'` and `"LGTM"` both resolve to `LGTM`. -- Word-based operators (`contains`, `starts_with`, `ends_with`) require whitespace boundaries — they are not confused with template variable names like `{{ step.contains_test.response }}`. +- Word-based operators (`contains`, `starts_with`, `ends_with`, `matches`) require whitespace boundaries — they are not confused with template variable names like `{{ step.contains_test.response }}`. - Symbolic operators (`==`, `!=`) are matched outside `{{ }}` template blocks. +#### `matches` — Regular Expression Matching + +The `matches` operator applies a regular expression to the resolved LHS. Semantics: + +- **Engine:** Rust `regex` crate syntax (POSIX-like with Perl-style character classes; no backreferences or lookaround — same tradeoff the rest of the ecosystem made for linear-time matching). +- **Anchoring:** unanchored by default. `matches 'PASS'` fires on `"tests PASSED"`. Use explicit anchors for exact matching: `matches '^PASS$'`. +- **Case sensitivity:** case-sensitive by default — deliberately different from `contains`/`starts_with`/`ends_with`, which are case-insensitive. Use the inline flag `(?i)` for case-insensitive matching: `matches '(?i)^pass'`. +- **Invalid regex:** compilation failure is caught at parse time (`CONFIG_VALIDATION_FAILED`), not at match time. A pipeline with a broken regex refuses to load. +- **YAML quoting:** backslashes in regex require YAML-safe quoting. Prefer single-quoted YAML strings, which treat backslashes literally: `matches '\d+'` (single quotes) vs. `matches "\\d+"` (double quotes). + +```yaml +condition: "{{ step.test.response }} matches '^PASS\\b'" +condition: "{{ step.build.stderr }} matches '(?i)error|warning'" +condition: "{{ step.review.response }} matches 'LGTM|SHIP IT'" +``` + #### Error Handling - If the LHS template variable cannot be resolved (e.g. references a step that has not run), the pipeline aborts with a `CONDITION_INVALID` error (`ail:condition/invalid`). - If the condition string is not a recognised named condition and does not contain a supported operator, validation fails with `CONFIG_VALIDATION_FAILED` at parse time. +- If a `matches` operator is present but its RHS fails to compile as a regex, validation fails with `CONFIG_VALIDATION_FAILED` at parse time. #### Reuse in `on_result: expression:` From 17450dd542483c36edc315c10c0365409e2290df Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 01:37:12 +0000 Subject: [PATCH 3/4] spec(#130): use /PATTERN/FLAGS regex-literal syntax for `matches` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces inline `(?i)`-style flags with conventional `/pattern/flags` regex literals, matching JavaScript/Perl/Ruby convention. `/warn|error/i` reads as what it is; `(?i)warn|error` demands the reader know a Rust-regex-specific inline-flag grammar. Design choices: - Flags supported: i (case-insensitive), m (multiline), s (dotall). - g (global) rejected at parse time — matching is boolean, "global" is meaningless. - Unsupported Perl flags (x, etc.) rejected — users can still reach inline (?x) if they really need verbose mode. - Parse rule: first / opens, last / followed by [ims]* closes — so forward slashes inside the pattern rarely need escaping. - Single-quoted YAML strings strongly recommended so \d/\b/\s work as written (no YAML double-escape trap). No change to engine choice (still Rust `regex` crate, linear-time, no backrefs/lookaround) — just the surface syntax. --- spec/core/s05-step-specification.md | 4 +-- spec/core/s12-conditions.md | 42 +++++++++++++++++++++-------- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/spec/core/s05-step-specification.md b/spec/core/s05-step-specification.md index bf40f70..1f13e67 100644 --- a/spec/core/s05-step-specification.md +++ b/spec/core/s05-step-specification.md @@ -202,7 +202,7 @@ Rules are evaluated in declared order; the first match fires. Used when differen | Operator | Meaning | |---|---| | `contains: "TEXT"` | Response contains literal string (case-insensitive). | -| `matches: "REGEX"` | Response matches regular expression. Case-sensitive by default; use `(?i)` for case-insensitive. Shorthand for `expression: "{{ step..response }} matches '...'"`. See §12.2 for regex semantics. | +| `matches: /REGEX/FLAGS` | Response matches regular expression. Uses conventional regex-literal syntax — e.g. `/warn\|error/i` for case-insensitive. Shorthand for `expression: '{{ step..response }} matches /.../flags'`. See §12.2 for regex semantics. | | `starts_with: "TEXT"` | Response begins with literal string. | | `is_empty` | Response is blank or whitespace only. | | `exit_code: N` | Process exit code equals N. Valid on `shell:` sources within `context:` steps only. | @@ -244,7 +244,7 @@ Rules are evaluated in declared order; the first match fires. Used when differen context: shell: "cargo clippy" on_result: - - expression: "{{ step.lint.stdout }} matches '(?i)warning|deprecated'" + - expression: '{{ step.lint.stdout }} matches /warning|deprecated/i' action: pause_for_human - exit_code: 0 action: continue diff --git a/spec/core/s12-conditions.md b/spec/core/s12-conditions.md index 316e2a4..295f061 100644 --- a/spec/core/s12-conditions.md +++ b/spec/core/s12-conditions.md @@ -32,29 +32,49 @@ condition: "{{ step.check.response }} ends_with 'done'" | `contains` | Case-insensitive substring check | | `starts_with` | Case-insensitive prefix check | | `ends_with` | Case-insensitive suffix check | -| `matches` | Regular expression match (Rust `regex` crate syntax; unanchored; case-sensitive by default) | +| `matches` | Regular expression match. RHS is a `/PATTERN/FLAGS` literal (Rust `regex` crate syntax; unanchored). See below. | #### Syntax Rules - The **left-hand side** is a template expression (e.g. `{{ step.test.exit_code }}`) resolved at runtime. -- The **right-hand side** is a literal value. Surrounding single or double quotes are stripped: `'LGTM'` and `"LGTM"` both resolve to `LGTM`. +- The **right-hand side** is a literal value. Surrounding single or double quotes are stripped: `'LGTM'` and `"LGTM"` both resolve to `LGTM`. The `matches` operator takes a `/PATTERN/FLAGS` regex literal instead — see below. - Word-based operators (`contains`, `starts_with`, `ends_with`, `matches`) require whitespace boundaries — they are not confused with template variable names like `{{ step.contains_test.response }}`. - Symbolic operators (`==`, `!=`) are matched outside `{{ }}` template blocks. #### `matches` — Regular Expression Matching -The `matches` operator applies a regular expression to the resolved LHS. Semantics: +The `matches` operator uses conventional regex-literal syntax: a leading `/`, the pattern, a closing `/`, and zero or more flag characters. No inline flag expressions, no clever escape rules — it reads the same way as regex literals in JavaScript, Perl, or Ruby. -- **Engine:** Rust `regex` crate syntax (POSIX-like with Perl-style character classes; no backreferences or lookaround — same tradeoff the rest of the ecosystem made for linear-time matching). -- **Anchoring:** unanchored by default. `matches 'PASS'` fires on `"tests PASSED"`. Use explicit anchors for exact matching: `matches '^PASS$'`. -- **Case sensitivity:** case-sensitive by default — deliberately different from `contains`/`starts_with`/`ends_with`, which are case-insensitive. Use the inline flag `(?i)` for case-insensitive matching: `matches '(?i)^pass'`. -- **Invalid regex:** compilation failure is caught at parse time (`CONFIG_VALIDATION_FAILED`), not at match time. A pipeline with a broken regex refuses to load. -- **YAML quoting:** backslashes in regex require YAML-safe quoting. Prefer single-quoted YAML strings, which treat backslashes literally: `matches '\d+'` (single quotes) vs. `matches "\\d+"` (double quotes). +```yaml +condition: '{{ step.test.response }} matches /^PASS\b/' +condition: '{{ step.build.stderr }} matches /error|warning/i' +condition: '{{ step.review.response }} matches /LGTM|SHIP IT/' +condition: '{{ step.lint.stdout }} matches /^E\d{4}/m' +``` + +**Syntax.** `/PATTERN/FLAGS` + +| Flag | Meaning | +|---|---| +| `i` | Case-insensitive | +| `m` | Multiline — `^` and `$` match at line boundaries | +| `s` | Dotall — `.` matches newlines | + +**Parsing rule.** The regex literal is delimited by the *first* `/` and the *last* `/` followed by zero or more flag characters (`[ims]*`) at end-of-string. Forward slashes inside the pattern do not need escaping as long as the literal isn't ambiguous: `/a/b/` matches the pattern `a/b` with no flags; `/a/b/i` matches `a/b` case-insensitively. When in doubt, escape as `\/`. + +**Semantics.** + +- **Engine:** Rust `regex` crate (linear-time; no backreferences or lookaround). +- **Anchoring:** unanchored by default. `/PASS/` matches `"tests PASSED"`. Use `^`/`$` for exact matching. +- **Case sensitivity:** case-sensitive unless the `i` flag is set. +- **Unsupported flags:** `g` (global) is rejected at parse time — matching in `on_result`/`condition:` is boolean, so a "global" flag has no meaning. `x` (verbose) and other Perl-style flags are not supported; use `(?x)` inline syntax if you need them. +- **Invalid regex:** compilation failure (including unsupported flags) is caught at parse time with `CONFIG_VALIDATION_FAILED`. A pipeline with a broken regex refuses to load. + +**YAML quoting.** Prefer **single-quoted** YAML strings for conditions containing regex. Single quotes leave backslashes literal, so `\d`, `\b`, `\s` work as written: ```yaml -condition: "{{ step.test.response }} matches '^PASS\\b'" -condition: "{{ step.build.stderr }} matches '(?i)error|warning'" -condition: "{{ step.review.response }} matches 'LGTM|SHIP IT'" +condition: '{{ step.x.response }} matches /\d{3}-\d{4}/' # clean +condition: "{{ step.x.response }} matches /\\d{3}-\\d{4}/" # works, but doubled backslashes ``` #### Error Handling From 0086ac52fe4df22c162ab04d9a670477dcbe2afd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 01:47:21 +0000 Subject: [PATCH 4/4] =?UTF-8?q?spec(#130):=20extract=20regex=20syntax=20to?= =?UTF-8?q?=20=C2=A712.3=20as=20single=20source=20of=20truth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regex spec was living inside §12.2's `matches` operator subsection, which meant any future consumer (template filters, output_schema patterns, etc.) would either redefine it or link into a subsection header. Promoting it to §12.3 makes it directly referenceable. §12.2 `matches` row: thin pointer to §12.3. §5.4 `matches:` named-matcher row: thin pointer to §12.3. §12.3: literal form, flags, parsing rule, matching semantics, YAML quoting — all in one place. spec/README.md: §12 index entry updated to surface §12.3. No behavior change — pure reorganisation. --- spec/README.md | 2 +- spec/core/s05-step-specification.md | 2 +- spec/core/s12-conditions.md | 87 ++++++++++++++++------------- 3 files changed, 51 insertions(+), 40 deletions(-) diff --git a/spec/README.md b/spec/README.md index 52fe41d..71a9c67 100644 --- a/spec/README.md +++ b/spec/README.md @@ -35,7 +35,7 @@ The AIL Pipeline Language Specification — for pipeline authors and implementer | [s09-calling-pipelines.md](core/s09-calling-pipelines.md) | §9 Calling Pipelines as Steps | Sub-pipeline isolation; failure propagation | **alpha** — sub-pipeline isolation, failure propagation, and depth guards implemented | | [s10-named-pipelines.md](core/s10-named-pipelines.md) | §10 Named Pipelines | Multiple named pipelines in one file — define, reference, execute, circular detection | **v0.2** | | [s11-template-variables.md](core/s11-template-variables.md) | §11 Template Variables | `{{ }}` syntax; all variable paths incl. `{{ step..result }}` for context steps | **alpha** — all template variables implemented including `step..result`/`stdout`/`stderr`/`exit_code`, env vars, session vars | -| [s12-conditions.md](core/s12-conditions.md) | §12 Conditions | `condition:` field; named conditions (if_code_changed, etc.) | partial — `never`/`always` implemented | +| [s12-conditions.md](core/s12-conditions.md) | §12 Conditions | `condition:` field; named conditions (if_code_changed, etc.); §12.3 regex syntax (shared with `on_result: matches:` / `expression:`) | partial — `never`/`always` implemented | | [s13-hitl-gates.md](core/s13-hitl-gates.md) | §13 HITL Gates | pause_for_human; tool permission flow diagram | partial — `pause_for_human` implemented in `execute_with_control()` (TUI/JSON mode); no-op in simple `execute()` mode | | [s14-built-in-modules.md](core/s14-built-in-modules.md) | §14 Built-in Modules | ail/janitor, ail/security-audit, ail/test-writer, etc. | deferred | | [s15-providers.md](core/s15-providers.md) | §15 Providers | Provider strings; aliases; `resume:` for session continuity | partial — `defaults.model`/`defaults.provider` ✓; per-step `model:` ✓; per-step `resume:` ✓; provider string format/aliases deferred | diff --git a/spec/core/s05-step-specification.md b/spec/core/s05-step-specification.md index 1f13e67..3ebf77d 100644 --- a/spec/core/s05-step-specification.md +++ b/spec/core/s05-step-specification.md @@ -202,7 +202,7 @@ Rules are evaluated in declared order; the first match fires. Used when differen | Operator | Meaning | |---|---| | `contains: "TEXT"` | Response contains literal string (case-insensitive). | -| `matches: /REGEX/FLAGS` | Response matches regular expression. Uses conventional regex-literal syntax — e.g. `/warn\|error/i` for case-insensitive. Shorthand for `expression: '{{ step..response }} matches /.../flags'`. See §12.2 for regex semantics. | +| `matches: /REGEX/FLAGS` | Response matches regular expression. Shorthand for `expression: '{{ step..response }} matches /.../flags'`. See §12.3 for regex syntax and semantics. | | `starts_with: "TEXT"` | Response begins with literal string. | | `is_empty` | Response is blank or whitespace only. | | `exit_code: N` | Process exit code equals N. Valid on `shell:` sources within `context:` steps only. | diff --git a/spec/core/s12-conditions.md b/spec/core/s12-conditions.md index 295f061..5ae5768 100644 --- a/spec/core/s12-conditions.md +++ b/spec/core/s12-conditions.md @@ -32,18 +32,50 @@ condition: "{{ step.check.response }} ends_with 'done'" | `contains` | Case-insensitive substring check | | `starts_with` | Case-insensitive prefix check | | `ends_with` | Case-insensitive suffix check | -| `matches` | Regular expression match. RHS is a `/PATTERN/FLAGS` literal (Rust `regex` crate syntax; unanchored). See below. | +| `matches` | Regular expression match. RHS is a `/PATTERN/FLAGS` literal. See §12.3. | #### Syntax Rules - The **left-hand side** is a template expression (e.g. `{{ step.test.exit_code }}`) resolved at runtime. -- The **right-hand side** is a literal value. Surrounding single or double quotes are stripped: `'LGTM'` and `"LGTM"` both resolve to `LGTM`. The `matches` operator takes a `/PATTERN/FLAGS` regex literal instead — see below. +- The **right-hand side** is a literal value. Surrounding single or double quotes are stripped: `'LGTM'` and `"LGTM"` both resolve to `LGTM`. The `matches` operator takes a `/PATTERN/FLAGS` regex literal instead — see §12.3. - Word-based operators (`contains`, `starts_with`, `ends_with`, `matches`) require whitespace boundaries — they are not confused with template variable names like `{{ step.contains_test.response }}`. - Symbolic operators (`==`, `!=`) are matched outside `{{ }}` template blocks. -#### `matches` — Regular Expression Matching +#### Error Handling + +- If the LHS template variable cannot be resolved (e.g. references a step that has not run), the pipeline aborts with a `CONDITION_INVALID` error (`ail:condition/invalid`). +- If the condition string is not a recognised named condition and does not contain a supported operator, validation fails with `CONFIG_VALIDATION_FAILED` at parse time. +- If a `matches` operator is present but its RHS fails to compile as a valid regex literal, validation fails with `CONFIG_VALIDATION_FAILED` at parse time (see §12.3). + +#### Reuse in `on_result: expression:` + +The `expression:` matcher on `on_result` (§5.4) reuses this grammar without modification. Any valid condition expression is a valid `on_result: expression:` expression and vice versa. When the grammar is extended (e.g. numeric operators for confidence-score gating — #130), the extension applies to both sites simultaneously. + +#### Template Variables in Conditions + +All template variables from §11 are available: + +```yaml +# Check exit code of a shell step +condition: "{{ step.build.exit_code }} == 0" + +# Check if a prompt step response contains a keyword +condition: "{{ step.review.response }} contains 'LGTM'" + +# Check stdout of a context step +condition: "{{ step.lint.stdout }} contains 'no warnings'" + +# Compare against environment variable +condition: "{{ env.DEPLOY_TARGET }} == production" +``` + +### 12.3 Regular Expression Syntax + +This section is the single source of truth for regex syntax in `ail`. It is referenced by the `matches` operator in §12.2 (which in turn is reused by `on_result: expression:` in §5.4) and by the named `matches:` matcher in §5.4. Any future consumer of regex in the spec should link here rather than redefine. + +#### Literal form -The `matches` operator uses conventional regex-literal syntax: a leading `/`, the pattern, a closing `/`, and zero or more flag characters. No inline flag expressions, no clever escape rules — it reads the same way as regex literals in JavaScript, Perl, or Ruby. +Regex literals use conventional `/PATTERN/FLAGS` syntax — a leading `/`, the pattern, a closing `/`, and zero or more flag characters. This reads the same way as regex literals in JavaScript, Perl, or Ruby. ```yaml condition: '{{ step.test.response }} matches /^PASS\b/' @@ -52,7 +84,7 @@ condition: '{{ step.review.response }} matches /LGTM|SHIP IT/' condition: '{{ step.lint.stdout }} matches /^E\d{4}/m' ``` -**Syntax.** `/PATTERN/FLAGS` +#### Flags | Flag | Meaning | |---|---| @@ -60,49 +92,28 @@ condition: '{{ step.lint.stdout }} matches /^E\d{4}/m' | `m` | Multiline — `^` and `$` match at line boundaries | | `s` | Dotall — `.` matches newlines | -**Parsing rule.** The regex literal is delimited by the *first* `/` and the *last* `/` followed by zero or more flag characters (`[ims]*`) at end-of-string. Forward slashes inside the pattern do not need escaping as long as the literal isn't ambiguous: `/a/b/` matches the pattern `a/b` with no flags; `/a/b/i` matches `a/b` case-insensitively. When in doubt, escape as `\/`. +**Unsupported flags.** `g` (global) is rejected at parse time — `ail` regex matching is boolean (does the string match or not), so a "global" flag has no meaning. `x` (verbose) and other Perl-style flags are not supported as trailing flags; use `(?x)` inline syntax inside the pattern if you need them. -**Semantics.** +#### Parsing rule -- **Engine:** Rust `regex` crate (linear-time; no backreferences or lookaround). +The regex literal is delimited by the *first* `/` and the *last* `/` followed by zero or more flag characters (`[ims]*`) at end-of-string. Forward slashes inside the pattern do not need escaping as long as the literal isn't ambiguous: `/a/b/` matches the pattern `a/b` with no flags; `/a/b/i` matches `a/b` case-insensitively. When in doubt, escape as `\/`. + +#### Matching semantics + +- **Engine:** Rust `regex` crate (linear-time; no backreferences or lookaround — the standard RE2-family tradeoff for bounded match cost). - **Anchoring:** unanchored by default. `/PASS/` matches `"tests PASSED"`. Use `^`/`$` for exact matching. - **Case sensitivity:** case-sensitive unless the `i` flag is set. -- **Unsupported flags:** `g` (global) is rejected at parse time — matching in `on_result`/`condition:` is boolean, so a "global" flag has no meaning. `x` (verbose) and other Perl-style flags are not supported; use `(?x)` inline syntax if you need them. -- **Invalid regex:** compilation failure (including unsupported flags) is caught at parse time with `CONFIG_VALIDATION_FAILED`. A pipeline with a broken regex refuses to load. +- **Invalid regex:** compilation failure (including unsupported flags) is caught at parse time with `CONFIG_VALIDATION_FAILED`. A pipeline with a broken regex refuses to load — never "works until the first request arrives that would have triggered it." -**YAML quoting.** Prefer **single-quoted** YAML strings for conditions containing regex. Single quotes leave backslashes literal, so `\d`, `\b`, `\s` work as written: +#### YAML quoting + +Prefer **single-quoted** YAML strings around any condition containing a regex. Single quotes leave backslashes literal, so `\d`, `\b`, `\s` work as written: ```yaml condition: '{{ step.x.response }} matches /\d{3}-\d{4}/' # clean condition: "{{ step.x.response }} matches /\\d{3}-\\d{4}/" # works, but doubled backslashes ``` -#### Error Handling - -- If the LHS template variable cannot be resolved (e.g. references a step that has not run), the pipeline aborts with a `CONDITION_INVALID` error (`ail:condition/invalid`). -- If the condition string is not a recognised named condition and does not contain a supported operator, validation fails with `CONFIG_VALIDATION_FAILED` at parse time. -- If a `matches` operator is present but its RHS fails to compile as a regex, validation fails with `CONFIG_VALIDATION_FAILED` at parse time. - -#### Reuse in `on_result: expression:` - -The `expression:` matcher on `on_result` (§5.4) reuses this grammar without modification. Any valid condition expression is a valid `on_result: expression:` expression and vice versa. When the grammar is extended (e.g. numeric operators for confidence-score gating — #130), the extension applies to both sites simultaneously. - -#### Template Variables in Conditions - -All template variables from §11 are available: - -```yaml -# Check exit code of a shell step -condition: "{{ step.build.exit_code }} == 0" - -# Check if a prompt step response contains a keyword -condition: "{{ step.review.response }} contains 'LGTM'" - -# Check stdout of a context step -condition: "{{ step.lint.stdout }} contains 'no warnings'" - -# Compare against environment variable -condition: "{{ env.DEPLOY_TARGET }} == production" -``` +YAML double quotes interpret backslashes. `"/\d+/"` becomes `/d+/` silently — a failure mode that is painful to debug. Single-quoted is the idiomatic form. ---