Skip to content

docs: close the create-vale-rule gaps that produce a silent rule - #174

Open
thecodedrift wants to merge 5 commits into
mainfrom
openspec/vale-authoring-polish
Open

docs: close the create-vale-rule gaps that produce a silent rule#174
thecodedrift wants to merge 5 commits into
mainfrom
openspec/vale-authoring-polish

Conversation

@thecodedrift

@thecodedrift thecodedrift commented Aug 25, 2026

Copy link
Copy Markdown
Member

Stack (root → tip):

What this is

Unit 1 of a two-PR stack for the vale-authoring-polish change. Text only — no
behavior changes, no new code paths. It carries the OpenSpec artifacts, the
changeset for the whole stack, and the create-vale-rule revisions.

Unit 2 (the schema and its verify layer) stacks on top of this branch.

Why

A malformed Vale rule fails loudly. A Vale rule that is merely wrong passes
verify, passes test, and reports nothing forever — and nobody re-checks a
green rule. Authoring house-style rules against this repository produced three
of those in a row. The recipe is the only place those can be prevented, and it
was incomplete in the specific places that produce them.

What I measured

Everything added to the recipe was measured against the vendored Vale
3.18.0
, by authoring a rule in a temp project with BasedOnStyles = (so no
bundled style can contribute a finding) and running the binary directly. Where
a verdict is "did not fire", a known-valid control was run against the same
document first.

Twelve check types, not eleven — and the binary settles it

An extends outside the set is not silently ignored. Vale rejects the file,
exits 2, and enumerates the whole set in the error:

'extends' key must be one of [capitalization conditional consistency existence
occurrence repetition substitution readability spelling sequence metric script].

That is twelve. The docs' eleven omits readability, folding it into metric;
the binary treats them as separate checks with disjoint fields
(metrics/grade versus formula/condition), and each rejects the other's.
The recipe now says twelve and quotes the message it came from.

The scope vocabulary

Measured firing: text, code, raw, heading, heading.h1h6,
paragraph, sentence, list, blockquote, link, alt, summary,
strong, emphasis, table, table.header, table.cell, table.caption,
comment, comment.line, comment.block, frontmatter,
frontmatter.<key>, figure.caption, text.class.<name>.

figure.caption took two attempts and is the best argument for the
known-valid control this change insists on. The obvious control — a
<figcaption> nested in <figure> — never fires, and neither does a
scope: text control over the same document, because Vale drops
everything inside a <figure> element
before any check runs. A bare
<figcaption> is linted normally and the scope fires on it. Read off the
first result alone, the operand would have been recorded as one the binary
ignores and dropped from the enum.

Reach, on one document holding the token in prose, in an inline span, and in a
fenced block: text → 1, code → 1, [code, text] → 2, raw → 3. raw
subsumes the other two, which is now stated rather than implied.

~ negation, & chaining, and the list form all parse and behave — but a
negation over an operand Vale does not recognize is a silent no-op.
~fenced
and text & ~fenced both fire on everything, so a typo inside a ~ removes the
exclusion the rule was written for. That one was not in the design and is new.

Corrections to the design's own draft

  • There is no meta scope. The design named meta and meta.class.<kind>
    as the v3.18.0 addition. The binary's vocabulary is text.frontmatter.<key>,
    and frontmatter / frontmatter.<key> is what fires. meta never fired in
    Markdown, MDX, or HTML.
  • extends: nonsense does not verify clean and match nothing. The design's
    Context table claims it does. On 3.18.0 it is rejected outright, exit 2, whole
    run suppressed — the same blast radius as E201. The genuinely silent case is
    scope, alone. That does not weaken the case for a schema layer, it
    re-labels it: two of the three defects are blast-radius arguments and one is a
    silence argument.

Both corrections are recorded in design.md under Measured against Vale
3.18.0
, alongside the per-check field matrix.

Per-check fields

Measured by adding each candidate key to a minimal rule of each check and
watching for E201. Two published claims are wrong: capitalization rejects
prefixes, suffixes and ignorecase; occurrence rejects exceptions and
vocab.

consistency and spelling accept any key at allbananafield: true on
either loads without complaint and is ignored. They do not use the strict decode
the other ten do, so neither Vale nor a schema can catch a typo in their fields.
The recipe says so.

What changed in the recipe

  • The measured scope table, and raw subsuming code and text.
  • scope is per-rule; the single assembled config does not make rules interact.
  • A raw-scoped rule cannot be suppressed by <!-- vale Rule = NO --> (it
    reads the unparsed document), so a rule about a command needs raw and trades
    away per-case exemption. Measured both ways.
  • nonword: true for a punctuation-only token, with the em-dash rule as the
    worked before/after.
  • Scoping a rule out, with a second matcher assigning NO — and that
    precedence is positional, so the exclusion goes second.
  • Collocation narrowing, checked by writing the pass/ fixture from the literal
    sense first.
  • When the rule's subject normally appears in code, fail/ must carry it
    inline, fenced, and in prose in one document.
  • limit and vocab in the common-fields table, and that a style file renamed
    to .yaml is silently not loaded — no error, no warning, zero findings.
  • Fixtures run under a generated isolating config, so a green test is not
    evidence the rule's matcher glob reaches any real file. The only check for
    that is a real check against a real file.

Verification

pnpm typecheck, pnpm lint, pnpm test all pass (829 tests, 53 files).
pnpm build then pnpm cli agent create-vale-rule renders cleanly, with
%(VALE_VERSION)s interpolating. No cross-reference test needed updating —
recipe-cross-references.test.ts asserts the rendered format lists, which these
sections do not touch.

Two further corrections, found while writing the schema

  • capitalization takes prefix — singular. It rejects prefixes and
    suffixes, both of which the docs list. An earlier draft of this table read
    the plural's rejection as the field not existing and dropped it entirely,
    which would have been a regression against the recipe's original text.
  • Field keys decode case-insensitively; the three header keys do not.
    Tokens: and ignoreCase: are read exactly as their lowercase spellings,
    while EXTENDS: fails with "Missing the required 'extends' key", and
    level: WARNING and extends: Existence are both rejected on their values.

Delivery shape

Stacked, merging forward. This unit is text only: landing it alone leaves
check, verify, and the test suite exactly as they are. The changeset lives
here, at the bottom, and unit 2 extends the same file.

One consequence of merging forward is worth flagging, because it caught me: the
first draft of these sections told the author that verify rejects a bad
extends, an unrecognized scope, and a foreign field. None of that is true
until unit 2 lands, so this unit alone would have shipped a recipe promising a
check that does not exist — which is the exact failure the recipe is about.
Those promises were replaced by the instruction each stood in for, and come back
in #175.

Fixes #170
Refs #171

thecodedrift and others added 2 commits August 24, 2026 21:30
A Vale rule that is malformed fails loudly. A Vale rule that is wrong
passes verify, passes test, and reports nothing forever. Authoring the
first house-style rules against this repository produced three of those
in a row, each green on every local gate.

Measured against the pinned 3.18.0, the engine does not close them:
`extends: nonsense` and `scope: fenced` both verify clean and match
nothing, while a field belonging to another check type throws E201 and
suppresses every other Vale rule, because Vale reads one assembled
config per run.

The change adds a schema layer to the Vale verify path and closes the
recipe gaps that produce a silent rule. Vale publishes no JSON Schema —
the machine-readable field knowledge sits behind its paid hosted MCP —
so the schema is hand-authored, and a corpus of rule files run through
both the binary and the schema is what keeps the transcription honest.

Delivery shape: stacked, merging forward. The recipe text lands alone,
then the schema and its verify layer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cEN93Acyp4zBwP3oDnyy1
Every addition is a behaviour measured against the pinned Vale 3.18.0
binary, not a caution in principle: the scope vocabulary and what each
value reaches, `raw` subsuming `code` and `text`, `~`/`&` and the silent
no-op a negation over an unknown operand becomes, `scope` being per-rule,
a `raw` rule being unsuppressable by Vale's directive, `nonword: true`
for a punctuation-only token, scoping a rule out with a second matcher,
collocation narrowing, three-way fixtures for a subject that lives in
code, `limit`/`vocab`, `.yml` over `.yaml`, and that a green `test`
proves nothing about the matcher's reach.

Check types are now enumerated from the binary's own rejection message.
There are twelve, not eleven — the docs fold `readability` into `metric`.
The per-check field tables are measured the same way, correcting two
published claims.

Refs #170

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cEN93Acyp4zBwP3oDnyy1
Copilot AI lite review requested due to automatic review settings August 25, 2026 04:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

thecodedrift and others added 3 commits August 24, 2026 21:46
`scope: figure.caption` looked inert because the obvious control — a
`<figcaption>` nested in `<figure>` — is invisible to every scope, `text`
and `raw` included. A bare `<figcaption>` is linted and the scope fires
on it. The operand is measured after all; the first reading would have
dropped it from the vocabulary.

This is the case a known-valid control alongside each measurement exists
to catch, so it is recorded in the design as well as the recipe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cEN93Acyp4zBwP3oDnyy1
…on's prefix

Two more measurements against the pinned binary. `capitalization` takes
`prefix` — singular — and rejects both `prefixes` and `suffixes`; the
first draft of this table dropped the field entirely on the strength of
the plural's rejection.

Field keys decode case-insensitively (`Tokens:`, `ignoreCase:` both work)
while `extends`, `message` and `level` are read literally and their
values are case-sensitive, so `level: WARNING` and `extends: Existence`
are both rejected. Recorded because the schema in the next unit has to
match that split exactly or it rejects rules the binary accepts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cEN93Acyp4zBwP3oDnyy1
The revised sections told the author that `verify` rejects a bad
`extends`, an unrecognized `scope`, and a foreign field. None of that is
true until the schema layer lands, and this stack merges forward — so
this unit alone would ship a recipe promising a check that does not
exist, which is the exact failure the recipe is about.

Each promise is replaced by the instruction it was standing in for, and
comes back in the unit that makes it true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cEN93Acyp4zBwP3oDnyy1
@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 4m 2s —— View job


I'll analyze this and get back to you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

create-vale-rule: document the pitfalls that pass every local gate

2 participants