Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/vale-swap-backreference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@taskless/cli": patch
---

`verify` now rejects a Vale `substitution` rule whose `swap` key carries a backreference. No capture group survives a swap key: Vale compiles a rule's keys into one alternation, wrapping each in a capture group of its own and rewriting the author's groups to non-capturing, so `\1` refers to Vale's wrapper and matches nothing. Vale reports none of this, so the rule loads, runs and silently never fires. The detector is character-class aware, since `\1` inside `[…]` is an octal escape and works. `$1` in the swap _value_ is unaffected and still works. The `create-vale-rule` topic goes to v14: the claim that Vale tries Go's `regexp` before falling back to `regexp2` is removed (there is no fallback; it compiles with `regexp2` unconditionally), the `swap` constraint is generalised, and the leading-lookbehind mirror of the trailing-lookahead limit is documented.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-23
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
## Why

A backreference in a Vale `swap` key can never match, and Vale says nothing
about it. The rule loads, runs on every document, writes nothing to stderr, and
reports no finding. A silently dead rule is indistinguishable from a clean
project, so the author's own `fail/` fixture is the only thing that can catch
it — and by then they have already written the rule.

The cause is not the regex engine. Vale compiles every pattern with `regexp2`
in RE2 compatibility mode, unconditionally (`internal/regex/regex.go:63`), so
backreferences work under `tokens`, under `raw`, in a `consistency` key and in a
`conditional` `first`. It is that **no capture group survives a `swap` key**:
`NewSubstitution` compiles all of a rule's keys into one alternation, wrapping
each key in a capture group of its own because the index of the group that
matched is how it recovers the replacement. Those wrappers must be numbered
1..n, so any group the author wrote is rewritten to `(?:…)` by
`convertCaptureGroups` first. Either way `\1` points at Vale's wrapper — the
group still being matched — and matches nothing.

The standing spec covers the neighbouring case and not this one. "Behavior a
schema cannot express stays explicit" is scoped to a rule shape that _crashes_
the binary; this shape does not crash it, and the justification a crash supplies
(one config for the whole run, no findings for any rule) is not available here.
That gap is what this change closes: it states when the schema may reject a
pattern the binary accepts, and what such a rejection owes the author.

## What Changes

- **`cli-rule-validation`** gains one requirement covering a rejection for a
pattern the binary accepts but can never honor, with the evidence bar and the
message obligations such a rejection carries.

Code, already implemented on this branch:

- The Vale style-layer schema rejects a `swap` key carrying a live
backreference. The detector is character-class aware, because inside `[…]` a
`\1` is an octal escape and the key works.
- `create-vale-rule` goes to topic v14: the two-engine fallback claim is
removed (it was never true), the `swap` constraint is generalised from "a
backreference does nothing" to "no capture group survives a swap key", the
`$1`-in-the-value asymmetry is documented, and the leading-lookbehind mirror
of the existing trailing-lookahead limit is added.
- `vale-vendor-contract.test.ts` pins the mechanism, the false-positive
candidates, and — newly — that `consistency` and `conditional` are NOT
affected, so a widening of the blast radius is caught rather than assumed.

## Capabilities

### New Capabilities

None.

### Modified Capabilities

- `cli-rule-validation`: one ADDED requirement. No standing requirement is
restated, renamed or removed.

## Impact

One new schema rejection, `patch`. The migration cost was measured: across the
seven published Vale style packages plus this repository's own rules, 1,502
`swap` keys in 161 rule files, **0** carry a live backreference. `check` is
unaffected (it does not run the style-layer schema over authored rules the way
`verify` does), and `test` already fails a rule whose `fail/` fixture never
fires.

## Delivery shape

**Single PR.** The spec, the schema change, the recipe bump and the tests are
one reviewable diff and are only correct together. It is the tip, so the change
is archived here.
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
## ADDED Requirements

### Requirement: The schema may reject a pattern the binary accepts only when the pattern can never fire

The Vale rule schema SHALL reject a pattern that the vendored binary loads and runs without complaint, where that pattern cannot produce a finding on any document. Such a rejection SHALL be stated per rule in the style layer, and SHALL NOT be raised in the config layer.

This is a different class from a rejection justified by blast radius. A rule shape that crashes the binary, or a field that draws an `E201`, takes down every other rule's findings for the whole run, and its rejection borrows that justification. A pattern that can never fire takes down nothing: it loads, runs, writes nothing to stderr, and reports nothing — which is exactly what a rule with nothing to say looks like. The author has no signal to act on, at the only moment they are looking, and there is no suppression mechanism anywhere in the CLI for them to reach for if the schema is wrong.

Two obligations follow from that, and they pull against each other.

The rejection SHALL be carried by a detector whose precision is established by measurement against the binary, in both directions: every rejected form SHALL have been measured silent, and every form the detector accepts that could plausibly have been rejected SHALL have been measured firing. Precision is load-bearing here in a way it is not for a blast-radius rejection, because an author facing a false positive cannot suppress it and cannot write the rule.

The rejection's message SHALL carry its own justification rather than pointing at a broken run. It SHALL name the offending key or pattern, SHALL say that the pattern can never match and that the binary reports nothing, and SHALL name the construct to write instead.

#### Scenario: A backreference in a swap key is rejected

- **WHEN** a `substitution` rule's `swap` map carries a key containing a backreference outside a character class
- **THEN** `verify` SHALL reject the rule
- **AND** the message SHALL name the offending key
- **AND** the message SHALL say the key can never match and that Vale reports nothing
- **AND** the message SHALL name `existence` as the check to write instead

#### Scenario: A pattern the binary honors is not rejected

- **WHEN** a `swap` key contains `\1` inside a character class, where it is an octal escape rather than a backreference
- **THEN** `verify` SHALL accept the rule

An escaped backslash before a digit, escaped parentheses, and a non-capturing group are accepted for the same reason: each was measured firing.

#### Scenario: The rejection is scoped to the field that carries the defect

- **WHEN** the same pattern appears in a field of another check that the binary does honor it in
- **THEN** `verify` SHALL accept the rule

The defect belongs to how `substitution` compiles its keys, not to the regex engine, which is shared. Rejecting elsewhere would block a rule that works.

#### Scenario: A widening of the defect fails the vendor contract

- **WHEN** a Vale upgrade makes a neighbouring pattern field compile its patterns the same way
- **THEN** the vendor contract test SHALL fail
- **AND** the failure SHALL name the field whose treatment changed

#### Scenario: A rejection the binary stops earning is removed, not kept

- **WHEN** a Vale upgrade makes a rejected pattern fire
- **THEN** the vendor contract test asserting it silent SHALL fail
- **AND** the schema's rejection SHALL be removed rather than the test relaxed
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
## 1. Spec

- [x] 1.1 Confirm no standing requirement governs a rejection for a pattern the
binary accepts; "Behavior a schema cannot express stays explicit" is
scoped to a shape that crashes the binary.
- [x] 1.2 Add the requirement as an ADDED block, so nothing standing is
restated and nothing can be dropped by archive.
- [x] 1.3 Dry-run `openspec archive` and compare the scenario count in
`cli-rule-validation` before and after.

## 2. Schema

- [x] 2.1 Reject a `swap` key carrying a live backreference, in the style
layer, per rule.
- [x] 2.2 Make the detector character-class aware, so `[\1a]` is accepted.
- [x] 2.3 Scope the rejection to `substitution`, since the permissive checks
decode loosely and a `swap` map on one reaches the check.
- [x] 2.4 Write the message so it carries its own justification: the key can
never match, Vale reports nothing, the rule is silently dead — and say to
write an `existence` rule instead.

## 3. Recipe

- [x] 3.1 Remove the two-engine fallback claim; Vale compiles with `regexp2`
unconditionally.
- [x] 3.2 Generalise the `swap` constraint to "no capture group survives a swap
key", with all four measured forms.
- [x] 3.3 Document that `$1` in the swap value works, and that `\1` in a
character class works.
- [x] 3.4 Add the leading-lookbehind mirror of the trailing-lookahead limit.
- [x] 3.5 Bump the topic to v14.

## 4. Tests

- [x] 4.1 Detector, both directions: the four inert forms rejected, the
false-positive candidates accepted.
- [x] 4.2 Vendor contract: correct the mechanism comment, pin the no-capture-
group forms, the character class, the `$1` value, and the lookbehind.
- [x] 4.3 Vendor contract: measure `consistency` and `conditional` so a
widening of the blast radius is caught rather than assumed.
46 changes: 46 additions & 0 deletions openspec/specs/cli-rule-validation/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,3 +318,49 @@ The write path SHALL NOT refuse. `check`'s repair path calls `writeRuleFile`, so
- **WHEN** a rule is written whose id another engine already holds
- **THEN** the rule SHALL be written
- **AND** the caller SHALL receive a warning naming both directories

### Requirement: The schema may reject a pattern the binary accepts only when the pattern can never fire

The Vale rule schema SHALL reject a pattern that the vendored binary loads and runs without complaint, where that pattern cannot produce a finding on any document. Such a rejection SHALL be stated per rule in the style layer, and SHALL NOT be raised in the config layer.

This is a different class from a rejection justified by blast radius. A rule shape that crashes the binary, or a field that draws an `E201`, takes down every other rule's findings for the whole run, and its rejection borrows that justification. A pattern that can never fire takes down nothing: it loads, runs, writes nothing to stderr, and reports nothing — which is exactly what a rule with nothing to say looks like. The author has no signal to act on, at the only moment they are looking, and there is no suppression mechanism anywhere in the CLI for them to reach for if the schema is wrong.

Two obligations follow from that, and they pull against each other.

The rejection SHALL be carried by a detector whose precision is established by measurement against the binary, in both directions: every rejected form SHALL have been measured silent, and every form the detector accepts that could plausibly have been rejected SHALL have been measured firing. Precision is load-bearing here in a way it is not for a blast-radius rejection, because an author facing a false positive cannot suppress it and cannot write the rule.

The rejection's message SHALL carry its own justification rather than pointing at a broken run. It SHALL name the offending key or pattern, SHALL say that the pattern can never match and that the binary reports nothing, and SHALL name the construct to write instead.

#### Scenario: A backreference in a swap key is rejected

- **WHEN** a `substitution` rule's `swap` map carries a key containing a backreference outside a character class
- **THEN** `verify` SHALL reject the rule
- **AND** the message SHALL name the offending key
- **AND** the message SHALL say the key can never match and that Vale reports nothing
- **AND** the message SHALL name `existence` as the check to write instead

#### Scenario: A pattern the binary honors is not rejected

- **WHEN** a `swap` key contains `\1` inside a character class, where it is an octal escape rather than a backreference
- **THEN** `verify` SHALL accept the rule

An escaped backslash before a digit, escaped parentheses, and a non-capturing group are accepted for the same reason: each was measured firing.

#### Scenario: The rejection is scoped to the field that carries the defect

- **WHEN** the same pattern appears in a field of another check that the binary does honor it in
- **THEN** `verify` SHALL accept the rule

The defect belongs to how `substitution` compiles its keys, not to the regex engine, which is shared. Rejecting elsewhere would block a rule that works.

#### Scenario: A widening of the defect fails the vendor contract

- **WHEN** a Vale upgrade makes a neighbouring pattern field compile its patterns the same way
- **THEN** the vendor contract test SHALL fail
- **AND** the failure SHALL name the field whose treatment changed

#### Scenario: A rejection the binary stops earning is removed, not kept

- **WHEN** a Vale upgrade makes a rejected pattern fire
- **THEN** the vendor contract test asserting it silent SHALL fail
- **AND** the schema's rejection SHALL be removed rather than the test relaxed
76 changes: 53 additions & 23 deletions packages/cli/src/agent/create-vale-rule.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Topic: create-vale-rule (CLI v%(CLI_VERSION)s / topic v13)
# Topic: create-vale-rule (CLI v%(CLI_VERSION)s / topic v14)

## You are here
This is `create-vale-rule`. It helps you write a Vale rule: a check over
Expand Down Expand Up @@ -478,35 +478,65 @@ it.

- `(?:…)`, `[…]`, `|`, `+`, `?` all work.
- **Lookaround and backreferences work, and they are not free.**
Vale compiles a pattern with Go's own `regexp` first and falls
back to `regexp2` when that engine refuses it, so `(?=…)`,
`(?<=…)` and `\1` are all available even though Go's `regexp` has
none of them. Measured on Vale v%(VALE_VERSION)s with throwaway
rules, each firing on its `fail/` fixture and quiet on `pass/`: a
repeated-word `\b(\w+) \1\b`, a `foo(?= bar)` lookahead, and a
`(?<=x )y` lookbehind. The fallback engine backtracks and is the
slower of the two, so keep lookaround off a pattern that runs
over every file in the project, and prove any rule that uses one
with a fixture rather than trusting the syntax. "X but not when
followed by Y" is therefore writable as a single `substitution`,
but splitting it or narrowing with `scope` is still the cheaper
rule when either will do.

Two measured limits sit on top of that, and both are silent:

- **A backreference does nothing in `swap`.** The same
`(\w+) \1` that fires under `tokens` and under `raw` produces
no finding as a `swap` key, with nothing on stderr and the
rule loading cleanly, so the rule looks healthy and never
fires at all. A repeated-word check has to be an `existence`
rule; it cannot be a `substitution`.
Vale compiles every pattern with `regexp2` in RE2 compatibility
mode, so `(?=…)`, `(?<=…)` and `\1` are all available even
though Go's own `regexp` has none of them. (Older versions of
this topic said Vale tried Go's `regexp` first and fell back.
There is no fallback and never was; the conclusion was right and
the reason was not.) Measured on Vale v%(VALE_VERSION)s with
throwaway rules, each firing on its `fail/` fixture and quiet on
`pass/`: a repeated-word `\b(\w+) \1\b`, a `foo(?= bar)`
lookahead, and a `(?<=x )y` lookbehind. That engine backtracks
and is slower than a pattern without lookaround, so keep
lookaround off a rule that runs over every file in the project,
and prove any rule that uses one with a fixture rather than
trusting the syntax. "X but not when followed by Y" is therefore
writable as a single `substitution`, but splitting it or
narrowing with `scope` is still the cheaper rule when either
will do.

Three measured limits sit on top of that, and all three are
silent:

- **No capture group survives a `swap` key**, so a
backreference in one can never match. Vale compiles all of a
rule's swap keys into ONE alternation and wraps each key in a
capture group of its own, because the number of the group
that matched is how it knows which replacement to offer. Those
wrappers have to be numbered 1, 2, 3…, so any group you wrote
is rewritten to `(?:…)` first. Whichever way round, `\1` ends
up pointing at Vale's wrapper (the group still being matched)
and matches nothing. Measured silent, with nothing on
stderr and the rule loading cleanly: `(\w+) \1`, `(the) \1`,
`the \1` and `(?:\w+) \1`. **`%(TASKLESS_CLI)s verify` rejects a
`swap` key with a backreference**, because a rule that loads
and never fires is indistinguishable from a clean project. A
repeated-word check has to be an `existence` rule, where the
same pattern works under both `tokens` and `raw`.

Two things this does NOT mean:

- **`$1` in the swap VALUE works.** Vale expands it against
the original key, before the rewrite, so
`colour(s?): color$1` offers `colors` for `colours` and
`color` for `colour`. Only the key loses its groups.
- **`\1` inside a character class is fine**, because there it
is an octal escape rather than a backreference. `[\1a]bc`
matches `abc` and `verify` accepts it.

- **A trailing lookahead in `tokens` or `swap` has to peek at a
non-word character.** The implicit `\b` is appended after the
lookahead (the lookahead is zero-width, so the position is
still where the match ended), which puts the boundary between
the match and the text peeked at. `foo(?= bar)` fires;
`foo(?=bar)` can never match, whatever the document says. Use
`raw` when the lookahead has to land on a word character.
- **A leading lookbehind has the same problem at the other
end.** The implicit `\b` goes on before it, so the boundary
is tested between what the lookbehind peeked at and the match.
`(?<=x )y` fires; `(?<=x)foo` is silent under `tokens` and
under `swap` on a document reading `xfoo`, and fires under
`raw`. Same fix: use `raw`.
- **Word boundaries are applied for you, around the whole pattern.**
Measured: `Github` does not fire inside `GithubToken`, and the
multi-word `click here` does not fire inside `Clicking here`.
Expand Down
Loading
Loading