Skip to content

feat: derive the Vale rule schema from the vendored binary - #177

Open
thecodedrift wants to merge 3 commits into
openspec/vale-authoring-polish-schemafrom
feat/generate-vale-schema
Open

feat: derive the Vale rule schema from the vendored binary#177
thecodedrift wants to merge 3 commits into
openspec/vale-authoring-polish-schemafrom
feat/generate-vale-schema

Conversation

@thecodedrift

@thecodedrift thecodedrift commented Aug 25, 2026

Copy link
Copy Markdown
Member

Stack (root → tip):

What this is

Unit 3 of the vale-authoring-polish stack, on top of #175. It replaces the
hand-written Vale vocabulary with one derived from the vendored binary by a
checked-in generator, keeps zod as the validation layer, and ships the
divergences the derivation found as a report rather than dropping them.

Review #175 first — this rewrites the schema it introduced.

Why

#175 called its own schema what it was: a transcription. Twelve check types,
three levels, ten field tables and twenty-eight scope operands, read off a
binary by hand because Vale publishes no JSON Schema and its machine-readable
field knowledge sits behind a paid hosted MCP.

Every one of those values was measured. The measuring was done 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 already on the record:

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

A Go panic contains no has invalid keys string, so a phrase-grep scores a
crash as a clean run. A method that can make that mistake is worth writing
down once, correctly.

The method rule

Every verdict comes from the process exit status and the structured JSON
output. Never from grepping stdout for an error phrase.
A run's outcome is a
closed set with no escape hatch:

Outcome How it is recognized
clean status 0, stdout parses as a findings map
diagnostic status ≠ 0, stderr parses as Vale's JSON, keyed on Code
panic stderr carries 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".

What is derived, and what is honestly not

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 self-enumerate: hand the binary a sentinel and it names its own
accepted set. If either line stops matching, the generator errors 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 bottom two are verified, not discovered, and the artifact says so. The
candidate list is seeded from four sources, including names the binary rejects
(prefixes, suffixes, ignorecase on capitalization) so that their
rejection stays a recorded finding rather than an omission, and every check's
fields are offered to every other check so the tables are a measured partition.

Membership is read as evidence about the key, since the probe value is
arbitrary. Two of the positive verdicts are inferences rather than readings —
"Vale objected to the value instead" and "Vale panicked, so it dereferenced
something" — and both are named in the report, because an inference nobody
can audit is a grep with better manners.

scope has no oracle at all, so its verdict is three-valued and a scope: raw
reach probe must fire on every fixture or 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 in that element. Without the guard the operand
would be dropped as one Vale ignores, and every rule using it would then fail
verify.

The divergence report

src/generated/vale-vocabulary-report.md, emitted by the run:

Subject Finding What the schema did
meta, meta.class.<kind> documented, never fire omitted — verify rejects them
frontmatter, frontmatter.<key> fire, documented nowhere included
consistency, spelling accept a sentinel key no check has stay permissive
action on all ten strict checks expected a map, got 'bool' member, inference named

frontmatter is the standing proof of the limit above: nobody proposing from
the documentation alone would have found it.

Three claims carried in from earlier measurement did not reproduce, and the
report is where that surfaced:

  • figure.caption does fire, on a bare <figcaption>. What never fires is
    the nested-in-<figure> form; the earlier note conflated the two.
  • comment.block fires, in .js and .ts alike.
  • comment.* does not need a .ts fixture specifically — .js reaches the
    same tier. Rows for both now exist, because "fires in a source file" and
    "fires in this source file" are different claims.

What stays hand-written

  • The three fatal shapes. Every key in a tokenless sequence is a legal
    field of its check; it is the shape that panics, which a field table cannot
    express.
  • The scope grammar. ~ and & are operators around the operands, and
    rejecting ~fenced is a business rule rather than a transcription.
  • The union members, spelled out so each z.literal survives into
    ValeRule. An import-time guard requires every derived check type to appear
    exactly once and to be classified strict or permissive.
  • The version pin, asserted as a conditional type rather than an if.
    Both sides are string literal types, so a mismatch is a build failure naming
    the line instead of a throw in front of a user.

How it is checked

The corpus is the acceptance test. All 86 existing rows pass unmodified
against the generated schema — that is what proves generation reproduced the
transcription. No row was edited to fit the output. Two rows were added for
ground the generation newly measured (comment.line / comment.block in the
TypeScript tier), for 88.

The generated tables matched the hand-written ones exactly: the same twelve
check types, the same three levels, the same nine common fields, all ten
per-check tables identical, the same twenty-eight scope operands and two
prefixes. Nothing in the vocabulary changed; only how it is produced.

Deleting both artifacts and re-running produces byte-identical files.

What it looks like

$ taskless verify
✗ vale/no-simply
    no-simply.yml: scope: "fenced" is not a Vale 3.18.0 scope. Vale does not
    reject an unknown scope — the rule loads, runs, and matches nothing.
    Accepted: alt, blockquote, code, comment, comment.block, comment.line,
    emphasis, figure.caption, frontmatter, frontmatter.<name>, heading,
    heading.h1, … text.class.<name> (each optionally prefixed with "~",
    chained with "&", or given as a list).

$ taskless verify   # with the scope fixed, the foreign field surfaces
✗ vale/no-simply
    no-simply.yml: 'tokens' is not a field of the occurrence check. Vale
    reports this as E201 and reads one config for the whole run, so it
    suppresses every other Vale rule's findings. occurrence accepts: action,
    description, extends, ignorecase, level, limit, link, max, message, min,
    name, scope, token.

The second only appears once the first is fixed, which is the .pipe()
reproducing the binary's own order: with no valid header there is no field
table to check anything against.

Refs #171

thecodedrift and others added 3 commits August 24, 2026 23:47
`pnpm generate:vale-schema` runs the pinned Vale against rules it writes
itself and emits `src/generated/vale-vocabulary.ts`, alongside a divergence
report. It replaces a transcription whose measurements were real and whose
measuring script was discarded — leaving the answer, not the question.

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 how a tokenless `sequence` rule once looked like a check
that validates nothing. A run's outcome is a closed set — clean, diagnostic,
panic, unrecognized — and the last is fatal at every call site.

Two of the four vocabularies self-enumerate: an unknown `extends` or `level`
makes the binary name its own accepted set. If either line stops matching,
generation fails rather than emitting a short enum, because a truncated enum
is stricter than the binary and strictness is the direction that blocks
working rules.

The other two are honest about their limit. `E201` names the key you got
wrong and never the ones you could have used, and an unknown scope raises
nothing at all, so field tables and scope operands are verified rather than
discovered from a candidate list whose provenance is 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 unreachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cEN93Acyp4zBwP3oDnyy1
The enums and field tables move out of `vale-rule.ts` and into the generated
artifact; what stays is the part worth reading — the zod construction, the
two-stage `.pipe()` that reproduces the binary's own order, the case-folding
transform, the scope grammar, and the error messages that explain blast radius
to an author. Generating those would mean maintaining a code emitter to
reproduce prose.

Three things stay hand-written on purpose, and each says why in place:

- The fatal shapes. Every key in a tokenless `sequence` is a legal field of
  its check; it is the shape that panics, which a field table cannot express.
- The scope grammar. `~` and `&` are operators around the operands, and
  rejecting `~fenced` is a business rule rather than a transcription.
- The union members, spelled out so each `z.literal` survives into `ValeRule`.
  An import-time guard requires every derived check type to appear exactly
  once and to be classified strict or permissive.

The artifact's version is asserted against `VALE_VERSION` as a conditional
type rather than an `if`. Both are string literal types, so the compiler
settles it and a mismatch is a build failure naming the line, instead of a
throw in front of a user on whichever command loads the schema first.

All 86 existing corpus rows pass unmodified — that is what proves generation
reproduced the transcription. Two rows are added for ground the generation
newly measured: `comment.line` and `comment.block` in the TypeScript tier,
which is the only operand family whose membership depends on which parser the
extension routes to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cEN93Acyp4zBwP3oDnyy1
The `cli-rule-validation` requirement "The Vale rule schema is pinned to the
vendored binary" was written for a hand-authored transcription. It becomes a
derivation, and gains the three constraints that make one trustworthy: take
every verdict from the exit status and the structured output rather than from
an error phrase; fail rather than emit a partial enumeration, because a short
enum is stricter than the binary; and report a divergence from Vale's
documentation rather than resolving it silently.

Delivery shape is a single PR — the generator, its artifact, and the schema's
switch to importing it are only correct together, and there is no intermediate
state that reaches production safely.

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 06:49

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

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 12m 29s —— View job


Review complete

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json contained no inline threads and no prior review body (only the Copilot "quota limit" notice and the @claude /review trigger comment), so this is the first substantive review of this PR.

Todo list

  • Read .prior-review.json (prior review data — empty, no prior findings to classify)
  • Gather PR diff and changed files (git diff origin/openspec/vale-authoring-polish-schema...HEAD)
  • Review diff for correctness, security, performance, test adequacy, clarity
  • Post inline comments for concrete issues
  • Post final top-level summary

Overall assessment

This is a well-executed, thoroughly documented change: the generator (scripts/generate-vale-schema.ts) derives the Vale vocabulary from the vendored binary using a closed outcome set (clean/diagnostic/panic/unrecognized) rather than phrase-grepping, exactly per the method rule the PR describes. I cross-checked every derived constant (VALE_CHECK_TYPES, VALE_LEVELS, VALE_PERMISSIVE_CHECKS, VALE_COMMON_FIELDS, VALE_CHECK_FIELDS, VALE_SCOPE_OPERANDS/VALE_SCOPE_PREFIXES) against the old hand-written schema it replaces — every value matches exactly, nothing was silently dropped or added, and all the documented divergences (meta/meta.class.* never firing, frontmatter/frontmatter.* undocumented-but-real, action's type-objection inference, the three fatal shapes staying hand-written) are consistent between vale-vocabulary.ts, vale-vocabulary-report.md, design.md, and the spec delta. The two new corpus rows (comment.line-ts/comment.block-ts) are wired correctly through the .map() at the bottom of vale-corpus.ts. No breaking changes to any of the module's exports or their callers (inspect.ts, the test suite).

I did find one real correctness/altitude gap and a few lower-severity cleanup items, posted inline:

  1. [New] packages/cli/src/schemas/vale-rule.ts:495-542 — the import-time guard block validates UNION_MEMBERS (a third, independently hand-written list of the 12 check names) against the derived vocabulary, but UNION_MEMBERS is never used to construct the actual discriminatedUnion array a few lines below (confirmed by grep — it's referenced exactly twice: its own declaration and inside the guard). A maintainer who updates UNION_MEMBERS in response to the guard's own error message, but forgets the corresponding check(...) call in the union array, gets a passing import and a schema that's silently too strict for the new check type — the exact failure class this file exists to prevent. In practice vale-schema-contract.test.ts's "too strict" test would catch it before merge, but that's the test suite doing the job the guard's doc comment claims for itself.
  2. [New] packages/cli/scripts/generate-vale-schema.ts:208-236ValeDiagnostic/readDiagnostic duplicate ValeConfigError/asValeConfigError already defined in src/rules/vale/map.ts, which this script could import the same way it already imports findValeBinary and VALE_VERSION from src/.
  3. [New] packages/cli/scripts/generate-vale-schema.ts:142-163probe()'s isolating .vale.ini recipe re-derives the same three load-bearing details buildIsolatingConfig (src/rules/vale/verify.ts:40-50) already encodes and documents, though the directory layouts differ enough that reuse isn't a pure drop-in.
  4. [New] packages/cli/scripts/generate-vale-schema.ts:613-635 — efficiency note: field-candidate probing spawns ~600 subprocesses one field at a time, when invalidKeys already parses Vale's rejected-key list, so most candidates could resolve in one batched probe per check. Dev-time script only, not a correctness issue.

Minor, not inlined: the changeset, proposal.md, and tasks.md all repeat the same grammatically broken clause — "two [rows] were added for ground the generation newly measured" — probably meant to say "for ground the generation newly measured" differently; worth a pass since it's user-facing release-note text.

Nothing else rose to the level of a finding — the type-precision question around commonFields: Record<string, z.ZodType> possibly widening ValeRule's inferred field types was raised independently by two lines of investigation but I couldn't run tsc in this sandbox to confirm it, and no current consumer depends on ValeRule's field-level types, so I left it out of the inline comments.
(branch)

Comment on lines +495 to +542
const UNION_MEMBERS = [
"existence",
"substitution",
"capitalization",
"occurrence",
"repetition",
"conditional",
"metric",
"readability",
"sequence",
"script",
"consistency",
"spelling",
] as const;

{
const spelled = new Set<string>(UNION_MEMBERS);
const derived = new Set<string>(VALE_CHECK_TYPES);
const missing = [...derived].filter((name) => !spelled.has(name));
const extra = [...spelled].filter((name) => !derived.has(name));
const permissive = new Set<string>(VALE_PERMISSIVE_CHECKS);
const unclassified = [...derived].filter(
(name) => !permissive.has(name) && !(name in CHECK_FIELDS)
);

if (missing.length > 0) {
throw new Error(
`Vale ${PINNED_VALE_VERSION} has a ${missing.join(", ")} check and the schema's ` +
`union does not, so every rule extending it would be rejected. Add ` +
`the member in src/schemas/vale-rule.ts.`
);
}
if (extra.length > 0) {
throw new Error(
`the schema's union has a ${extra.join(", ")} member and Vale ` +
`${PINNED_VALE_VERSION} has no such check, so those rules would verify clean ` +
`and then fail the whole run. Remove the member in ` +
`src/schemas/vale-rule.ts.`
);
}
if (unclassified.length > 0) {
throw new Error(
`${unclassified.join(", ")} is in neither the derived field tables nor ` +
`the permissive set, so there is nothing to build a union member from. ` +
`Re-run the generator: pnpm generate:vale-schema.`
);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[New] The import-time guard checks the wrong list — it never validates the actual discriminatedUnion array.

UNION_MEMBERS (line 495) is referenced exactly twice in this file: its own declaration, and inside this guard block. It is never used to build valeBodySchema — the union at lines 544-560 is a separate, independently hand-written list of check(...)/permissiveCheck(...) calls. So this guard proves UNION_MEMBERS agrees with the derived vocabulary; it does not prove the union array does.

Concrete failure: Vale adds a 13th check type. pnpm generate:vale-schema updates VALE_CHECK_TYPES, and on next import missing is non-empty, throwing and naming the check — correctly prompting a fix. A maintainer adds the name to UNION_MEMBERS per the error message and stops there (an easy thing to miss, since it's a separate list from the actual union a few lines below). The guard now passes — spelled matches derived — but valeBodySchema's discriminatedUnion array still has only 12 members. A rule with the new extends value passes stage-1 header validation (it's in VALE_CHECK_TYPES) and then fails stage-2 with the generic "extends must be one of: ..." fallback, even though Vale itself runs the rule fine. That's exactly the "too-strict" failure this module says is the worse direction, and the doc comment above ("This block closes that gap at import... every derived check type must appear below exactly once") overstates what's actually checked.

In practice vale-schema-contract.test.ts's "is not too strict" test would catch this (given a corpus row for the new check), so it wouldn't reach production silently — but that's the test suite doing the job this comment credits to the import-time guard. Consider building the union array from UNION_MEMBERS/CHECK_FIELDS/VALE_PERMISSIVE_CHECKS instead of introducing a third freestanding name list, or asserting the constructed union's actual discriminant set against VALE_CHECK_TYPES after valeBodySchema is built.

Comment on lines +208 to +236
/**
* Read Vale's diagnostic object off stderr.
*
* Vale writes one JSON object per config error, pretty-printed and possibly
* several in a row. `JSON.parse` handles the single-object case; anything else
* returns `undefined` and becomes {@link ProbeOutcome} `unrecognized`, which
* every caller treats as fatal. That is the point: an unreadable diagnostic
* must never be mistaken for a clean run.
*/
function readDiagnostic(stderr: string): ValeDiagnostic | undefined {
const trimmed = stderr.trim();
if (trimmed === "") return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return undefined;
}
const first: unknown = Array.isArray(parsed) ? parsed[0] : parsed;
if (typeof first !== "object" || first === null) return undefined;
const candidate = first as Partial<ValeDiagnostic>;
if (
typeof candidate.Text !== "string" ||
typeof candidate.Code !== "string"
) {
return undefined;
}
return candidate as ValeDiagnostic;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[New] ValeDiagnostic/readDiagnostic duplicate ValeConfigError/asValeConfigError in src/rules/vale/map.ts.

map.ts:44-68 already defines the same {Code, Text, ...} shape check (asValeConfigError), which this script could import — it already does the equivalent for findValeBinary/VALE_VERSION a few lines up (await import("../src/rules/vale/binary.js") / capabilities.js), so importing map.js the same way is an established, working pattern here. As written, a future change to Vale's diagnostic envelope (e.g. Span becoming optional, or a new discriminating field) has to be applied in both places, and nothing enforces that it is.

Comment on lines +142 to +163
* Run one rule over one document in a config isolated from everything else.
*
* `BasedOnStyles =` is load-bearing. Without it Vale loads its bundled styles
* and a control document can trip one of those, which a finding count cannot
* tell apart from the rule under test firing.
*
* `--no-exit` suppresses the non-zero status Vale returns merely for *finding*
* something, so a non-zero status here means the config itself failed.
*/
function probe(
rule: string,
document: string,
extension: string
): ProbeOutcome {
const cwd = mkdtempSync(join(tmpdir(), "vale-derive-"));
try {
mkdirSync(join(cwd, "styles", "probe"), { recursive: true });
writeFileSync(join(cwd, "styles", "probe", "probe.yml"), rule);
writeFileSync(
join(cwd, ".vale.ini"),
"StylesPath = styles\nMinAlertLevel = suggestion\n\n[*]\nBasedOnStyles =\nprobe.probe = YES\n"
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[New] probe()'s isolating config re-derives the recipe buildIsolatingConfig (src/rules/vale/verify.ts:40-50) already encodes.

Both write StylesPath + MinAlertLevel = suggestion + an empty BasedOnStyles = + a single <style>.<rule> = YES line, and buildIsolatingConfig's docstring specifically calls out why each of those three details is load-bearing. Here the same shape is re-derived by hand with a hardcoded probe.probe name. The two aren't a drop-in match (buildIsolatingConfig requires an absolute StylesPath tied to the .taskless/vale/rules/<id> layout, vs. this script's simpler styles/probe/probe.yml), so reuse would take some adapting, but a future load-bearing requirement discovered against the isolating recipe (e.g. another key Vale needs) would need to be applied in both places today.

Comment on lines +613 to +635
for (const check of CHECK_TYPES) {
// The sentinel is the one probe where a panic is *not* read as evidence. It
// decides whether the check validates keys at all, and a permissive verdict
// taken from a crash would make the schema loose for a check that is strict.
if (
probeField(check, SENTINEL_FIELD, { panicIsEvidence: false }) === "member"
) {
permissiveChecks.push(check);
continue;
}
const accepted = new Set([
...HEADER_FIELDS,
...Object.keys(must(CHECK_BASES[check], `the base rule for ${check}`)),
]);
for (const field of FIELD_CANDIDATES) {
if (accepted.has(field)) continue;
if (probeField(check, field, { panicIsEvidence: true }) === "member") {
accepted.add(field);
}
}
acceptedFields[check] = [...accepted].toSorted();
console.log(` ${check}: ${String(accepted.size)} fields`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[New] Efficiency: this probes one candidate field at a time (~60 candidates × 10 strict checks ≈ 600 subprocess spawns, each with its own mkdtempSync/writeFileSync/spawnSync/rmSync), but invalidKeys (line 488) already parses Vale's has invalid keys: 'a', 'b', … as a list.

A rule carrying every untested candidate at once would return every rejected key in a single diagnostic, so a "throw everything at it" pre-pass could resolve most candidates in one probe per check, falling back to one-at-a-time only to disambiguate the type-objection/panic cases. This is a dev-time generator script, not a hot path, so it's a wall-clock nicety rather than a correctness issue — flagging since pnpm generate:vale-schema wall time scales with subprocess count and this cuts it by roughly an order of magnitude.

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.

2 participants