Files
Nick Vigilante ccffab23a0 feat: fail CI when the style guide claims enforcement it doesn't have (#29299)
## Summary

PR 1 corrected 9 annotations that claimed enforcement the config doesn't
provide. This adds the check that keeps them correct, because the same
drift will recur: annotations are prose, `.vale.ini` is config, and
nothing tied them together.

`scripts/styleclaims` reconciles three sources and fails `make lint`. It
also runs in `lint-light`, so the fast pre-commit hook covers docs-only
changes: the checker's inputs all live under `docs/.style/`, and leaving
it out would let this drift land through the hook that docs changes
actually use. It is the only `lint-light` entry that compiles Go, which
costs one small build on first run and nothing after.

What it reports:

1. `.vale.ini` plus `docs/.style/styles/Coder/`, which is the truth.
2. The per-rule annotations in the style guide subpages.
3. The two coverage tables on the style guide landing page.

### What it reports

| Finding | Example it would have caught |
|---|---|
| A `Coder.*` rule cited as active with no rule file |
`Coder.FirstPersonSingular`, `Coder.FirstPersonPlural` |
| A third-party rule cited as active while `BasedOnStyles = Coder` | the
8 `Google.*` citations |
| A rule file that no section cites as active | a rule landing without
the section the per-rule PR pattern requires |
| Checks-table severity, scope, or membership drift | a rule re-scoped
in `.vale.ini` without updating the table |
| Coverage-count drift | the per-section and total rows |

### Also in this PR

- One annotation reworded so each planned citation is marked
individually (`Google.Gender` and `Google.GenderBias`). A trailing
`(both planned)` covering several citations isn't machine-readable, and
the checker depends on that precision.
- The annotation format documented in `scripts/styleclaims/README.md`,
with the per-rule PR pattern in `docs/.style/README.md` pointing at it.
- `make lint/prose` prints a coverage pointer after running, so the
advisory result doesn't read as a pass.

## Verification

- `go test ./scripts/styleclaims/` covers the parser and each finding
class, including the mixed `scripts/check_emdash.sh` plus `Coder.EmDash`
(planned) annotation and the "rule mentioned but not claimed" case that
`Google.Passive` represents.
- `go run ./scripts/styleclaims` is clean on the branch.
- Regression check: reverting `Coder.FirstPersonSingular` to its
pre-PR-1 wording reproduces 3 findings (the false claim plus the 2 count
rows). Restoring it returns to clean.
- `golangci-lint run scripts/styleclaims/...` reports 0 issues; `gofmt`
clean.
- `make lint/markdown`, `make lint/emdash` clean.

<details>
<summary>Design notes</summary>

### Why Go, not bash

`scripts/` is mostly bash, but this parses Markdown tables and
multi-line annotations, which bash does badly. `scripts/docshtmlcheck`
sets the precedent for a Go checker invoked from a lint target, so this
follows its layout: `main.go`, `main_test.go`, `README.md`, and a `go
run ./scripts/...` target.

### Why not generate the tables

Considered and rejected. Generation and checking need the same parse, so
once the checker exists the table's correctness is guaranteed either
way. Generation would only save a one-row edit in a PR that already
edits the guide by doctrine, at the cost of a generated block inside
prose contributors are invited to edit. The checker also catches a case
generation can't: a rule enabled in `.vale.ini` with no style guide
section at all.

### Parsing tradeoff

The checker reads prose annotations, so it depends on their format
staying regular: start with a recognized keyword, and mark each planned
citation individually. That regularity is enforced by the checker
itself, which is the point, but it does mean an author who invents a new
annotation phrasing gets a failure rather than silent acceptance. If
that proves annoying in practice, the fallback is a small YAML manifest
of rule status that both the tables and the checker read. I'd rather not
add that until the prose form actually hurts.

### Scope validation

Scope cells are checked against `.vale.ini` section globs: every glob
that sets a rule to `YES` must appear in the cell, and a rule with no
`NO` toggle must claim `docs/**`. That's how the `OneSentencePerLine`
row stays honest about running on contributor docs only.

</details>

---

Linear:
[DOCS-887](https://linear.app/codercom/issue/DOCS-887/make-clear-that-vale-passing-is-not-style-guide-conformance)

🤖 Generated with Coder Agents on behalf of @nickvigilante.
2026-09-22 19:18:59 +00:00
..

styleclaims

styleclaims fails CI when the Coder documentation style guide claims a prose rule is enforced by tooling that isn't actually enabled. It runs as make lint/style-claims (part of make lint and make lint-light).

Why it exists

Each rule section in docs/.style/style-guide/ ends with an annotation naming what enforces it. Those annotations drifted from the configuration: 8 cited third-party Google.* rules that the repo-root .vale.ini never loads, and 2 cited Coder.* rules that have no YAML under docs/.style/styles/Coder/. A reader who trusts an annotation believes a rule is checked when it isn't, which is worse than an unenforced rule the guide is honest about.

What it catches

  • A Coder.* rule cited as active with no rule file. The rule doesn't exist; the citation needs (planned).
  • A third-party rule cited as active. .vale.ini sets BasedOnStyles = Coder, and third-party rules return one per PR after their corpus is clean, so any Google.*, alex.*, or write-good.* citation is planned until that happens.
  • A rule with no style guide section. A rule file exists but no annotation cites it as active, so a rule landed without the section the per-rule PR pattern requires.
  • Checks-table drift. A severity that disagrees with the rule file's level:, a row for a rule that doesn't exist, a missing row for a rule that does, or a scope cell that doesn't read the way .vale.ini configures the rule. The scope check compares the rendered text, not the set of paths, so a cell that says "and" where the config says "except" is caught along with a cell that omits or invents a path.
  • A rule cited as enforcement while .vale.ini leaves it off. A rule file can exist and still run nowhere.
  • Coverage-table drift. A per-section or total count that disagrees with the annotations.
  • A rule heading with no footer. A ## or ### heading that states a rule but carries no annotation, so the coverage table can't count it.

The checker verifies a Coder.* citation against the rule files, so a typo is caught. A third-party citation is verified only against the loaded styles: those rule files are vendored rather than kept in this repository, so the guarantee is weaker on that side.

The annotation format it relies on

An annotation starts with one of Enforced, Documentation-only, Adapted, or Vale rule, and runs to the line ending in *.

A citation counts as a claim only when its sentence contains enforc or names a Vale rule. A sentence that mentions a rule without claiming it runs, such as the note explaining why Google.Passive stays out of the package, is ignored.

Mark each planned citation individually. (planned) after the citation, or Planned Vale rule before it, both work:

*Enforced by `Coder.BrandNames`.*
*Enforced by `Coder.LearnMore` (planned).*
*Enforced by `Google.Gender` (planned) and `Google.GenderBias` (planned).*
*Enforced by `scripts/check_emdash.sh` (existing CI script) and `Coder.EmDash` (planned).*
*Documentation-only. No Vale rule.*

A trailing clause such as (both planned) that covers several citations at once isn't understood; mark each one.

A marker that appears before any citation introduces the list that follows, as in "Planned Vale rules" followed by names, and it carries until a later clause re-asserts enforcement. So "Planned A, enforced by B." reads A planned and B active.

Coverage counts

Each annotation is classified once for the coverage table, so the table counts rule sections rather than linter rule names. One annotation footer is one rule section: a heading without a footer, such as an example, a navigation heading, or the out-of-scope Color contrast section, isn't a rule of the guide and isn't counted.

The checker also reports a ## or ### heading that states a rule but carries no footer, so a rule can't drop out of the table by never being annotated. A footer belongs to the nearest heading above it, and a heading that has sub-headings is treated as covered by them, so a parent rule whose footer sits after a scoping sub-heading isn't flagged. A heading is also exempt when it is the Learn more navigation section, or when it carries a note that opens with Out of scope or Not a rule, which is how a section that organizes a page without stating a rule declares itself. Headings deeper than ### are detail inside a section, not rules. A Markdown file that has no footers and no coverage row, such as editor-setup.md, isn't a rule section and isn't scanned.

  • Documentation-only: the annotation declares the section Documentation-only. This wins over everything else, so a section that only cross-references a rule another section owns counts under that other section and no rule is counted twice. The match is case-sensitive; a lowercase "documentation-only" describes part of a section, not the section.
  • Tool-checked: cites as active an enabled Coder rule, a third-party rule whose style .vale.ini loads, markdownlint, an MD### rule, or scripts/check_emdash.sh. A Coder rule that exists but is disabled everywhere doesn't count: it checks no file.
  • Planned: cites only planned checkers.

Usage

go run ./scripts/styleclaims

It takes no arguments and runs from the repository root. Findings print as file:line: message, or as file: message when the problem is the file as a whole, such as a rule with no style guide section or a missing coverage row. Any finding exits 1.

The set of style guide sections comes from the directory, not from a list in the source. A Markdown file is a rule section when it carries at least one enforcement footer, or when the coverage table gives it a row, so a new subpage is validated as soon as either is true.