Skip to content

feat(vale): schema-check per-rule .vale.ini in verify (slice 1) - #365

Merged
theCodeDrift merged 6 commits into
mainfrom
feat/vale-config-schema
Sep 21, 2026
Merged

theCodeDrift merged 6 commits into
mainfrom
feat/vale-config-schema

Conversation

@theCodeDrift

@theCodeDrift theCodeDrift commented Sep 21, 2026 •

Copy link
Copy Markdown
Member

Stack (root → tip):

What

Slice 1 of #359: a per-rule .taskless/rules/vale/<id>/.vale.ini is parsed into an ordered AST and validated against a schema keyed by the rule id, and verify reports each rejection under a vale-config-* constraint. check is untouched; slice 2 (assembly refusal, dispatch, recipe, ledger, archive) stacks on top.

The OpenSpec change is on this branch too, under openspec/changes/vale-config-schema/: proposal.md, the two spec deltas (cli-vale-rule-engine ADDED + MODIFIED, cli-rule-validation MODIFIED, every MODIFIED block restated in full), and tasks.md with slice 1 ticked.

Decisions to review

  1. Parser: @jedmao/ini-parser@0.2.4, chosen by measurement over ini/@nodecraft/ini (section names nest on ., source order lost), iniparser (drops the tskl) rule key), config-ini-parser (truncates it to rule; GPL-3.0), js-ini (works, but discards comments and needs a merge-strategy option to see duplicate keys). Lossless, ordered AST; MIT; zero deps; pinned exactly. Bake-off table in vale: parse per-rule .vale.ini into an AST, validate it with a schema, refuse a bad config at assembly #359.
  2. Refuse, don't omit. A rejected config fails the Vale engine for that check run (reaches the exit code; ast-grep still runs), the treatment a malformed ast-grep rule already gets. Omitting the rule with a notice would produce a rule that verifies, runs, and reports nothing — the failure the engine's design exists to close. (Slice 2.)
  3. No serializer. Assembly stays byte concatenation of accepted source under a breadcrumb comment. The AST is read for validation and for sections; the parser's toString() is measured not round-trip safe and is never called.
  4. Stacked forward, two PRs, so verify starts naming the rejection one release before check starts refusing it.
  5. Advisories vs rejections. A repeated key in one matcher, [*], and .taskless/** are reported but not fatal. A NO matcher before every YES is a rejection (moved from the advisory list in the plan-review commit: with BasedOnStyles empty such a NO is dead or overridden in every config, so refusing it turns away nothing an author meant). Everything the schema cannot read as a well-formed self-scoping config is fatal.

Slice 1

packages/cli/src/schemas/vale-config.ts (new). parseValeRuleConfig(source) constructs the parser with resolve: false (the default JSON-parses values, so 2024 would come back a number) and delimiter: /=/ (the default also splits on :), and returns a plain { sections } AST with a 1-based line attached to every header and property by walking the source's non-blank lines side by side with the AST. Two parser behaviours are undone: a blank line is reported as a new section with an empty name and the lines after it land there, so those sections are folded back into their predecessor (Vale reads them as the same matcher); root properties arrive as a section with no name and are kept apart, because the first rejection is about exactly them.

validateValeRuleConfig(ruleId, source) returns { rejections: RuleViolation[], advisories: string[], sections: string[] }. A zod schema over the AST pushes one custom issue per rejection with params.constraintId, which is how a rejection is attributed without matching on its own wording:

Constraint Rejects
vale-config-no-root-keys any property above the first [matcher] (StylesPath, a rule assignment Vale would W101)
vale-config-breadcrumb-required a matcher with no tskl) rule = <id>, or one naming another rule
vale-config-own-key-only any key other than the breadcrumb, BasedOnStyles, and <id>.<id> (a <style>.<check> naming another rule is called out as a cross-rule override)
vale-config-value-yes-no <id>.<id> assigned anything but YES/NO
vale-config-based-on-styles-empty BasedOnStyles non-empty
vale-config-matcher-required no [matcher] at all
vale-config-enabled-somewhere no matcher assigns YES
vale-config-disable-after-enable a NO matcher before every YES, naming the YES that re-enables it

Advisories: a key assigned twice in one matcher (or once in each of two same-glob sections, which Vale merges), a [*] matcher, a .taskless/** matcher. Every message names the rule, the matcher or key, and the line.

Measured against the vendored 3.22.0 before writing the value-yes-no rationale: yes and maybe leave the rule off with no diagnostic, error/suggestion turn it on at that level, and YES # note is read as YES by Vale but kept whole by the parser under resolve: false, so that one line is rejected here with the value quoted. Noted in the module docblock as the one too-strict edge.

RULE_CONSTRAINTS gains the eight vale-config-* entries (all enforcedBy: "verify"), each with a scenario in constraints.test.ts that asserts the message text and the attribution. reference.json regenerated.

verifyOneRule for vale runs the schema in place of its two substring checks; rejections reach errors and violations via violate, advisories join notice. The two prior messages ("declares no matcher", "never enables … present but off") survive verbatim under vale-config-matcher-required and vale-config-enabled-somewhere.

assets/demo-vale/.vale.ini gains its breadcrumb, which the schema requires and taskless demo vale must verify under. buildIsolatingConfig's docblock no longer says a repeat inside one matcher is discarded.

ESM interop. The parser is CJS with __esModule and exports.default = Parser. The vite bundle honours the marker and hands a default import the class; vitest externalises the dependency and Node's loader hands it module.exports, with the class under .default. The module reads iniParser.default ?? iniParser, and the built dist/index.js was checked to resolve it to the class.

Verified

  • pnpm build, pnpm typecheck, pnpm lint (including pnpm cli check over the prose), pnpm --filter @taskless/cli test: 96 files, 1575 tests, all green.
  • test/vale-config-schema.test.ts: 26 cases over test/fixtures/vale-config/ (one fixture per rejection and advisory, the dogfood shape with [*.md], [2024/**], [docs/[a-z]*.md] and a trailing .taskless/** block, a blank line inside a matcher, line numbers on every node).
  • test/vale-vendor-contract.test.ts: for [*.md], [docs/**/*.md], [docs/[a-z]*.md], [2024/**], the parser's section name equals the glob and Vale fires exactly on the files it names. One measured surprise recorded there: * crosses /, so [*.md] matches docs/deep.md while the sibling block's literal [CLAUDE.md] does not recurse.
  • test/verify-test-commands.test.ts: verify --json on a foreign key carries constraintId: "vale-config-own-key-only" with the same message in errors; a repeated key passes with the advisory on notice.
  • test/mixed-engine-check.test.ts W101 case: verify now rejects the misplaced assignment; check still surfaces W101 at run time until slice 2.

Refs #359

@theCodeDrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Claude finished @theCodeDrift's task in 4m 57s —— View job


Claude finished reviewing

Todo list

  • Read .prior-review.json (prior review data)
  • Fetch full diff (gh pr diff 365)
  • Review proposal.md, both spec deltas, tasks.md for correctness/consistency
  • Post inline comments for concrete findings
  • Post final top-level summary

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json contained no reviewThreads, no reviews, and only the triggering @claude /review comment — this is the first review of this PR, so the whole diff was assessed fresh.

Scope

This PR is spec-only (283 lines, all under openspec/changes/vale-config-schema/): a proposal, two spec deltas, and a task list — no implementation code. I read all four changed files in full, plus the standing specs they modify (openspec/specs/cli-vale-rule-engine/spec.md, openspec/specs/cli-rule-validation/spec.md) to check the MODIFIED blocks against the "a delta REPLACES a requirement" risk called out in this repo's CLAUDE.md.

Scenario-preservation check (per CLAUDE.md's archive-loss warning): I compared both restated requirements against the standing spec by title and scenario. Titles match exactly (Per-rule scoping is expressed in the rule's own Vale config, Verify checks a rule's required components, A rejection names the constraint it violated), and every scenario present in the standing spec is carried forward in the delta, with new ones added on top. No scenario loss found — the archive-dry-run counts in the PR description (23→30, 26→30) check out against what's in the diff.

Findings (posted inline)

  1. specs/cli-vale-rule-engine/spec.md:7-15 — The new rejection rule for "any property above its first matcher" appears to contradict the standing, unmodified requirement "Vale diagnostics on a successful run are surfaced as notices," whose scenario "An ignored rule assignment reaches the user" describes exactly that trigger (a rule assignment outside any section) resulting in a notice + zero exit, not a refusal. That requirement isn't in this PR's Modified Capabilities, so the archived spec will contain two requirements disagreeing about the same trigger.
  2. specs/cli-vale-rule-engine/spec.md:105-109 — New scenario "A rule cannot assign another rule's key" ends with "the assembled run config SHALL NOT be written with that line in it," which reads like the old line-stripping behavior this PR explicitly removes, rather than the whole-run refusal the ADDED requirement above it describes.
  3. specs/cli-vale-rule-engine/spec.md:17-20 — The carried-forward requirement text says a disable "SHALL" be declared after the enable it narrows, but this PR's new schema enforces that only as a non-fatal advisory, not a rejection — a gap between normative wording and actual enforcement that becomes real now that enforcement exists.
  4. proposal.md:16 — Question on the patch bump: slice 2 (which this changeset covers per tasks.md 2.5) makes check start refusing configs that previously ran to zero exit. Worth an explicit sentence on why that's still patch under 0.y.z rather than the "consumer must react" case the repo's own bump guidance calls out.

Everything else (constraint-id lists, rejection/advisory counts matching the "7 rejections, 4 advisories" claim, changeset placement on the bottom PR per the stacking guidance, PR issue reference formatting) checked out consistent.

Comment thread openspec/changes/vale-config-schema/specs/cli-vale-rule-engine/spec.md Outdated
Comment thread openspec/changes/vale-config-schema/specs/cli-vale-rule-engine/spec.md Outdated
Comment thread openspec/changes/vale-config-schema/specs/cli-vale-rule-engine/spec.md Outdated
Comment thread openspec/changes/vale-config-schema/proposal.md Outdated
theCodeDrift added a commit that referenced this pull request Sep 21, 2026
…01 notice requirement, state the patch case

Review on #365:
- the notices requirement's motivating scenario (a top-level assignment,
  W101) is now a schema rejection before Vale runs; the requirement is
  MODIFIED to cover diagnostics the schema cannot foresee and keeps the
  scenario, re-pointed at the rejection
- a NO matcher before every YES moves from advisory to rejection: with
  BasedOnStyles empty it is dead or overridden in every config
- the foreign-key scenario no longer reads like per-line stripping
- the proposal says why slice 2's refusal is still patch under 0.y.z

Refs #359
@theCodeDrift

Copy link
Copy Markdown
Member Author

Re: @claude — "Claude finished reviewing …"
#365 (comment)

All four findings applied in 40e5e0f: the W101 notices requirement is now MODIFIED and re-pointed at diagnostics the schema cannot foresee; NO before every YES is a rejection; the foreign-key scenario no longer reads like line stripping; the proposal states why slice 2's refusal is still patch. Archive dry-run re-run: engine 23 → 32 scenarios, validation 26 → 30, none lost.

— AI Coding Agent

@theCodeDrift theCodeDrift added the Open OpenSpec Contains unresolved OpenSpec changes. All openspec changes must eventually reach an archive state. label Sep 21, 2026
@theCodeDrift
theCodeDrift marked this pull request as ready for review September 21, 2026 18:06
Adds the vale-config-schema OpenSpec change: parse each rule's .vale.ini
into a lossless AST with @jedmao/ini-parser, validate it with a zod schema
keyed by the rule id, attribute rejections in verify under vale-config-*
constraints, and refuse the Vale run at assembly on a rejected config
while ast-grep still runs. Assembly stays verbatim concatenation.

Stacked forward in two slices; this commit carries only the proposal.

Refs #359
…01 notice requirement, state the patch case

Review on #365:
- the notices requirement's motivating scenario (a top-level assignment,
  W101) is now a schema rejection before Vale runs; the requirement is
  MODIFIED to cover diagnostics the schema cannot foresee and keeps the
  scenario, re-pointed at the rejection
- a NO matcher before every YES moves from advisory to rejection: with
  BasedOnStyles empty it is dead or overridden in every config
- the foreign-key scenario no longer reads like per-line stripping
- the proposal says why slice 2's refusal is still patch under 0.y.z

Refs #359
@theCodeDrift
theCodeDrift force-pushed the feat/vale-config-schema branch from 40e5e0f to 5e56981 Compare September 21, 2026 18:19
Add @jedmao/ini-parser (exact 0.2.4) and schemas/vale-config.ts, which
parses a rule's config into an ordered AST (resolve: false, delimiter /=/,
blank-line sections folded back into their matcher, source lines attached
by a side-by-side walk) and validates it keyed by the rule id: no root
keys, breadcrumb on every matcher, own key only, YES/NO values, empty
BasedOnStyles, a matcher, a YES somewhere, and no NO before every YES.
A repeated key, a [*] matcher and a .taskless/** matcher are advisories.

The vendor contract pins that the parser reads each corpus glob shape as
the matcher Vale enables, including a character class and a numeric
segment.
…raints

verifyOneRule for vale runs the config schema in place of its two
substring checks. Each rejection reaches errors and violations under its
vale-config-* constraint, so verify --json pairs it with a constraintId
the way it already does for sg; advisories ride on notice. The eight
constraints join RULE_CONSTRAINTS with a scenario each in the constraints
suite, and reference.json is regenerated.

The shipped demo Vale config gains its tskl) rule breadcrumb, which the
schema now requires and taskless demo vale must verify under. The W101
check test keeps its run-time assertion (check is untouched until the
next slice) and now expects verify to reject the misplaced assignment
first.
… tick slice 1

buildIsolatingConfig said a repeat inside one matcher is discarded; since
Vale 3.21.0 the last assignment wins there too. The changeset records
what verify now refuses and advises; check is unchanged in this slice.
@theCodeDrift theCodeDrift changed the title spec(vale): schema validation of per-rule .vale.ini feat(vale): schema-check per-rule .vale.ini in verify (slice 1) Sep 21, 2026
@theCodeDrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Claude finished @theCodeDrift's task in 5m 31s —— View job


Claude finished reviewing

Todo list

  • Read .prior-review.json (prior review data)
  • Fetch full diff (gh pr diff 365)
  • Review implementation files (schemas/vale-config.ts, constraints.ts, verify.ts, inspect.ts)
  • Review OpenSpec deltas for consistency with implementation
  • Review tests and fixtures
  • Post final top-level summary

Review mode: incremental — read 4 prior review thread(s) before reviewing. All four are isResolved: true and each has a reply ending *— AI Coding Agent* (commit 40e5e0f), so I'm treating all four as ADDRESSED and not repeating them:

  1. The rejection list vs. the standing "Vale diagnostics on a successful run" requirement — now MODIFIED and re-pointed.
  2. The "assembled run config SHALL NOT be written with that line in it" wording — reworded to "the Vale run SHALL be refused rather than assembled without that rule's config."
  3. NO-before-YES was an advisory, inconsistent with the carried-forward SHALL — promoted to a rejection (vale-config-disable-after-enable).
  4. The patch bump question for slice 2's refusal — proposal now states the rationale explicitly.

I checked that all four are actually reflected in the current diff (they are: the requirement is MODIFIED with the reworded scenario, vale-config-disable-after-enable exists as a rejection with its own constraint entry, fixture, and test, and proposal.md's last paragraph now argues the patch case).

Scope

This revision is no longer spec-only — it adds the slice 1 implementation: packages/cli/src/schemas/vale-config.ts (new, 516 lines), the vale-config-* RULE_CONSTRAINTS entries, verify's wiring in inspect.ts, reference.json regeneration, the demo asset breadcrumb, and a large fixture/test set. I read the full new schema file, its callers, the constraint definitions, and the test suite (including every .ini fixture) rather than just the diff hunks.

Findings (1 posted inline)

1. [New] packages/cli/src/schemas/vale-config.ts:304-399 — enabledAnywhere is a one-way latch set the moment any matcher assigns YES, but it's never reset if a later assignment in that same matcher (or an identical-glob duplicate matcher) overrides it back to NO. verdicts[index] is computed correctly (last-wins, matching Vale 3.21.0's real semantics, as this PR's own docblock states), but enabledAnywhere and the disable-after-enable check's firstYes don't consult it consistently.

Concretely, test/fixtures/vale-config/repeat-key.ini (= YES then = NO in one matcher) and repeat-across-sections.ini (two identical [*.md] matchers, YES then NO, which adviseValeRuleConfig itself treats as a Vale-merged repeat) are both asserted to produce zero rejections — only an informational advisory. But per the engine's own real behavior, both configs leave the rule permanently disabled everywhere: exactly the "verifies, runs, and reports nothing" silent-disable class this schema exists to close, reached via a repeated/duplicated assignment instead of a missing YES. Full detail, including the line-by-line trace through both checks, is in the inline comment.

Suggested fix: derive enabledAnywhere (and the disable-after-enable ordering) from the already-correct verdicts array instead of a separately-latched flag, and fold duplicate-name matchers together before computing verdicts, consistent with what the advisory logic already knows about Vale's section-merge behavior.

Everything else checked out

  • Wiring: verifyOneRule's Vale branch correctly routes rejections to errors/violations via violate() and advisories to notice; RuleConstraintId/RULE_CONSTRAINTS satisfy the type-level link so a typo'd constraintId would fail to compile.
  • locateLines' side-by-side walk and the blank-line/root-section folding in parseValeRuleConfig — traced through several edge cases (leading comments, blank lines inside a matcher, numeric-looking globs, character-class globs); all consistent with the AST the tests assert.
  • Message/constraint-id pairings in constraints.test.ts's VIOLATIONS map match the schema's actual output, including line numbers, for every vale-config-* id.
  • reference.json regeneration, the exact @jedmao/ini-parser@0.2.4 pin in both package.json and the lockfile, and the demo asset's added breadcrumb are all consistent with the proposal's description.
  • mixed-engine-check.test.ts's W101 scenario now correctly asserts verify rejects (previously asserted exit 0) and carries vale-config-no-root-keys.
  • No CLAUDE.md violations found (import grouping/direct-import conventions are followed in the new files).

Comment thread packages/cli/src/schemas/vale-config.ts Outdated
@theCodeDrift

Copy link
Copy Markdown
Member Author

Re: @claude[bot] — "Claude finished @theCodeDrift's task in 5m 31s … Findings (1 posted inline)"
#365 (comment)

Read. The one inline finding (enabledAnywhere latch vs. last-wins verdicts on a repeated <id>.<id>) is left open: the fix it proposes would reject the exact shape the spec scenario "A repeated key is reported, not rejected" says verify SHALL accept, so it is a spec decision rather than a code fix and has been raised with the author.

— AI Coding Agent

…nal verdict

Vale merges same-glob sections and keeps the last assignment, so a YES
that a later NO overrides never reaches it. The schema now folds sections
by name, keeps each matcher's final verdict, and reads both the
enabled-somewhere and disable-after-enable checks from those verdicts: a
config whose only YES is overridden is rejected as present but off, while
the repeat itself stays an advisory. Spec scenarios updated to match, with
sibling fixtures pinning the accepted shape.
@theCodeDrift
theCodeDrift merged commit 58b78d3 into main Sep 21, 2026
5 checks passed
@theCodeDrift
theCodeDrift deleted the feat/vale-config-schema branch September 21, 2026 21:15
theCodeDrift added a commit that referenced this pull request Sep 21, 2026
…01 notice requirement, state the patch case

Review on #365:
- the notices requirement's motivating scenario (a top-level assignment,
  W101) is now a schema rejection before Vale runs; the requirement is
  MODIFIED to cover diagnostics the schema cannot foresee and keeps the
  scenario, re-pointed at the rejection
- a NO matcher before every YES moves from advisory to rejection: with
  BasedOnStyles empty it is dead or overridden in every config
- the foreign-key scenario no longer reads like per-line stripping
- the proposal says why slice 2's refusal is still patch under 0.y.z

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

Labels

Open OpenSpec Contains unresolved OpenSpec changes. All openspec changes must eventually reach an archive state.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant