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

Close the gaps in `create-vale-rule` that produce a rule which is green
everywhere and reports nothing.

A malformed Vale rule fails loudly. A Vale rule that is merely _wrong_ passes
`verify`, passes `test`, and never fires — and nobody re-checks a green rule.
The recipe now documents each of those failures as an observed behavior of the
pinned Vale binary rather than as a caution in principle:

- **The measured `scope` vocabulary**, with what each value actually reaches.
`raw` subsumes `code` and `text`; `~` negation and `&` chaining are accepted;
and a negation over a scope Vale does not know (`~fenced`) is a silent no-op
that removes the exclusion you wrote the rule for.
- **`scope` is per-rule.** Taskless assembles one config per run, which invites
the assumption that scopes interact. They do not.
- **A `raw`-scoped rule cannot be suppressed** by `<!-- vale Rule = NO -->`,
because it reads the unparsed document — so a rule about a shell command needs
`raw` and trades away per-case exemption.
- **A punctuation-only token needs `nonword: true`**, because Vale wraps every
token in word boundaries and an em dash has no word character on either side.
- **How to scope a rule _out_**, with a second matcher assigning `NO`.
- **Collocation guidance** for a banned word, checked by writing the `pass/`
fixture from the literal sense first.
- **Fixture design for a subject that appears in code**: `fail/` must carry it
inline, fenced, and in prose.
- **`limit` and `vocab`** in the common-fields table, and that Vale loads only
`.yml` — a style file renamed to `.yaml` is silently not loaded at all.
- **Fixtures run under an isolating config**, so a green `test` is not evidence
the rule's matcher glob reaches any real file.

Vale's check types are now enumerated from the binary rather than the docs.
There are **twelve**, not eleven: the docs fold `readability` into `metric`.
The per-check field tables are measured the same way, which corrects two
published claims — `capitalization` rejects `prefixes`/`suffixes`, and
`occurrence` rejects `exceptions`.
2 changes: 2 additions & 0 deletions openspec/changes/vale-authoring-polish/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-25
197 changes: 197 additions & 0 deletions openspec/changes/vale-authoring-polish/design.md

Large diffs are not rendered by default.

45 changes: 45 additions & 0 deletions openspec/changes/vale-authoring-polish/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
## Why

A Vale rule that is _malformed_ fails loudly. A Vale rule that is _wrong_ passes `verify`, passes `test`, and reports nothing forever — and nobody re-checks a green rule. Authoring the first house-style rules against this repository produced three of those in a row, each green on every local gate:

- An em-dash rule whose token can never match, because Vale wraps tokens in word boundaries and an em dash is non-word on both sides. It needed `nonword: true`, which nothing asked for.
- A rule about a shell command that caught zero of two real violations, because commands in a README live in fenced blocks and Vale skips those by default.
- A rule scoped to `packages/cli/src/**/*.ts` whose clean report was indistinguishable from a matcher glob that reached nothing.

Measured against the pinned Vale 3.18.0, the engine does not close these. `extends: nonsense` and `scope: fenced` both pass `verify` and simply match nothing; a field belonging to a different check type throws `E201`, which takes **every** Vale rule in the project down because Vale reads one config per run. The `create-vale-rule` recipe carries the knowledge that exists, but it is incomplete in the specific places that produce silent rules.

## What Changes

- **`verify` validates a Vale rule file structurally**, before Vale sees it, against a schema that models the check types and their fields. Today it validates `level` and the presence of the rule's `.vale.ini`, and nothing else — `extends: nonsense` verifies clean.
- **The schema is hand-authored and pinned to `VALE_VERSION`**, because there is nothing to generate it from. Vale publishes no JSON Schema; the machine-readable field knowledge exists only behind its paid cloud MCP at `api.vale.sh/mcp`, which `verify` cannot depend on. The vendor-contract test is what stops the schema drifting from the binary.
- **`scope` gains a real enum**, including the hierarchical forms, the v3.17.0+ inline scopes, and the v3.18.0+ `meta` scopes — and accepts the `~` negation and `&` chaining syntax that the recipe never documents and that a naive enum would reject.
- **The `create-vale-rule` recipe closes the gaps that produce silent rules**: the measured scope table, `nonword` for punctuation tokens, how to scope a rule _out_ (the recipe only explains scoping in), fixture design for a subject that normally appears in code, and the `limit`/`vocab` fields it omits.
- **No breaking change.** Every rule that verifies today continues to verify; the schema only rejects rules the engine was already going to ignore or fail on.

## Capabilities

### New Capabilities

None. This tightens two existing capabilities rather than introducing a surface.

### Modified Capabilities

- `cli-rule-validation`: the `verify` component table currently says a Vale rule is checked "against Vale's own validation." Measured, that covers `level` and not `extends` or `scope`. The requirement changes to name schema validation as its own layer for `vale`, matching how `sg` is already checked against the ast-grep schema.
- `cli-agent-authoring`: the Vale authoring recipe requirement gains the scope, token, and fixture guidance whose absence is what produces a green-but-inert rule.

## Impact

- `packages/cli/src/rules/vale/verify.ts` — today entirely fixture behavior (bucket coverage, which fixtures fired); gains a schema layer.
- A new generated-or-authored schema artifact alongside `packages/cli/src/generated/ast-grep-rule-schema.json`, plus whatever pins it to `VALE_VERSION`.
- `packages/cli/test/vale-vendor-contract.test.ts` — extended to hold the schema's claims against the pinned binary.
- `packages/cli/src/agent/create-vale-rule.txt` — the recipe text.
- Closes #171 and #170; #167 is the worked case that motivated both.

## Delivery shape

**Stacked, merging forward.** Two units, each independently safe in production:

1. **The recipe gaps.** Text only, no behavior change, and it is the half that helps an author today. It can land alone.
2. **The schema and its `verify` layer.** Code, tests, and the vendor-contract additions.

Landing 1 alone leaves `check`, `verify`, and the test suite exactly as they are, so the stack merges forward rather than down. The changeset goes on the bottom branch and grows as the second unit lands.
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
## MODIFIED Requirements

### Requirement: The Vale authoring recipe covers rule, scope, and fixtures

The `create-vale-rule` recipe SHALL instruct the agent to produce three artifacts, and SHALL state that a rule is incomplete without all three:

1. A Vale style file at `.taskless/rules/vale/<id>/<id>.yml`.
2. That rule's own `.taskless/rules/vale/<id>/.vale.ini`, declaring the matchers that scope it and enabling it as `<id>.<id> = YES`.
3. `pass/` and `fail/` fixture documents under `.taskless/rules/vale/<id>/.tests/`.

The recipe SHALL state that scope is declared in the rule's own config, that no shared file is edited, and that the project-wide config is assembled rather than authored.

It SHALL direct the agent to check its work by running `verify` and then `test` against the rule's directory path.

The previous version of this requirement taught the agent to add a matcher to a single project-wide `.vale.ini`. Executing that recipe against sandboxed agents found every one of its silent failures in that step and nowhere else — an assignment above the first matcher, a glob that missed the fixture extension, three names that had to agree with nothing reporting when they didn't. The layout change removes the step rather than documenting it further.

The recipe SHALL additionally carry the guidance below. Each item is a failure observed while authoring rules against this repository, and each produced a rule that passed `verify` and `test` while reporting nothing:

- **What each `scope` reaches**, as measured: `text` sees prose, `code` sees inline code spans, `[code, text]` sees both, and `raw` sees prose, inline code, and fenced blocks. `raw` subsumes the other two.
- **That `scope` is per-rule.** Vale assembles one config per run, which invites the assumption that scopes interact. They do not.
- **That a rule scoped to `raw` cannot be suppressed** by Vale's `<!-- vale Rule = NO -->` directive, because it reads the unparsed document. A rule about a command needs `raw`, so it trades away per-case exemption.
- **That a token made only of punctuation needs `nonword: true`**, because Vale wraps every token in word boundaries.
- **How to scope a rule out**, not only in: a second matcher assigning `<id>.<id> = NO`.
- **That a bare word finds senses you did not mean**, and that narrowing to a collocation is checked by writing the `pass/` fixture from the literal sense first.
- **That fixture design follows the rule's subject**: when the subject normally appears in code, the `fail/` fixture SHALL contain it inline, fenced, and in prose.
- **The `limit` and `vocab` common fields**, which the recipe's field table omits.

#### Scenario: Authoring produces all three artifacts

- **WHEN** the agent follows `create-vale-rule`
- **THEN** it writes the style file, the rule's own config with a scoping matcher, and both fixture buckets

#### Scenario: No shared file is edited

- **WHEN** the agent scopes a rule
- **THEN** it writes matchers into that rule's own config
- **AND** it SHALL NOT be directed to edit a project-wide Vale config

#### Scenario: The recipe names the commands that check the work

- **WHEN** the agent has written the three artifacts
- **THEN** the recipe SHALL direct it to run `verify` and `test` against the rule's path

#### Scenario: An unscoped rule is not silently accepted

- **WHEN** the agent writes a style file without a matcher enabling it in the rule's own config
- **THEN** the recipe SHALL identify this as incomplete
- **AND** `verify` SHALL report it

#### Scenario: A rule about a command reaches fenced blocks

- **WHEN** the agent authors a rule whose subject is a command, flag, or package name
- **THEN** the recipe SHALL direct it to a scope that reaches fenced blocks
- **AND** the `fail/` fixture SHALL carry the subject inline, fenced, and in prose

#### Scenario: A punctuation token is not left unable to match

- **WHEN** the agent authors a rule whose token contains no word characters
- **THEN** the recipe SHALL direct it to set `nonword: true`

## ADDED Requirements

### Requirement: An authoring recipe states the failure a rule cannot report itself

An authoring recipe SHALL document the ways a rule of its engine can be well formed, enabled, green on its fixtures, and still report nothing.

A malformed rule is caught by `verify` and needs no recipe. A rule that is merely _wrong_ is caught by nothing, ships green, and is discovered only when someone notices it has never fired — so the recipe is the only place that failure can be prevented.

#### Scenario: The recipe names the silent failures for its engine

- **WHEN** an authoring recipe is written or revised
- **THEN** it SHALL name the failures that pass every local gate for that engine
- **AND** each SHALL be stated as an observed behavior of the pinned engine rather than as a caution in principle

#### Scenario: Passing fixtures are not presented as proof of reach

- **WHEN** the recipe directs the agent to run `test`
- **THEN** it SHALL state that fixtures run under an isolating config
- **AND** that a clean `check` SHALL be confirmed against a real file before the rule is believed to be working
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
## MODIFIED Requirements

### Requirement: Verify checks a rule's required components

`verify` SHALL check that a rule has the components its engine requires and that they are well formed, and SHALL NOT require fixtures or test cases to exist.

The two commands split because they have different preconditions. An agent part-way through authoring has a rule and no fixtures yet, and needs to know the rule itself is valid before it can write a meaningful test for it.

Per engine, `verify` SHALL check:

| Engine | Components |
| --------- | ---------------------------------------------------------------------------------------------------- |
| `sg` | `<id>.yml` against the ast-grep schema and the Taskless required fields |
| `vale` | `<id>.yml` against the Vale rule schema and the Taskless required fields, and the rule's `.vale.ini` |
| `runtime` | `check.ts` present, and at least one capture rule under `captures/` |

The `vale` row previously read "against Vale's own validation." Measured against the pinned 3.18.0 binary, that covers less than it claims: `level: bananas` is reported, while `extends: nonsense` and `scope: fenced` both verify clean and produce a rule that matches nothing. Vale validates a rule when it _runs_ one, and it runs one field at a time — so a name it does not recognize is not an error, it is a check that never fires. Schema validation is therefore its own layer for `vale`, as it already is for `sg`.

#### Scenario: A rule with no fixtures still verifies

- **WHEN** `verify` runs against a rule whose fixture buckets are empty or absent
- **THEN** it SHALL report on the rule's components only
- **AND** the absence of fixtures SHALL NOT be a verify failure

#### Scenario: A malformed rule reports its own error

- **WHEN** a Vale style declares a `level` outside `suggestion`/`warning`/`error`
- **THEN** `verify` SHALL report that error, naming the field

#### Scenario: An unrecognized extension point is rejected

- **WHEN** a Vale style declares an `extends` that is not one of Vale's check types
- **THEN** `verify` SHALL report it, naming the field and the accepted values
- **AND** it SHALL NOT report the rule as valid

#### Scenario: An unrecognized scope is rejected

- **WHEN** a Vale style declares a `scope` that is not one of Vale's scope values
- **THEN** `verify` SHALL report it, naming the field
- **AND** a scope using the `~` negation or `&` chaining syntax over recognized values SHALL be accepted

#### Scenario: A field belonging to another check type is rejected

- **WHEN** a Vale style declares a field its `extends` does not accept, such as `tokens` on an `occurrence` check
- **THEN** `verify` SHALL report it before Vale is invoked

The failure it prevents is not a local one: Vale reports this as `E201: has invalid keys` and reads one assembled config per run, so a single rule with a stray field suppresses every other Vale rule's findings.

## ADDED Requirements

### Requirement: The Vale rule schema is pinned to the vendored binary

The Vale rule schema SHALL be authored in this repository and pinned to `VALE_VERSION`, and a vendor-contract test SHALL hold its claims against the vendored binary.

Vale publishes no JSON Schema for its check types. The machine-readable field knowledge exists only behind its hosted MCP server, which is a paid product and unavailable to `verify`. The schema is therefore a transcription, and a transcription drifts — so the binary, not the documentation, SHALL be the authority for what the schema asserts.

#### Scenario: A Vale upgrade that invalidates the schema fails loudly

- **WHEN** `VALE_VERSION` is raised to a version whose accepted check types or scopes differ from the schema
- **THEN** the vendor-contract test SHALL fail
- **AND** the failure SHALL name the field whose accepted values changed

#### Scenario: The schema is not derived from documentation alone

- **WHEN** a value is added to the schema's `extends` or `scope` enumerations
- **THEN** it SHALL be one the vendored binary was measured accepting
Loading
Loading