Skip to content

test(vale): make the Vale 3.21.0 upgrade land green - #339

Merged
thecodedrift merged 4 commits into
vendor/vale/upgradefrom
vendor/vale/upgrade-tests
Sep 16, 2026
Merged

thecodedrift merged 4 commits into
vendor/vale/upgradefrom
vendor/vale/upgrade-tests

Conversation

@thecodedrift

@thecodedrift thecodedrift commented Sep 16, 2026

Copy link
Copy Markdown
Member

Stack (root → tip):

#336 moves the six @taskless/vale-* pins to Vale 3.21.0. That commit alone does not land green: VALE_VERSION still says 3.20.0, the generated vocabulary was derived against 3.20.0, and four things the binary changed underneath the pins fail the existing suites. This PR is what makes the upgrade correct, measured against both binaries side by side, and it merges down into vendor/vale/upgrade so the upgrade reaches main atomically.

What the pin bump alone left broken

  • VALE_VERSION → 3.21.0; vale-vocabulary.ts re-derived. The vocabulary diff is additive: in joins conditional's fields, doc( joins the scope families. Nothing removed.
  • Findings carry a new Suggestions field (mirrors a replace action's params). toFix keeps reading Action; the contract pins that the two agree.
  • A duplicated [glob] section or repeated key in a rule's .vale.ini now keeps its last assignment (upstream 1e4f6ed). The contract asserted first-wins; it now asserts last-wins in both orders, and assemble.ts says so.
  • Two upstream perf commits made the timeout fixtures ~20× faster, so the timeout test was passing without exercising the timeout. Re-measured and regrown (6.7 MB / 19 MB) to restore the headroom.
  • .ipynb is a format now, so it left the plaintext fallback: new tier row, new fixture, three pins on what the tier means.

Schema gaps the new vocabulary opened

  • Too strict: scope: doc(<selector>) was rejected as an unknown operand. verify now accepts the family, splitting a chain on & the way Vale does (a port of splitOutside) so a selector with & inside quotes is one term. The selector itself is left to Vale, which reports a bad one as E201 at load — the path a malformed regex already takes.
  • Too lax: an unknown action name. 3.20.0 loaded it and died on first match with a path-less E100; 3.21.0 refuses it at load as E201, and one config serves the run, so a typo silences every Vale rule on every check. verify now rejects a name outside replace, remove, suggest, convert, edit.

New contract pins (binary invoked directly)

Every doc(...) shape (chained, negated, standalone container, the inert standalone leaf, E201 for a bad selector, a metric over one section); a metric honoring an ordinary scope; BlockIgnores/TokenIgnores on HTML; unknown action refused at load. The sequence negated-scope double-report fix from the release notes did not reproduce on either binary through the CLI's path; it is pinned at the current count with that said.

Changeset and ledger

.changeset/vale-3-21-0.md is grown in place (still patch). update.md gains a Migrating to 0.11.2 entry because existing rules change behavior: a metric with a scope now measures per block, a bad action fails the run at load, duplicated ini keys flip to last-wins, and [*.ipynb] rules stop linting notebook JSON. create-vale-rule.md documents the doc(...) shapes and the inert-leaf trap.

Stacked on #336 — merges down into vendor/vale/upgrade.

The bot moved the six platform pins; this is what the 3.21.0 binary
changed underneath them, measured against 3.20.0 side by side.

VALE_VERSION moves to 3.21.0 and the vocabulary is re-derived. The diff
is additive: `in` joins `conditional`'s field table, and `doc(` joins the
scope families. Nothing was removed. `in` names a View scope, and Views
live under a directory the rule layout has no home for, so the corpus
records it as a measured member every use of which fails at load.

Four things the pin alone left broken, each read rather than guessed:

- Findings carry a new `Suggestions` field. It mirrors a `replace`
  action's params and is `[]` otherwise; `toFix` keeps reading `Action`
  and the contract pins that the two agree.
- A duplicated `[glob]` section, or a repeated key in one, now keeps its
  LAST assignment (upstream 1e4f6ed, `ValueWithShadows`). The contract
  asserted first-wins in both orders; it now asserts last-wins in both,
  and assemble.ts's docstring says so.
- Two perf commits made the timeout fixtures ~20x faster: 152KB ran in
  ~45ms against a 100ms budget and the test reported `ok`. Re-measured
  (linear, ~2.8us per repetition); the fixtures grow to 6.7MB and 19MB
  to restore the headroom the old ones had.
- `.ipynb` is a format now (`internal/lint/notebook.go`), so it left the
  plaintext fallback for `markup`: a Markdown cell as Markdown, a code
  cell as its kernel's comments, raw cells and outputs unread. New tier
  row, new fixture, and three pins on what the tier means.

New contract cases for what a rule author can reach: every `doc(...)`
shape (chained, negated, standalone container, the inert standalone
leaf, the E201 for a bad selector, a metric over one section), a metric
honoring an ordinary scope, BlockIgnores/TokenIgnores on HTML, and an
unknown action name refused at load. The sequence negated-scope fix in
the release notes did not reproduce on either binary through the CLI's
path and is pinned at the current count with that said.
… verify

Two schema gaps the 3.21.0 vocabulary opened, one in each direction.

Too strict: `scope: doc(<selector>)` was rejected as an unknown operand.
The generator records the family as a `doc(` prefix; the schema now
knows the term ends in `)` with something between, and splits a chain
on `&` the way Vale does (a port of `splitOutside`), so a selector
carrying `&` inside quotes is one term rather than two fragments. What
is between the parens is not parsed: Vale compiles every selector at
load and reports a bad one as `E201 invalid selector in 'doc(...)'`,
which `test` surfaces, the path a malformed regex already takes.

Too lax: an unknown `action` name. Measured on 3.20.0 the rule loaded
and the run died only when it fired, as an E100 with no path; 3.21.0
refuses it at load as an E201 naming the file, and one config serves
the run, so a typo silences every Vale rule on every check. The five
accepted names are transcribed from upstream `checkAction` and held to
the binary by corpus rows.

Corpus: five accepted `doc(...)` shapes, the unclosed and empty forms
the schema rejects with the binary, and three recorded divergences
where the schema accepts what Vale does not honor because judging it
would mean parsing CSS against the document (a standalone leaf is
inert; a bad selector is Vale's to report; `in` needs a View the rule
layout cannot supply). Each mutation was run: the plain split fails the
quoted-ampersand row, dropping the paren check fails the empty and
unclosed rows, dropping the action check fails both rejected rows.
The changeset grows in place, for a rule author: what can now be
written (`doc(...)` scopes, a metric that honors its scope, `.ipynb`,
BlockIgnores on HTML) and what an existing rule will observe (a
duplicated matcher keeps its last assignment, an unknown action fails
at load, a scoped metric measures what it names, notebook JSON is no
longer prose). Only what the vendor contract pins end to end is listed;
`in` on `conditional` and TextFSM Views are not, since neither can be
reached through the rule layout.

`update.md` gains the 0.11.2 ledger entry with the same list and what to
do about each, every item measured against the 3.20.0 and 3.21.0
binaries side by side. The sequence negated-scope fix in the release
notes did not reproduce on either binary, and the entry says so the
way the ast-grep root-metavariable case does. `create-vale-rule.md`
adds `doc(...)` to the scope table with the one trap (a leaf on its own
is inert; chain it) and notes that `metric` takes a scope. Both topic
versions bump.
@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Sep 16, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 5m 56s —— View job


Review complete

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json contained no reviewThreads, no reviews, and no top-level comments beyond the @claude /review trigger itself, so this is the first review of this PR — the whole diff was assessed fresh, nothing is being skipped as already-addressed.

Todo

  • Read .prior-review.json (no prior findings to classify — first review)
  • Gather PR diff via gh pr diff
  • Review schema/vocabulary changes (vale-rule.ts, vale-vocabulary.ts, capabilities.ts)
  • Review assemble.ts (last-wins ini merge) and map.ts
  • Review test changes (vale-corpus.ts, vale-vendor-contract.test.ts, vale-run.test.ts, reconcile-marker.test.ts)
  • Review docs/changeset (create-vale-rule.md, update.md, changeset)
  • Post findings

Summary

This is an unusually well-substantiated PR — every behavioral claim (last-wins ini merge, doc(...) scope grammar, action-load validation, .ipynb tiering, timeout-fixture re-measurement) is pinned by a test that runs the real vendored Vale binary side by side with the schema, and the prose in capabilities.ts/assemble.ts/vale-rule.ts explains why each number or branch is what it is. I traced the changed logic in detail:

  • isScopeOperand/splitOutside/scopeMessages (packages/cli/src/schemas/vale-rule.ts) — the new doc(<selector>) family handling (paren-balance check, &-splitting that respects quotes and nested parens) is correct against every corpus row I hand-traced (doc(section), doc(h1) standalone-inert, doc(h2[) invalid-selector, the ampersand-inside-quotes case, doc() empty). The three intentional divergences (doc(h1) leaf, doc(h2[) bad selector, conditional.in) are exactly the cases the "diverges only where a row says so" test would catch if they were wrong.
  • actionMessages — correctly checks the name key case-insensitively (matching how Vale decodes a check's own fields) and defers non-string/malformed shapes to Vale's own diagnostics rather than duplicating them. Flagged one low-confidence inline note below.
  • assemble.ts — the last-wins merge behavior change is purely a docstring/test update; there was never CLI-side merge logic to change since Vale's own ini parser does the merging. No functional gap.
  • map.ts — the new Suggestions field is additive and optional; toFix correctly keeps reading Action as documented, and nothing else in the codebase references Suggestions yet.
  • capabilities.ts.ipynbmarkup is placed correctly in the alphabetized table; the extensive versioned commentary accurately describes the narrowing (JSON → cell-aware) direction.
  • Large re-measured timeout fixtures (350k/1M repetitions, 6.7MB/19MB) in vale-run.test.ts are justified with measured tables and don't carry a timeout override, but the process is killed at the 100ms budget regardless of total duration, so the risk is bounded to file-write overhead, not the full linear duration.

One inline note posted (low severity / plausible, not confirmed): in packages/cli/src/schemas/vale-rule.ts:669, ACTION_NAMES.includes(name) is a case-sensitive comparison on the action value, and only the key's case-insensitivity (Name: vs name:) is pinned by a corpus row (action/mixed-case-name-key, which uses an already-invalid value). Everything else in this module is binary-verified; this one comparison isn't. Almost certainly fine (upstream checkAction is very likely a literal Go switch), but worth either a corpus row or a comment recording that the value was deliberately left case-sensitive.

No correctness bugs, removed-behavior regressions, or CLAUDE.md violations found elsewhere in the diff.
· branch vendor/vale/upgrade-tests

Comment thread packages/cli/src/schemas/vale-rule.ts
@thecodedrift

Copy link
Copy Markdown
Member Author

Re: @claude[bot] — "Claude finished @thecodedrift's task in 5m 56s — Review complete … One inline note posted."
#339 (comment)

Read and acknowledged. The one inline note (action-value case sensitivity) is being measured against the binary with a corpus row; the thread will carry the result.

— AI Coding Agent

Review on #339: `ACTION_NAMES.includes(name)` compares the value
verbatim, and the only casing row (`action/mixed-case-name-key`) used
an already-invalid value, so it proved the key's case-insensitivity and
nothing about the value's.

Measured on 3.21.0 with a params shape that loads clean under
`replace`: `Replace` and `REPLACE` both draw `E201 unknown action
'Replace'`. The schema was already the binary's answer; the new row
`action/mixed-case-name-value` is what holds it there. Mutation run:
folding the value to lowercase before the compare fails that row as
"Vale rejected, schema accepted". The docstring says which half of the
action map is folded and which is not.
@thecodedrift
thecodedrift merged commit 5eb969b into vendor/vale/upgrade Sep 16, 2026
5 checks passed
@thecodedrift
thecodedrift deleted the vendor/vale/upgrade-tests branch September 16, 2026 17:38
thecodedrift added a commit that referenced this pull request Sep 16, 2026
Review on #339: `ACTION_NAMES.includes(name)` compares the value
verbatim, and the only casing row (`action/mixed-case-name-key`) used
an already-invalid value, so it proved the key's case-insensitivity and
nothing about the value's.

Measured on 3.21.0 with a params shape that loads clean under
`replace`: `Replace` and `REPLACE` both draw `E201 unknown action
'Replace'`. The schema was already the binary's answer; the new row
`action/mixed-case-name-value` is what holds it there. Mutation run:
folding the value to lowercase before the compare fails that row as
"Vale rejected, schema accepted". The docstring says which half of the
action map is folded and which is not.
thecodedrift added a commit that referenced this pull request Sep 16, 2026
Review on #339: `ACTION_NAMES.includes(name)` compares the value
verbatim, and the only casing row (`action/mixed-case-name-key`) used
an already-invalid value, so it proved the key's case-insensitivity and
nothing about the value's.

Measured on 3.21.0 with a params shape that loads clean under
`replace`: `Replace` and `REPLACE` both draw `E201 unknown action
'Replace'`. The schema was already the binary's answer; the new row
`action/mixed-case-name-value` is what holds it there. Mutation run:
folding the value to lowercase before the compare fails that row as
"Vale rejected, schema accepted". The docstring says which half of the
action map is folded and which is not.
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.

1 participant