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
48 changes: 48 additions & 0 deletions .changeset/vale-schema-generation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
"@taskless/cli": patch
---

Derive the Vale rule schema's vocabulary from the vendored binary instead of
transcribing it by hand.

`pnpm generate:vale-schema` runs the pinned Vale against rules it writes itself
and emits `src/generated/vale-vocabulary.ts` — twelve check types, three levels,
ten per-check field tables, twenty-eight scope operands, two open scope
families — plus a divergence report. `src/schemas/vale-rule.ts` imports it and
stays what it was: the zod layer, the scope grammar, and the error messages that
explain blast radius to an author. Nothing about `verify`'s behavior changes; all
86 existing corpus rows pass against the generated schema unmodified, and two
were added to cover ground the generation newly measured.

What a transcription lost was not the answer but the question. Every value in the
previous schema _was_ measured — by a script that was then discarded, leaving the
next person to raise `VALE_VERSION` with a failing test and no way to reproduce
the measurement it was failing against.

The measuring is also where the errors live, so the generator is built around one
rule: **every verdict comes from the process exit status and the structured JSON
output, never from matching stdout against an error phrase.** A Go panic contains
no `has invalid keys` string, so a phrase-grep scores a crash as a clean run —
which is exactly how a tokenless `sequence` rule once came to look like a check
that validates nothing. A run's outcome is a closed set of `clean`, `diagnostic`,
`panic`, and `unrecognized`, and the last one is fatal at every call site.

Two of the four vocabularies are self-enumerating: an unknown `extends` or
`level` makes the binary name its own accepted set. If either of those lines
stops matching, generation **fails** rather than emitting a short enum — a
truncated enum is _stricter_ than the binary, which is the direction that blocks
rules that would have worked.

The other two are honest about their limit, and the artifact says so. `E201`
names the key you got wrong and never the ones you could have used, and an
unrecognized `scope` raises nothing at all — so field tables and scope operands
are **verified, not discovered**, from a candidate list seeded from four sources
with its provenance recorded. A scope verdict is three-valued, and a `scope: raw`
reach probe must fire on every fixture, so an operand cannot be dropped because
its fixture was never linted.

Where the binary and Vale's documentation disagree, `vale-vocabulary-report.md`
records it rather than either side being quietly dropped: `meta` and
`meta.class.<kind>` are documented and never fire; `frontmatter` and
`frontmatter.<key>` fire and are documented nowhere; `consistency` and `spelling`
validate no keys at all.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-25
110 changes: 110 additions & 0 deletions openspec/changes/archive/2026-08-25-vale-schema-generation/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Design

## The method rule, stated first because it is the whole risk

**Every verdict comes from the process exit status and the structured JSON output. Never from grepping stdout for an error phrase.**

This is not style guidance. It has already produced one wrong answer in this project:

| Probe | Verdict |
| -------------------------------------------------- | --------------------------------------- |
| `sequence`, bare rule, grep for `has invalid keys` | "accepts any key" — permissive |
| `sequence`, bare rule, actual behavior | `panic: interface conversion: … is nil` |

A Go panic contains no `has invalid keys` string, so a phrase-grep scores a crash as a clean run. Run Vale with `--output=JSON`; on a config error it writes `{Line, Path, Text, Code, Span}` to **stderr**. The generator therefore models the outcome of a run as a closed set with no escape hatch:

```
clean status 0, stdout parses as a findings map
diagnostic status ≠ 0, stderr parses as Vale's JSON object, keyed on Code
panic stderr contains a Go panic header
unrecognized everything else
```

`unrecognized` is fatal at every call site. There is deliberately no branch that folds an unfamiliar shape into "fine", because that is the failure the corpus caught the first time and cannot be relied on to catch the second.

## What is derived and what is seeded

Four vocabularies, and they do not have equal standing.

| Vocabulary | Oracle | Discovers? |
| ---------------- | -------------------------------------------------- | ---------- |
| Check types (12) | `'extends' key must be one of [...]` | **yes** |
| Levels (3) | `'level' must be one of [...]` | **yes** |
| Per-check fields | `has invalid keys: '<name>'` — names the _bad_ key | no |
| Scope operands | none; an unknown scope is silent | no |

The top two are self-enumerating: give the binary a sentinel and it names its own accepted set. A Vale release that adds a check type is picked up rather than merely failing a test.

The bottom two are **verified, not discovered**, and the difference is load-bearing. `E201` names the key you got wrong and never the ones you could have used, so a field table can only be built by proposing a candidate and asking. A real field nobody proposes is absent from the artifact, and the schema then rejects a rule Vale accepts — the too-strict direction, which the design of the schema itself ranks as the worse failure. The candidate list is therefore seeded from four independent sources and its provenance is documented in the generator:

1. Vale's published key documentation, **including names the binary rejects** — `prefixes`, `suffixes`, and `ignorecase` on `capitalization` stay in the list precisely so their rejection is a recorded finding rather than an omission.
2. The hand transcription this replaces, so the generator is a superset of what a human already established.
3. Vale's shared keys: `link`, `limit`, `action`, `scope`, `description`, `name`, `comment`, `vocab`.
4. Every check's fields offered to every other check. That cross-probe is what makes the per-check tables a measured _partition_ rather than twelve unrelated lists.

## Membership, and the two inferences

The probe sets a candidate key to an arbitrary value (`true`) and reads the outcome as evidence about the **key**, not the value:

| Outcome | Verdict | Why |
| ----------------------------------------- | ---------------- | --------------------------------------------------------- |
| clean | member | the key decoded |
| `has invalid keys` naming this field | **not** a member | the only negative oracle Vale offers |
| any other `E201` | member | Vale knew the key and objected to the value |
| panic | member | an unknown key is collected as unused, never dereferenced |
| invalid-key list naming a _different_ key | fatal | the base rule is contaminated |

The last two rows of the positive column are inferences rather than readings, so both are collected and written into the divergence report. On Vale 3.18.0 the type-complaint case fires ten times, all `action: expected a map, got 'bool'`, and the panic case fires zero times. An inference nobody can audit is a grep with better manners; naming them is what keeps the distinction real.

Two probes are excluded by construction rather than measured:

- **The header keys.** `extends`, `message` and `level` are in every base rule and were verified running clean. Probing them is also unsound: `extends: true` reaches an `interface{}` → `string` conversion and panics.
- **The sentinel.** The one probe where a panic is _not_ read as evidence. It decides whether a check validates keys at all, and a permissive verdict taken from a crash would make the schema loose for a check that is strict — which is precisely the `sequence` error, re-run.

Each check needs a **base rule**: a minimal working rule of that check to add the candidate to. That is hand-seeded and has to be, since several checks are not valid empty. Every base is verified clean before a single verdict is taken against it.

**Common fields are derived, not declared.** They are the _intersection_ of the ten strict checks' measured tables. That is a stronger statement than a hand-written list: nobody had to remember that `vocab` is per-check because five checks reject it — the intersection simply does not contain it. The permissive checks are excluded because they accept everything and so constrain nothing.

## Scope: three-valued, and only a fixture separates two of the values

`scope` has no oracle at all. An invalid scope does not fail — the rule loads, runs, and matches nothing, which from outside is identical to a valid scope whose construct is absent from the document. So the verdict is three-valued:

| Verdict | What happened |
| ------------- | ------------------------------------------------- |
| `fires` | the rule flagged the fixture: the operand is real |
| `silent` | the fixture was linted and the rule found nothing |
| `unreachable` | the fixture was never linted at all |

Only a hand-written fixture separates the middle from the last, and the generator refuses to guess: every fixture contains the word `bogus`, a `scope: raw` **reach probe** must fire on it, and if it does not the run is fatal. That guard is not theoretical — `figure.caption` measures `silent` when its fixture nests the caption inside a `<figure>`, and so does `scope: text` over the same document, because Vale drops everything inside that element. Without the reach guard the operand would be dropped as one Vale ignores, and every rule using it would then fail `verify`.

The file extension is part of the fixture, not a detail: it decides which parser Vale routes the document to, and `comment.*` only exists in a source tier.

## Divergences are reported, not resolved

Where the binary and the documentation disagree, a generator that dropped the finding would be quietly deciding which to believe. The artifact carries `VALE_DIVERGENCES` and the run writes `vale-vocabulary-report.md`. Measured on 3.18.0:

- **`meta` and `meta.class.<kind>` are documented and never fire.** Omitted from the vocabulary, so `verify` rejects them. A rule written from the docs would otherwise be inert forever with no error anywhere.
- **`frontmatter` and `frontmatter.<key>` fire and are documented nowhere.** Included. This is the standing proof that a candidate list verifies rather than discovers: nobody proposing from the documentation alone would have found them.
- **`consistency` and `spelling` validate no keys at all**, measured with a sentinel. The schema stays permissive there; being strict would reject rules the binary runs.
- **Ten field probes drew a type complaint rather than a key rejection**, all on `action`. Recorded as members, and named so the inference is auditable.

Three claims carried into this change from earlier measurement did **not** reproduce, and the report is where that surfaced:

- `figure.caption` does fire, on a _bare_ `<figcaption>` in an `.html` document. What never fires is the nested-in-`<figure>` form, and the earlier note conflated the two.
- `comment.block` fires — in `.js` and in `.ts` alike. It does not "never fire while `comment.line` catches both".
- `comment.*` does not need a `.ts` fixture specifically; `.js` reaches the same tier. Rows for both now exist in the corpus, because "fires in a source file" and "fires in _this_ source file" are different claims and the schema makes the wider one.

## What stays hand-written, and why each is not a generation failure

- **The three fatal shapes.** A tokenless `sequence`, a `sequence` whose `tokens` is not a list, a `metric` with a `formula` and no `condition`. Every key in these rules is a legal field of its check — it is the _shape_ that is fatal, so a field table cannot express it. A panic is also a wider blast radius than `E201`: no rule name, no findings for anything, nothing to act on.
- **The scope grammar.** `~` negation and `&` chaining are operators around the operands, not values in the enum. Rejecting `~fenced` is a deliberate business rule, not a transcription, and the corpus carries it as a recorded divergence.
- **The union members.** They are spelled out rather than mapped over the generated table, because that is what carries each `z.literal` into `ValeRule`; a mapped union infers `extends: string`. The cost is a hand-maintained list beside a derived one, so an import-time guard requires every derived check type to appear exactly once and to be classified strict or permissive. The failure is a sentence naming the check.
- **The version pin.** The artifact records the version it was derived from, and `vale-rule.ts` asserts it against `VALE_VERSION` — as a **conditional type**, not an `if`. Both are string literal types, so the compiler settles it and a mismatch is a build failure naming the line, rather than a throw in front of a user on whichever command loads the schema first.

## Alternatives considered

**Generate the zod schema itself.** Rejected. The interesting content of `vale-rule.ts` is not the enums; it is the error messages that explain blast radius to an author, the two-stage `.pipe()` that reproduces the binary's own order, and the case-folding transform. Generating that would mean maintaining a code emitter to reproduce prose. Generating only the vocabulary keeps the derived part small and the reviewed part readable.

**Emit JSON, like `ast-grep-rule-schema.json`.** Rejected. That file is JSON because upstream publishes JSON. Here the generator authors the artifact, and a `.ts` file gets `as const` — which is what makes `VALE_CHECK_TYPES` a literal union rather than `string[]`, and the import-time guard checkable at all.

**Keep the transcription and add a checked-in probe script.** Rejected: two sources of truth that agree only by discipline. The failure mode is the one already observed — the answer survives, the question does not.
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
## Why

`vale-authoring-polish` landed a Vale rule schema and called it, in its own module comment, **a transcription**: twelve check types, three levels, ten field tables and twenty-eight scope operands read off a binary by hand.

Every one of those was measured — by a script that was then thrown away. What survives is the answer, not the question. The next person to raise `VALE_VERSION` gets a test failure naming a field and no way to reproduce the measurement it is failing against.

The measuring is also where the errors live, and one is on the record. A probe grepping stdout for `has invalid keys` reads a Go panic as a clean run — a panic contains no such string — which is how a tokenless `sequence` rule came to look like a check that validates nothing:

```
sequence, bare rule, probe grepping for "has invalid keys" -> read as "accepts any key"
sequence, bare rule, actual behavior -> panic: interface conversion
```

A method that can make that mistake is worth writing down once, correctly.

## What Changes

- **A generator derives the vocabulary from the vendored binary.** `pnpm generate:vale-schema` writes `packages/cli/src/generated/vale-vocabulary.ts`, a checked-in artifact pinned to `VALE_VERSION`, mirroring how `fetch-ast-grep-schema.ts` produces `ast-grep-rule-schema.json`. `vale-rule.ts` imports it and keeps zod as the validation layer.
- **Verdicts come from the exit status and the structured JSON, never from a phrase match.** Vale's `--output=JSON` emits `{Line, Path, Text, Code, Span}` on stderr for a config error. The generator keys on `Code`, parses `Text` structurally, detects panics explicitly, and treats any outcome it does not recognize as fatal.
- **The generator fails loudly rather than emitting a short enum.** If the `'extends' key must be one of [...]` line stops matching, it errors. A truncated enum is _stricter_ than the binary, which is the direction that blocks working rules.
- **A divergence report ships with the artifact.** Where the binary and Vale's documentation disagree, the disagreement is written to `vale-vocabulary-report.md` rather than silently dropped on one side or the other.
- **The three fatal-shape checks stay hand-written.** A `sequence` with no `tokens`, a `sequence` whose `tokens` is not a list, and a `metric` with a `formula` and no `condition` each panic the binary. That is behavior a field table cannot express, so it stays a `.check()` beside the union.
- **No behavior change.** All 86 existing corpus rows pass unmodified against the generated schema; two rows are added to cover ground the generation newly measured.

## Capabilities

### New Capabilities

None. This changes how an existing capability's data is produced.

### Modified Capabilities

- `cli-rule-validation`: the requirement "The Vale rule schema is pinned to the vendored binary" says the schema "SHALL be authored in this repository" and calls it a transcription. It becomes a derivation: the vocabulary SHALL be generated from the vendored binary by a script in the repository, and the requirement gains the two constraints that make a derivation trustworthy — fail loudly on an unrecognized error shape, and report rather than drop a divergence.

## Impact

- `packages/cli/scripts/generate-vale-schema.ts` — new.
- `packages/cli/src/generated/vale-vocabulary.ts` and `vale-vocabulary-report.md` — new, checked in.
- `packages/cli/src/schemas/vale-rule.ts` — the enums and field tables are replaced by imports; the zod construction, the scope grammar, and the fatal-shape checks are unchanged.
- `packages/cli/package.json` — one script.
- `packages/cli/test/vale-corpus.ts` — two rows added, none modified.
- Refs #171.

## Delivery shape

**Single PR.** The generator, the artifact it produces, and the schema's switch to importing it are one reviewable diff and are only correct together: the artifact is meaningless without the generator that reproduces it, and `vale-rule.ts` does not compile without the artifact. There is no intermediate state that reaches production safely, and no unit that a reviewer would be better off seeing alone.

It stacks on `openspec/vale-authoring-polish-schema` (#175), whose schema it rewrites, so it is the tip of that stack and archives the change.
Loading
Loading