feat(cli): verify Vale rules from per-rule fixture directories - #93
feat(cli): verify Vale rules from per-rule fixture directories#93thecodedrift wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
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 makeverifyValeRules()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.
2660127 to
f418122
Compare
|
@claude /review |
|
Claude finished @thecodedrift's task in 3m 47s —— View job Review of #93 — Vale rule verification (fixture-driven)
SummarySolid, well-documented unit. The isolating-config approach (absolute Two inline comments posted:
Also agree with Copilot's observation on Minor, non-blocking:
Nothing else stood out as incorrect; config generation, path normalization ( |
…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
f418122 to
d0d32b5
Compare
Both inline findings were real and are fixed in One-sided fixture buckets — The bare catch — Your agreement with Copilot on the Two things you raised that I did not change, deliberately:
The spec was silent on an unpopulated bucket, which is what let code and spec drift here. Corrected one PR up in #95 ( — AI Coding Agent |
d0d32b5 to
1bbe412
Compare
thecodedrift
left a comment
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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", () => { |
There was a problem hiding this comment.
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.
7d88961 to
d1487be
Compare
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
d1487be to
3a70ffb
Compare
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 itspass/andfail/fixtures, and assert everyfail/fires and everypass/is clean.The generated config is isolating, not merely minimal
Three details in it are load-bearing:
StylesPathis absoluteStylesPathrelative to the config fileBasedOnStyles =is emptyVale.Spellingand be counted as the rule under test firingA
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: apass/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
empty, notpassed. An empty directory proves nothing; calling it a pass is how an unverified rule ships looking verified.Found while writing the fixtures
Vale's
existenceis case-sensitive by default:Simplydoes not match the tokensimply. This first read as a bug in the verifier rather than as fixtures that never matched, so it is now pinned invale-vendor-contract.test.tsalongside the rest of Vale's observable behaviour.Verification
pnpm --filter @taskless/cli test→ 514 passed (14 new); lint, typecheck, prettier, andopenspec validate --strictall clean.Tasks 1.4 and 1.5 are now complete, which closes out section 1.
Refs OSS-21