From 3687028b9eaebf2c6c38a662dfeda908168d239f Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 22 Sep 2026 10:17:56 -0700 Subject: [PATCH 1/4] docs(vale): correct the regex-engine and fixture-bucket claims (topic v13) Step 3 said `tokens` and `swap` compile as Go RE2 and that lookahead and lookbehind therefore do not exist, which sent authors off to split a rule one pattern expresses. Vale compiles with Go `regexp` first and falls back to `regexp2`, so lookaround and backreferences work. Measured on the vendored binary and pinned in the vendor contract suite. The test section's `check /.tests/fail --json` was reported as a command that can never return a finding. It does: `.taskless/` is excluded from the whole-project walk only, and a path the user names is honored. The shape that really empties it is the rule's own `[.taskless/**]` matcher setting the rule to NO, which leaves `test` green and `check` silent, so the recipe names that and the troubleshooting list now starts by reading the finding. --- .changeset/vale-recipe-regex-and-fixtures.md | 5 ++ packages/cli/src/agent/create-vale-rule.md | 53 ++++++++++++++++--- .../cli/test/recipe-cross-references.test.ts | 28 ++++++++++ .../cli/test/vale-vendor-contract.test.ts | 40 ++++++++++++++ 4 files changed, 118 insertions(+), 8 deletions(-) create mode 100644 .changeset/vale-recipe-regex-and-fixtures.md diff --git a/.changeset/vale-recipe-regex-and-fixtures.md b/.changeset/vale-recipe-regex-and-fixtures.md new file mode 100644 index 00000000..bc7e9988 --- /dev/null +++ b/.changeset/vale-recipe-regex-and-fixtures.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +`agent create-vale-rule` (topic v13) corrects two claims that cost rule authors work. Vale patterns are not RE2-only: Vale compiles with Go's `regexp` and falls back to `regexp2`, so lookahead, lookbehind and backreferences all work, and the recipe no longer tells you to split a rule that one pattern expresses. `check` on a rule's `.tests/fail` bucket is a supported way to read a rendered message, because `.taskless/` is excluded from the whole-project walk only; when that bucket comes back empty, the cause named is the rule's own `[.taskless/**]` matcher rather than the config. diff --git a/packages/cli/src/agent/create-vale-rule.md b/packages/cli/src/agent/create-vale-rule.md index 671a6d03..def50b55 100644 --- a/packages/cli/src/agent/create-vale-rule.md +++ b/packages/cli/src/agent/create-vale-rule.md @@ -1,4 +1,4 @@ -# Topic: create-vale-rule (CLI v%(CLI_VERSION)s / topic v12) +# Topic: create-vale-rule (CLI v%(CLI_VERSION)s / topic v13) ## You are here This is `create-vale-rule`. It helps you write a Vale rule: a check over @@ -471,15 +471,26 @@ it. expect to add to the list. 3. **Know what you are writing: `tokens` and `swap` keys are patterns, - not literals.** They compile as **Go RE2** regular expressions. + not literals.** They compile as Go regular expressions. *This step is about `tokens` and `swap` only. A `capitalization`, `occurrence` or `metric` rule has neither, skip to step 4.* - `(?:…)`, `[…]`, `|`, `+`, `?` all work. - - **Lookahead and lookbehind do not exist in RE2.** A rule that needs - "X but not when followed by Y" cannot be written as a single - `substitution`; split it or narrow with `scope`. + - **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. - **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`. @@ -939,13 +950,35 @@ it. reported one entry per rule. `.taskless/rules/vale` covers every Vale rule; no argument at all covers the project. - If you would rather see the raw findings, the message text and the - line numbers, run `check` against a bucket instead: + **`test` answers pass-or-fail and never shows you the finding**, so + it cannot tell you that a `substitution` message renders its two + `%%s` slots in the wrong order: the rule fires, the fixture is + satisfied, and `test` reports `ok`. To read the rendered message, + the line numbers and the matched text, name the bucket to `check`: ``` %(TASKLESS_CLI)s check .taskless/rules/vale//.tests/fail --json ``` + **That works because a path you name is honored.** `.taskless/` is + excluded from the *whole-project* walk only, so a bare `check` over + the project reports nothing from anyone's fixtures while the command + above reports every finding in that bucket. Measured on this build: + a whole-project `check` returned no result under `.taskless/`, and + the same rule's `fail/` bucket named explicitly returned its + findings with the message text rendered. + + **If that command returns `results: []` for a rule whose `test` is + green, suspect the rule's own config before the pattern.** A + `[.taskless/**]` matcher setting `. = NO` turns the bucket + off for exactly this invocation, which is the one shape that + reproduces "`test` says the fixture fired, `check` on the same + fixture says nothing". Measured: adding that matcher to a working + rule left `test` at `ok: true` and emptied `results`. The tell is + in the same envelope, as a notice reading `matcher [.taskless/**] + is unnecessary`. `verify` reports it too, as an advisory. Delete + the matcher; step 4 explains why no rule needs one. + Read `results` there. Ignore `success` and the exit code: `success` says the run worked rather than that the fixture behaved, and the exit code follows severity, so a `level: error` rule exits 1 on @@ -1014,6 +1047,10 @@ it. When a `fail/` document does not fire, work down this list before touching the pattern. The cause is usually further up: + - Read the finding first, with the `check` on the `fail/` bucket + above. What the run saw is cheaper than any guess about why it + saw nothing, and it separates "no finding" from "a finding whose + message is wrong". - Does the rule have a `.vale.ini` at all? - Is the assignment underneath a `[…]` matcher? - Is it spelled `.`, both halves the same? @@ -1191,7 +1228,7 @@ either: **The id must be word characters only.** `consistency` is the one extension point that compiles the rule's own name into the pattern, as -a `(?P…)` capture group, and Go RE2 rejects a group name containing +a `(?P…)` capture group, and Go's `regexp` rejects a group name containing a hyphen. Measured: an id of `ize-ise` fails with `E201 … invalid group name` and takes **every** Vale rule in the project down with it, because Vale reads one config for the whole run. Name this one `izeise` or diff --git a/packages/cli/test/recipe-cross-references.test.ts b/packages/cli/test/recipe-cross-references.test.ts index 0ad4a787..412b9e5d 100644 --- a/packages/cli/test/recipe-cross-references.test.ts +++ b/packages/cli/test/recipe-cross-references.test.ts @@ -427,4 +427,32 @@ describe("recipes state engine reach from the pinned versions", () => { // otherwise is stale the moment it slips. expect(recipe).not.toMatch(/\b20\d\d-\d\d\b/); }); + + it("does not tell a Vale author that lookaround is unavailable", async () => { + // The recipe claimed `tokens` and `swap` compile as RE2 and that + // lookaround therefore does not exist (taskless/cli#371). It does: Vale + // falls back to `regexp2`, measured in `vale-vendor-contract.test.ts`. + // The claim is worth pinning as an absence because it does not fail + // anything — it only costs an author a rule split into two that one + // pattern expresses. + const recipe = await rendered("create-vale-rule.md"); + expect(recipe).not.toMatch(/do not exist in RE2/); + expect(recipe).toContain("regexp2"); + // The two notes that shared that bullet list and did measure true. Losing + // them to the rewrite above would be the quiet half of the same edit. + expect(recipe).toContain("Word boundaries are applied for you"); + expect(recipe).toContain("A hyphen is a boundary"); + }); + + it("explains why a named fixture bucket is reachable by check", async () => { + // `check` on a `fail/` bucket is the only way to read a rendered `%s` + // message, and it works: `.taskless/` is excluded from the whole-project + // walk only. taskless/cli#370 read an empty `results` as proof the + // command could never work, so the recipe now says which exclusion is + // which, and names the rule-local matcher that really does empty it. + const recipe = await rendered("create-vale-rule.md"); + expect(recipe).toContain(".tests/fail --json"); + expect(recipe).toContain("a path you name is honored"); + expect(recipe).toContain("matcher [.taskless/**]"); + }); }); diff --git a/packages/cli/test/vale-vendor-contract.test.ts b/packages/cli/test/vale-vendor-contract.test.ts index cff9c2dd..370104a4 100644 --- a/packages/cli/test/vale-vendor-contract.test.ts +++ b/packages/cli/test/vale-vendor-contract.test.ts @@ -86,6 +86,10 @@ function runRaw(cwd: string, paths: string[], extraArguments: string[] = []) { } const header = "StylesPath = .\nMinAlertLevel = suggestion\n"; + +/** An `existence` rule whose single `raw` entry is the pattern under test. */ +const rawPatternRule = (pattern: string) => + `extends: existence\nmessage: "%s"\nlevel: warning\nraw:\n - '${pattern}'\n`; const existence = (token: string, level = "warning") => `extends: existence\nmessage: "Avoid '${token}'"\nlevel: ${level}\ntokens:\n - ${token}\n`; @@ -743,6 +747,42 @@ withVale("Vale vendor contract", () => { }); }); + describe("lookaround and backreferences compile", () => { + // Vale tries Go's own `regexp` first and falls back to `regexp2` when a + // pattern will not compile, so constructs Go's engine has never had are + // still available. The recipe said the opposite for twelve topic + // revisions (taskless/cli#371) and sent authors off to split a rule that + // one pattern expresses, so the correction is pinned against the binary + // rather than restated in prose: if a future Vale drops the fallback, + // these go red and the recipe's step 3 is wrong again. + // + // Each case is asserted in both directions. A pattern that fails to + // compile produces no findings at all, which is indistinguishable from a + // pattern that compiled and did not match, so the negative half alone + // would pass for the wrong reason. + it("matches a backreference to an earlier group", () => { + const repeated = rawPatternRule(String.raw`\b(\w+) \1\b`); + expect(lines(repeated, "A the the repeated word.\n").messages).toEqual([ + "the the", + ]); + expect(lines(repeated, "A sentence with no repeat.\n").lines).toEqual([]); + }); + + it("matches a lookahead", () => { + const ahead = rawPatternRule("foo(?= bar)"); + expect(lines(ahead, "We wrote foo bar here.\n").messages).toEqual([ + "foo", + ]); + expect(lines(ahead, "We wrote foo baz here.\n").lines).toEqual([]); + }); + + it("matches a lookbehind", () => { + const behind = rawPatternRule("(?<=x )y"); + expect(lines(behind, "Here is x y now.\n").messages).toEqual(["y"]); + expect(lines(behind, "Here is z y now.\n").lines).toEqual([]); + }); + }); + describe("`nonword` governs `tokens`, not `raw`", () => { // Vale wraps the pattern in `\b…\b` only when the rule has `tokens` and // `nonword` is unset, and `raw` is inserted verbatim either way. So a From 86473b62b501800cc38c0e4b3e8c59c9cc2f5afb Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 22 Sep 2026 10:34:19 -0700 Subject: [PATCH 2/4] fix(vale): say what a .taskless/** matcher actually costs The advisory called the matcher unnecessary because `check` excludes `.taskless/` before Vale runs, so it "acts only under a bare vale invocation." The exclusion is applied on a whole-project walk only, since an explicit path is a request, so the matcher does bite on `check .taskless/rules/vale//.tests/fail` and empties the one command that shows an author a rendered message while `test` stays green. The advisory now names both halves, and the docblock records why it stopped calling the matcher harmless. `create-vale-rule` and `update` repeated the same claim in prose and are corrected with it. --- .changeset/vale-recipe-regex-and-fixtures.md | 2 ++ packages/cli/src/agent/create-vale-rule.md | 16 +++++++++------- packages/cli/src/agent/update.md | 9 +++++---- packages/cli/src/schemas/vale-config.ts | 20 +++++++++++++++----- packages/cli/test/vale-config-schema.test.ts | 10 ++++++++-- 5 files changed, 39 insertions(+), 18 deletions(-) diff --git a/.changeset/vale-recipe-regex-and-fixtures.md b/.changeset/vale-recipe-regex-and-fixtures.md index bc7e9988..ffaf76dc 100644 --- a/.changeset/vale-recipe-regex-and-fixtures.md +++ b/.changeset/vale-recipe-regex-and-fixtures.md @@ -3,3 +3,5 @@ --- `agent create-vale-rule` (topic v13) corrects two claims that cost rule authors work. Vale patterns are not RE2-only: Vale compiles with Go's `regexp` and falls back to `regexp2`, so lookahead, lookbehind and backreferences all work, and the recipe no longer tells you to split a rule that one pattern expresses. `check` on a rule's `.tests/fail` bucket is a supported way to read a rendered message, because `.taskless/` is excluded from the whole-project walk only; when that bucket comes back empty, the cause named is the rule's own `[.taskless/**]` matcher rather than the config. + +`verify` carried the same imprecision and now states both halves: a `[.taskless/**]` matcher is unnecessary on a whole-project check, AND it silences the rule on a path you name, such as the rule's own fixture bucket. It was previously described as acting only under a bare `vale` invocation, which read as harmless. `agent update` (topic v10) is corrected to match. diff --git a/packages/cli/src/agent/create-vale-rule.md b/packages/cli/src/agent/create-vale-rule.md index def50b55..6e407c57 100644 --- a/packages/cli/src/agent/create-vale-rule.md +++ b/packages/cli/src/agent/create-vale-rule.md @@ -712,8 +712,9 @@ it. the rule: if it was the only `YES`, the config is rejected, not advised - a `[*]` matcher, which reaches every file Vale can read - - a matcher under `.taskless/**`, which `check` already excludes - before Vale runs, so it acts only under a bare `vale` invocation + - a matcher under `.taskless/**`, which a whole-project `check` + already excludes, and which silences the rule over a fixture + bucket you name on purpose (step 6) Each advisory has a legitimate reading, which is what separates the two lists. Fix a rejection before moving on; read an advisory and @@ -1038,11 +1039,12 @@ it. run `vale` directly, and do not add config to make a bare run behave. The matcher that comes from doing so is `[.taskless/**]` with the rule set to `NO`, meant to keep a bare run quiet over - fixtures that hold violations on purpose. `check` excludes - `.taskless/` before Vale runs on a whole-project walk, so that block - only ever acts under the invocation this paragraph tells you not to - use, and `verify` reports it as unnecessary (step 4 lists the - advisory). A rule needs the matchers for the files it is about and + fixtures that hold violations on purpose. It does not stay confined + to the invocation it was written for: a whole-project `check` skips + `.taskless/` without its help, and on a fixture bucket you name it + is the one thing acting, which is how it empties the `check` above + while `test` stays green. `verify` reports it as an advisory (step 4 + lists it). A rule needs the matchers for the files it is about and no more. When a `fail/` document does not fire, work down this list before diff --git a/packages/cli/src/agent/update.md b/packages/cli/src/agent/update.md index b1b1d936..195fbd24 100644 --- a/packages/cli/src/agent/update.md +++ b/packages/cli/src/agent/update.md @@ -1,4 +1,4 @@ -# Topic: update (CLI v%(CLI_VERSION)s / topic v9) +# Topic: update (CLI v%(CLI_VERSION)s / topic v10) ## You are here This is `update`. It tells you what an upgrade changed for the rules @@ -324,9 +324,10 @@ Vale was ignoring with a `W101`); a matcher with no `tskl) rule = ` breadcrumb; a key naming another rule (`no-hedging.no-hedging = NO` inside `no-simply`'s config, which was a cross-rule override); and a `NO` matcher declared before every `YES`, which the `YES` was -overriding. A `.taskless/**` matcher is reported as unnecessary rather -than rejected, since `check` excludes that tree before Vale runs; -delete it. `%(TASKLESS_CLI)s agent create-vale-rule` lists every +overriding. A `.taskless/**` matcher is reported as an advisory rather +than rejected: a whole-project `check` excludes that tree anyway, and +on a fixture path you name the matcher is what silences the rule. +Delete it. `%(TASKLESS_CLI)s agent create-vale-rule` lists every rejection and advisory. Vale also moves from 3.21.0 to 3.22.0 in this release. Two things diff --git a/packages/cli/src/schemas/vale-config.ts b/packages/cli/src/schemas/vale-config.ts index 48ea4950..b4f43af5 100644 --- a/packages/cli/src/schemas/vale-config.ts +++ b/packages/cli/src/schemas/vale-config.ts @@ -425,7 +425,7 @@ function valeRuleConfigSchema(ruleId: string) { // --- Advisories -------------------------------------------------------------- -/** The tree `check` excludes before Vale runs. */ +/** The tree `check` excludes before Vale runs, on a whole-project walk. */ const TASKLESS_TREE_PREFIX = ".taskless/"; /** @@ -433,8 +433,18 @@ const TASKLESS_TREE_PREFIX = ".taskless/"; * * Each of these has a legitimate reading, so none is a rejection: a repeated * key may be a deliberate override an author is mid-way through, `[*]` may be - * meant, and a `.taskless/**` matcher is harmless, only unnecessary. They are - * said rather than refused. + * meant, and a `.taskless/**` matcher may be an author keeping a bare `vale` + * run quiet over fixtures that hold violations on purpose. They are said + * rather than refused. + * + * The `.taskless/**` advisory used to call that matcher harmless, on the + * reasoning that `check` excludes the tree anyway. It does not always: the + * exclusion is applied on a whole-project walk only, because an explicit path + * is a request (`rules/vale/run.ts`). So the matcher does act on + * `check .taskless/rules/vale//.tests/fail`, which is the one command + * that shows an author a rendered message, and it empties the result while + * `test` stays green (taskless/cli#370). Still an advisory, since the matcher + * has a reading; no longer described as costing nothing. */ function adviseValeRuleConfig( ruleId: string, @@ -460,8 +470,8 @@ function adviseValeRuleConfig( } if (section.name.startsWith(TASKLESS_TREE_PREFIX)) { advisories.push( - `${where(ruleId, section)} matcher ${label} is unnecessary: check excludes .taskless/ before Vale runs, ` + - `so it acts only under a bare vale invocation.` + `${where(ruleId, section)} matcher ${label} is unnecessary on a whole-project check, which excludes .taskless/ before Vale runs, ` + + `and it silences the rule on a path you name, such as its own fixture bucket.` ); } diff --git a/packages/cli/test/vale-config-schema.test.ts b/packages/cli/test/vale-config-schema.test.ts index f2c90a49..fa93a2e3 100644 --- a/packages/cli/test/vale-config-schema.test.ts +++ b/packages/cli/test/vale-config-schema.test.ts @@ -382,8 +382,14 @@ describe("validateValeRuleConfig advises, without rejecting", () => { const result = verdict("taskless-tree"); expect(result.rejections).toEqual([]); expect(result.advisories).toEqual([ - "no-simply/.vale.ini line 5: matcher [.taskless/**] is unnecessary: check excludes .taskless/ before Vale runs, " + - "so it acts only under a bare vale invocation.", + // The advisory names both halves deliberately. It said the matcher only + // ever acted under a bare `vale`, which is wrong: `check` drops + // `.taskless/` on a whole-project walk only, so the matcher does bite on + // an explicitly named fixture bucket, and there it empties the one + // command that shows a rendered message (taskless/cli#370). + "no-simply/.vale.ini line 5: matcher [.taskless/**] is unnecessary on a whole-project check, " + + "which excludes .taskless/ before Vale runs, " + + "and it silences the rule on a path you name, such as its own fixture bucket.", ]); }); }); From bc16225811bb55ddd70707b23ad2a67b701f9293 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 22 Sep 2026 13:39:32 -0700 Subject: [PATCH 3/4] test(vale): reuse existenceOver for the raw regex cases --- packages/cli/test/vale-vendor-contract.test.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/cli/test/vale-vendor-contract.test.ts b/packages/cli/test/vale-vendor-contract.test.ts index 370104a4..a26c0641 100644 --- a/packages/cli/test/vale-vendor-contract.test.ts +++ b/packages/cli/test/vale-vendor-contract.test.ts @@ -87,9 +87,6 @@ function runRaw(cwd: string, paths: string[], extraArguments: string[] = []) { const header = "StylesPath = .\nMinAlertLevel = suggestion\n"; -/** An `existence` rule whose single `raw` entry is the pattern under test. */ -const rawPatternRule = (pattern: string) => - `extends: existence\nmessage: "%s"\nlevel: warning\nraw:\n - '${pattern}'\n`; const existence = (token: string, level = "warning") => `extends: existence\nmessage: "Avoid '${token}'"\nlevel: ${level}\ntokens:\n - ${token}\n`; @@ -761,7 +758,7 @@ withVale("Vale vendor contract", () => { // pattern that compiled and did not match, so the negative half alone // would pass for the wrong reason. it("matches a backreference to an earlier group", () => { - const repeated = rawPatternRule(String.raw`\b(\w+) \1\b`); + const repeated = existenceOver("raw", String.raw`'\b(\w+) \1\b'`); expect(lines(repeated, "A the the repeated word.\n").messages).toEqual([ "the the", ]); @@ -769,7 +766,7 @@ withVale("Vale vendor contract", () => { }); it("matches a lookahead", () => { - const ahead = rawPatternRule("foo(?= bar)"); + const ahead = existenceOver("raw", "'foo(?= bar)'"); expect(lines(ahead, "We wrote foo bar here.\n").messages).toEqual([ "foo", ]); @@ -777,7 +774,7 @@ withVale("Vale vendor contract", () => { }); it("matches a lookbehind", () => { - const behind = rawPatternRule("(?<=x )y"); + const behind = existenceOver("raw", "'(?<=x )y'"); expect(lines(behind, "Here is x y now.\n").messages).toEqual(["y"]); expect(lines(behind, "Here is z y now.\n").lines).toEqual([]); }); From f4d82cb1b016be7f6e4f48dacb48368deee1fbb7 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 22 Sep 2026 14:01:17 -0700 Subject: [PATCH 4/4] fix(vale): measure tokens and swap, and correct the spec to match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recipe scopes the lookaround/backreference claim to `tokens` and `swap`, but every measurement behind it went through `raw`. Measuring the other two against the vendored binary found two silent divergences: - a backreference does nothing as a `swap` key, where the identical pattern fires under `tokens` and `raw`, with nothing on stderr - the implicit `\b` on `tokens`/`swap` is appended after a trailing lookahead, so `foo(?=bar)` can never match there while `raw` fires Step 3 now carries both as caveats instead of a flat "they work", and the vendor-contract suite pins them. Also corrects `cli-vale-rule-engine`, which still described a `[.taskless/**]` matcher as acting "only under a bare vale invocation" — the claim #370/#371 removed from the recipe and the advisory — and states the imprecise topic span as the measured v1-through-v12. --- .changeset/vale-recipe-regex-and-fixtures.md | 2 +- .../.openspec.yaml | 2 + .../proposal.md | 55 +++++++++++ .../specs/cli-vale-rule-engine/spec.md | 84 ++++++++++++++++ .../tasks.md | 12 +++ openspec/specs/cli-vale-rule-engine/spec.md | 5 +- packages/cli/src/agent/create-vale-rule.md | 16 ++++ .../cli/test/vale-vendor-contract.test.ts | 95 ++++++++++++++++++- 8 files changed, 263 insertions(+), 8 deletions(-) create mode 100644 openspec/changes/archive/2026-09-22-vale-taskless-matcher-spec/.openspec.yaml create mode 100644 openspec/changes/archive/2026-09-22-vale-taskless-matcher-spec/proposal.md create mode 100644 openspec/changes/archive/2026-09-22-vale-taskless-matcher-spec/specs/cli-vale-rule-engine/spec.md create mode 100644 openspec/changes/archive/2026-09-22-vale-taskless-matcher-spec/tasks.md diff --git a/.changeset/vale-recipe-regex-and-fixtures.md b/.changeset/vale-recipe-regex-and-fixtures.md index ffaf76dc..220bb018 100644 --- a/.changeset/vale-recipe-regex-and-fixtures.md +++ b/.changeset/vale-recipe-regex-and-fixtures.md @@ -2,6 +2,6 @@ "@taskless/cli": patch --- -`agent create-vale-rule` (topic v13) corrects two claims that cost rule authors work. Vale patterns are not RE2-only: Vale compiles with Go's `regexp` and falls back to `regexp2`, so lookahead, lookbehind and backreferences all work, and the recipe no longer tells you to split a rule that one pattern expresses. `check` on a rule's `.tests/fail` bucket is a supported way to read a rendered message, because `.taskless/` is excluded from the whole-project walk only; when that bucket comes back empty, the cause named is the rule's own `[.taskless/**]` matcher rather than the config. +`agent create-vale-rule` (topic v13) corrects two claims that cost rule authors work. Vale patterns are not RE2-only: Vale compiles with Go's `regexp` and falls back to `regexp2`, so lookahead, lookbehind and backreferences work, and the recipe no longer tells you to split a rule that one pattern expresses. Two silent limits are measured and documented alongside it: a backreference does nothing as a `swap` key, and the implicit word boundary on `tokens`/`swap` lands after a trailing lookahead, so that lookahead has to peek at a non-word character. `check` on a rule's `.tests/fail` bucket is a supported way to read a rendered message, because `.taskless/` is excluded from the whole-project walk only; when that bucket comes back empty, the recipe now sends you to the rule's own config, specifically a `[.taskless/**]` matcher, before the pattern. `verify` carried the same imprecision and now states both halves: a `[.taskless/**]` matcher is unnecessary on a whole-project check, AND it silences the rule on a path you name, such as the rule's own fixture bucket. It was previously described as acting only under a bare `vale` invocation, which read as harmless. `agent update` (topic v10) is corrected to match. diff --git a/openspec/changes/archive/2026-09-22-vale-taskless-matcher-spec/.openspec.yaml b/openspec/changes/archive/2026-09-22-vale-taskless-matcher-spec/.openspec.yaml new file mode 100644 index 00000000..1b9acb7f --- /dev/null +++ b/openspec/changes/archive/2026-09-22-vale-taskless-matcher-spec/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-22 diff --git a/openspec/changes/archive/2026-09-22-vale-taskless-matcher-spec/proposal.md b/openspec/changes/archive/2026-09-22-vale-taskless-matcher-spec/proposal.md new file mode 100644 index 00000000..6a07c81a --- /dev/null +++ b/openspec/changes/archive/2026-09-22-vale-taskless-matcher-spec/proposal.md @@ -0,0 +1,55 @@ +## Why + +taskless/cli#370 and #371 corrected the recipe and the `verify` advisory: a +`[.taskless/**]` matcher is not harmless. `check` excludes `.taskless/` from a +_whole-project walk_ only, so the matcher does nothing there — but on a path +named explicitly, such as the rule's own `.tests/fail` bucket, the matcher is +live and silences the rule. That is the shape that reproduces "`test` says the +fixture fired, `check` on the same fixture says nothing". + +The recipe, the `update` ledger and the advisory string now all say both halves. +The standing spec does not. `cli-vale-rule-engine` still reads "`check` excludes +that tree before Vale runs, so the matcher acts only under a bare `vale` +invocation", and its scenario still requires `verify` to "report that `check` +already excludes that tree" — the exact imprecise phrasing the code no longer +ships. The spec is the source of truth for this capability, so leaving it +disagreeing with the advisory it describes is how the next author reproduces +#370 from the spec instead of the recipe. + +This change carries no code. The implementation already landed in this PR; the +spec is what is behind. + +## What Changes + +- **`cli-vale-rule-engine`** — the advisory bullet and the + `.taskless/**` scenario state both halves of the behaviour: unnecessary on a + whole-project check, AND silencing on a named path. The scenario also gains + the `check`-notices half that the other advisory scenarios already carry, so + the two advisory paths are specified alike. + +No requirement is added or removed, and no behaviour changes: this is the spec +catching up to an advisory string and a recipe that already shipped. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `cli-vale-rule-engine`: "A rule's Vale config is validated against a schema + before it is assembled" — the `.taskless/**` advisory bullet and its scenario + are restated to match the shipped advisory. + +## Impact + +Documentation only. No source file, test, or public surface changes. The bump +stays `patch` and rides the existing changeset for this PR; no second changeset +is added. + +## Delivery shape + +**Single PR.** The spec correction is two edits inside one requirement and +belongs with the code change that made the standing text wrong, which is this +PR. It is the tip, so the change is archived here. diff --git a/openspec/changes/archive/2026-09-22-vale-taskless-matcher-spec/specs/cli-vale-rule-engine/spec.md b/openspec/changes/archive/2026-09-22-vale-taskless-matcher-spec/specs/cli-vale-rule-engine/spec.md new file mode 100644 index 00000000..841a7d69 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-vale-taskless-matcher-spec/specs/cli-vale-rule-engine/spec.md @@ -0,0 +1,84 @@ +## MODIFIED Requirements + +### Requirement: A rule's Vale config is validated against a schema before it is assembled + +The system SHALL parse each rule's `.vale.ini` into an ordered, lossless AST and validate that AST against a schema keyed by the rule's directory id, before the config is assembled into the run config and when the rule is verified. Validation SHALL be performed on the parsed structure, never by matching the file's text. + +The schema SHALL reject a config that: + +- assigns any property above its first matcher (`StylesPath`, `MinAlertLevel`, or anything else; Vale ignores such a line with a `W101` warning and the rule verifies clean while enabled nowhere) +- declares a matcher without a `tskl) rule = ` breadcrumb naming this rule +- assigns a key other than `.` (a `