Skip to content

feat(cli): verify Vale rules from per-rule fixture directories - #93

Open
thecodedrift wants to merge 4 commits into
openspec/add-vale-rule-enginefrom
openspec/add-vale-rule-engine-2-verify
Open

feat(cli): verify Vale rules from per-rule fixture directories#93
thecodedrift wants to merge 4 commits into
openspec/add-vale-rule-enginefrom
openspec/add-vale-rule-engine-2-verify

Conversation

@thecodedrift

@thecodedrift thecodedrift commented Aug 11, 2026

Copy link
Copy Markdown
Member

Stack (root → tip):

Unit 2 of add-vale-rule-engine. Stacked on #71, merging down.

Task 1.4 plus the verify half of 1.5: for each .taskless/vale/rule-tests/<rule>/, generate an isolating config, run Vale over its pass/ and fail/ fixtures, and assert every fail/ fires and every pass/ is clean.

The generated config is isolating, not merely minimal

Three details in it are load-bearing:

Detail Why
StylesPath is absolute The config is written to a temp dir, and Vale resolves StylesPath relative to the config file
BasedOnStyles = is empty Otherwise a fixture could trip Vale.Spelling and be counted as the rule under test firing
Exactly one assignment, in one matcher Precedence is positional, so assigning the key twice would lean on the semantics the scoping spec got wrong

A pass/ fixture's whole claim is "the rule under test does not fire here", and that claim is worthless if another rule's finding is what got counted. There's a test for exactly that: a pass/ fixture that trips a different rule still verifies clean.

The expected set comes from disk, not from Vale's output

A fixture that produces no finding is absent from the payload entirely. So a fail/ fixture that silently stopped matching is unobservable in the output alone — which is precisely the case the verifier exists to catch. Fixture lists are read from disk and compared against what fired.

Two states that could have shipped as false confidence

  • A rule with no fixtures reports empty, not passed. An empty directory proves nothing; calling it a pass is how an unverified rule ships looking verified.
  • An unavailable or failed Vale stops the pass rather than being recorded per rule. With no binary every rule reports no findings — indistinguishable from every rule being broken — and a wall of verification failures would send someone to debug their rules over a missing install.

Found while writing the fixtures

Vale's existence is case-sensitive by default: Simply does not match the token simply. This first read as a bug in the verifier rather than as fixtures that never matched, so it is now pinned in vale-vendor-contract.test.ts alongside the rest of Vale's observable behaviour.

Verification

pnpm --filter @taskless/cli test514 passed (14 new); lint, typecheck, prettier, and openspec validate --strict all clean.

Tasks 1.4 and 1.5 are now complete, which closes out section 1.

Refs OSS-21

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.

Pull request overview

Adds a fixture-driven verification harness for the Vale rule engine so each rule under .taskless/vale/rule-tests/<rule>/ can be validated in isolation (fail fixtures must fire; pass fixtures must be clean), with accompanying tests and a pinned “vendor contract” behavior check.

Changes:

  • Implemented Vale rule fixture discovery and per-rule verification using an ephemeral, isolating .vale.ini.
  • Added comprehensive tests for verify behavior (pass/fail, isolation, empty fixtures, missing binary).
  • Documented completion of tasks 1.4/1.5 and pinned an additional observed Vale default (case-sensitive existence tokens).

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
packages/cli/src/rules/vale/verify.ts Adds fixture discovery + per-rule and all-rules verification logic using isolating generated config.
packages/cli/test/vale-verify.test.ts Adds tests for verification behavior, including isolation and missing-binary handling.
packages/cli/test/vale-vendor-contract.test.ts Pins additional Vale default behavior (existence tokens are case-sensitive by default).
openspec/changes/add-vale-rule-engine/tasks.md Marks tasks 1.4/1.5 as complete and summarizes where coverage landed across units.
Suppressed comments (1)

packages/cli/src/rules/vale/verify.ts:105

  • This catch { return [] } treats any readdir failure as "no tests". For errors like EACCES, that can incorrectly make verifyValeRules() report {status: "ok", rules: []} even though fixtures exist but are unreadable. Suggest only swallowing ENOENT/ENOTDIR and rethrowing other errors.
  } catch {
    return [];
  }

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/cli/src/rules/vale/verify.ts Outdated
Comment thread packages/cli/src/rules/vale/verify.ts Outdated
@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 3m 47s —— View job


Review of #93 — Vale rule verification (fixture-driven)

  • Gather context (diff, run.ts, map.ts, engines.ts)
  • Understand the request (review only)
  • Review verify.ts for correctness
  • Review tests for coverage/clarity
  • Post inline comments
  • Post final summary

Summary

Solid, well-documented unit. The isolating-config approach (absolute StylesPath, empty BasedOnStyles, single rules.<id> assignment) is correct and well-tested — the "pass fixture trips a different rule" isolation test in particular is exactly the right regression guard. Reading the expected set from disk rather than from Vale's output correctly handles the "silent fixture that stopped matching" case the PR calls out, and the case-sensitivity pin in vale-vendor-contract.test.ts is a good catch to preserve.

Two inline comments posted:

  1. verify.ts:130 — The empty check only catches a rule with zero fixtures in both buckets. A rule with fail/ fixtures but no pass/ fixtures (or vice versa) is not empty and can report passed: true without ever proving the half of the claim that empty bucket represents — the same "unverified rule ships looking verified" failure mode the PR explicitly calls out for the fully-empty case, just less obvious.
  2. verify.ts:64fixtureFiles's bare catch { return [] } treats any readdir failure (not just missing directory) as "no fixtures for this bucket." Since pass/ and fail/ are read independently, an unreadable pass/ bucket alongside a working fail/ bucket silently downgrades to "0 pass fixtures" rather than surfacing the error, and the rule can still report passed: true. This mirrors the pattern Copilot already flagged on discoverValeRuleTests (line 103) and is consistent with existing bare-catch conventions elsewhere in rules/ (engines.ts, runtime/discover.ts), but the consequence here is quieter under-verification rather than just "no rules found."

Also agree with Copilot's observation on verifyValeRule's return shape ({ unavailable: ValeRunOutcome } covers timeout/failed too, not just a missing binary) — the naming is misleading at call sites even though verifyValeRules unpacks it correctly today.

Minor, non-blocking:

  • verifyValeRules verifies rules sequentially in a for loop, each spawning a Vale subprocess; this appears intentional (stop on the first unavailable/failed rather than recording partial results), so not flagging as a bug, just noting the tradeoff.
  • No test exercises a rule with only pass/ fixtures and no fail/ fixtures (or vice versa) — would be good coverage for the gap in finding 1 above.

Nothing else stood out as incorrect; config generation, path normalization (toRelativePosix), and the unavailable-stops-the-pass behavior for verifyValeRules all match the PR description and are backed by tests.

thecodedrift added a commit that referenced this pull request Aug 11, 2026
…required

Two gaps in the "One changeset, at the bottom of the stack" guidance,
both found on the #71/#93/#94/#95 merging-down stack:

- The section said where the changeset lives but never when to create
  it. Document the trigger: write it on the base branch before cutting
  the children, so every child inherits a file it can append to.
- "Grow it incrementally" is merge-forward reasoning. Under merging
  down nothing lands until everything does, so a whole-change note is
  accurate at the only moment it is read. Scope the rule to forward,
  and state the constraint that holds in both shapes: the file must be
  in the bottom PR's own diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
Comment thread packages/cli/src/rules/vale/verify.ts Outdated
Comment thread packages/cli/src/rules/vale/verify.ts Outdated
@thecodedrift
thecodedrift force-pushed the openspec/add-vale-rule-engine-2-verify branch from f418122 to d0d32b5 Compare August 12, 2026 00:54
@thecodedrift

Copy link
Copy Markdown
Member Author

Re: @claude[bot] — "Review of #93 — Vale rule verification (fixture-driven) … Solid, well-documented unit."
#93 (comment)

Both inline findings were real and are fixed in d0d32b5.

One-sided fixture bucketsempty: boolean is replaced by fixtures: ValeFixtureCoverage ("both" | "pass-only" | "fail-only" | "none"), and only "both" can reach passed: true. A union rather than a second boolean, so "unwritten" and "half-written" stay distinguishable and cannot contradict each other. Tests now cover both one-sided cases, which you correctly noted nothing exercised.

The bare catchfixtureFiles and discoverValeRuleTests now swallow only ENOENT/ENOTDIR and rethrow the rest. Your read of why this site is the consequential one is what justified fixing it rather than accepting the existing convention: the buckets are read independently, so a swallowed EACCES on pass/ left a rule looking populated rather than broken, and able to report passed: true with the pass side never checked.

Your agreement with Copilot on the { unavailable: ValeRunOutcome } return shape was also acted on — renamed to { outcome }, confirmed free by grepping for production callers first (there are none yet, only tests).

Two things you raised that I did not change, deliberately:

  • Sequential verification in verifyValeRules. Agreed it is a tradeoff rather than a bug, and the stop-on-first-unavailable behaviour is the intended one — a wall of per-rule failures caused by one missing binary is exactly the outcome that guard exists to prevent.
  • The other bare catches in rules/engines.ts and runtime/discover.ts. Left alone as out of scope. They are genuinely lower-consequence, since none has the independent-reads property that made fixtureFiles dangerous. A related one in filesystem/migrations/0004-vale-engine.ts:218 is arguably the worst of them, given its own comment explains that ENOTDIR is the case it needs to handle.

The spec was silent on an unpopulated bucket, which is what let code and spec drift here. Corrected one PR up in #95 (57e3eea) rather than on this branch — this file is git mv'd into the archive by #95, so an edit here would be dropped when that move replays during the rebase.

— AI Coding Agent

@thecodedrift
thecodedrift force-pushed the openspec/add-vale-rule-engine-2-verify branch from d0d32b5 to 1bbe412 Compare August 12, 2026 03:17

@thecodedrift thecodedrift left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

small changes, mostly things that will make our code more resilient.

* all — is a real IO problem, and reading it as "nothing here" is what makes an
* unreadable bucket indistinguishable from an unwritten one.
*/
function isMissingDirectory(error: unknown): boolean {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This will probably get swept up in a simplify pass, but isMissingDirectory feels either like it should be a utility function more generally available or removed because it's adding a big layer of indirection.

I'm leaning towards the former, because I imagine SG needs this too

});
});

it("matches existence tokens case-sensitively by default", () => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I actually really appreciate this sanity test. Do we have one for ast-grep? If not, we should get another PR started (can stack on this or just off main) which adds this sanity test to make sure the binary's behavior didn't change.

@thecodedrift
thecodedrift force-pushed the openspec/add-vale-rule-engine-2-verify branch from 7d88961 to d1487be Compare August 12, 2026 23:26
thecodedrift and others added 4 commits August 12, 2026 19:31
Unit 2, tasks 1.4 and the verify half of 1.5. For each
.taskless/vale/rule-tests/<rule>/, generate an isolating config, run Vale over
its pass/ and fail/ fixtures, and assert every fail/ fires and every pass/ is
clean.

The generated config is isolating, not merely minimal, and three details in it
are load-bearing. StylesPath is absolute, because the config is written to a
temp directory and Vale resolves StylesPath relative to the config file.
BasedOnStyles is empty, so none of Vale's bundled styles load — otherwise a
fixture could trip Vale.Spelling and be counted as the rule under test firing.
And the rule is enabled by exactly one assignment in one matcher: precedence is
positional, so a config that assigned the key twice would be leaning on the very
semantics that the scoping spec got wrong.

The expected set comes from disk, not from Vale's output. A fixture that
produces no finding is absent from the payload entirely, so a missing fail/
finding is unobservable in the output alone — which is precisely the case the
verifier exists to catch.

Two states that could each have shipped as false confidence:

- A rule with no fixtures reports `empty`, not `passed`. An empty directory
  proves nothing, and calling that a pass is how an unverified rule ships
  looking verified.
- An unavailable or failed Vale stops the pass rather than being recorded per
  rule. With no binary every rule reports no findings, indistinguishable from
  every rule being broken, and a wall of verification failures would send
  someone to debug rules over a missing install.

Writing the fixtures turned up that Vale's `existence` is case-sensitive by
default — `Simply` does not match the token `simply`. It first read as a bug in
the verifier rather than as fixtures that never matched, so it is now pinned in
the vendor contract alongside the rest of Vale's observable behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
… in Vale verify

Three review findings on the Vale verification path, none of which has a
production caller yet -- only tests import `verifyValeRule`, so the shape
changes are free to make now.

A rule with fixtures in only one bucket is not `empty`, and could report
`passed: true` without proving anything: `pass/`-only passes on a trivially
empty `missingFailures` and never shows the rule fires at all. `empty:
boolean` becomes `fixtures: ValeFixtureCoverage`, which keeps "no fixtures"
apart from "one bucket missing", and only `"both"` is verifiable.

`fixtureFiles` and `discoverValeRuleTests` read any `readdir` failure as
"nothing here". The buckets are read independently, so an unreadable `pass/`
silently became `[]` beside a populated `fail/`. Only ENOENT and ENOTDIR are
swallowed now; everything else rethrows.

`{ unavailable: ValeRunOutcome }` also carries `timeout` and `failed`, and the
name invites a caller to treat a real Vale failure as a skip. Renamed to
`{ outcome }`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
Four clarity changes, no behavior change:

- Extract `directoryEntries()` so the "missing directory is empty, every
  other errno throws" policy lives in one place instead of being spelled
  out identically in `fixtureFiles` and `discoverValeRuleTests`.
- `toRelativePosix` uses `relative()` instead of slicing by `cwd.length + 1`.
- `verifyValeRule` returns `{ outcome: ValeRunFailure }`, the non-`ok` half
  of `ValeRunOutcome`. Every member carries a `message`, so the unreachable
  `"message" in outcome ? ... : "Vale failed"` fallback goes away.
- Tests: `workspaces.splice(0)` drops an `as string` cast, and a
  `verification()` helper replaces seven copies of the narrowing guard while
  naming the Vale status that actually arrived.

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

The two halves of verification disagreed about recursion. `fixtureFiles`
reads one directory deep, but Vale is invoked over the whole
`rule-tests/<rule>` tree and lints recursively, so a nested fixture was
linted while never being collected.

That fails in the dangerous direction. A nested `pass/` fixture that
wrongly fires produces a finding the expected-set never knew about, so
`unexpectedFindings` discards it; a nested `fail/` fixture is never
required to fire. Either way the rule reports `passed: true` with half
its fixtures unchecked -- the same "ships looking verified" failure
`ValeFixtureCoverage` was added to prevent, one directory deeper.

Flat and loud rather than recursive: one legal layout instead of two, and
the error names the offending path the moment someone creates it. Nothing
nests today, so this closes the gap before it can be hit rather than
fixing a live break.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
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