Skip to content

fix(cli): one bad file costs one finding, not the whole Vale run - #315

Merged
thecodedrift merged 3 commits into
fix/vale-finding-line-numbersfrom
fix/one-bad-file-zeroes-the-run
Sep 8, 2026
Merged

fix(cli): one bad file costs one finding, not the whole Vale run#315
thecodedrift merged 3 commits into
fix/vale-finding-line-numbersfrom
fix/one-bad-file-zeroes-the-run

Conversation

@thecodedrift

Copy link
Copy Markdown
Member

Summary

A single markdown file with unparseable YAML front matter aborted the entire Vale invocation before any result was written, so check reported results: [] for the whole run regardless of how many other files had findings — indistinguishable from a genuinely clean pass.

runVale now retries: when Vale's own config-error payload attributes a failure to one of the run's target files — as opposed to a rule config it loaded through StylesPath, which always reports an absolute path — that file is excluded from the retry and reported as a per-file finding (ruleId: "vale-parse-error", severity: "error"), and the run continues over everything else. A failure that cannot be attributed to a single target file (a malformed rule, a timeout, a crash) still fails the run exactly as before.

No YAML parser was added. Vale's own error object already names the offending file and why it could not be parsed; the fix reuses that rather than re-deriving the same fact with a second parser (see STYLEGUIDE-CODE.md's "Verify Build Output In The Build, Not By Parsing It").

Before:

{"success":false,"results":[],"failures":["Vale exited 2: E201: yaml: mapping values are not allowed in this context in content/blog/zzz-probe.md"]}

After:

{"success":false,"results":[
  {"source":"vale","ruleId":"vale-parse-error","severity":"error","file":"content/blog/zzz-probe.md","message":"Vale could not check this file: E201: yaml: mapping values are not allowed in this context", ...},
  {"source":"vale","ruleId":"no-simply","severity":"warning","file":"content/blog/good-1.md", ...},
  {"source":"vale","ruleId":"no-simply","severity":"warning","file":"content/blog/good-2.md", ...}
]}

Test plan

  • packages/cli/test/vale-run.test.ts — unit coverage against the real Vale binary: one bad file among good ones, two bad files, the "empty results vs unreadable run" distinction the issue is about, and a regression check that a genuine rule-config error still blocks (not misattributed to a target file).
  • packages/cli/test/mixed-engine-check.test.ts — end-to-end test spawning the built CLI over a scaffolded project reproducing the issue's exact repro.
  • Mutation-checked: reverted the fix (forced the retry candidate to always be undefined), confirmed the new tests fail with the pre-fix symptom, reverted back, confirmed green.
  • pnpm typecheck, pnpm test (1350 tests), pnpm lint (includes pnpm cli check against the built CLI) all pass.
  • Reproduced the issue's exact repro against the built CLI on a scratch directory, before and after.

Fixes #300

@thecodedrift

Copy link
Copy Markdown
Member Author

Verified independently before review, since this is the largest change in the batch.

The discriminator holds for how the CLI actually invokes it. The absolute-vs-relative Path test is clever but load-bearing, so I checked what happens if a target ever arrives absolute: commands/check.ts:46 normalizes every incoming path to one relative to cwd and rejects anything escaping cwd, so runVale cannot be handed an absolute target through the CLI. The invariant the discriminator rests on is enforced one layer up, and your comment at the guard already says so.

Worth noting the failure direction too, because it is the right one: if the discriminator ever misjudged, the existence check and the .taskless/ guard make it fail toward blocking the whole run (loud, and the pre-fix behaviour) rather than toward excluding a rule config and continuing, which would silently run without a rule. That is the asymmetry that matters here, and it is on the safe side.

Mutation re-run independently: forcing the narrowing to never apply fails exactly the three tests meant to catch it, and reverting returns all 31 to green.

One thing left undone that I agree with: Option 3 (making the payload unambiguous when failures is non-empty) stays open. This fix converts the #300 class into an ok outcome with real findings so the ambiguity does not arise here, but it still exists for other blocking failures across all three engines. That is a broader design question and was right to leave.

— AI Coding Agent

@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 6m 20s —— View job


Claude finished @thecodedrift's task ——

Todo

  • Read .prior-review.json (prior top-level comments/threads)
  • Fetch the PR diff (gh pr diff 315)
  • Review packages/cli/src/rules/vale/run.ts retry/exclusion logic line by line
  • Trace every caller of runVale / targetFileParseError for broken preconditions
  • Check map.ts / formats.ts for interactions with the new code
  • Review test coverage in vale-run.test.ts and mixed-engine-check.test.ts
  • Post findings

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json contains no reviewThreads; it holds one top-level self-review comment from thecodedrift (independent verification of the absolute-vs-relative discriminator and mutation testing) and the /review trigger comment itself. That self-review doesn't raise any open findings to carry forward — it confirms the design holds and flags one already-acknowledged open item (Option 3, making the payload unambiguous when failures is non-empty), which the PR description also calls out as deliberately deferred. So there is nothing STILL OPEN to repeat; everything below is [New].

Summary

The core fix (retry-and-exclude in runVale, driven entirely off Vale's own error object with no second YAML parser) is sound, and the test coverage for the check path — one bad file, two bad files, the empty-results ambiguity, and the rule-config-error regression check — is thorough and well-targeted at the actual failure mode from #300.

One likely correctness gap and two small cleanups, posted inline:

  1. targetFileParseError's .taskless/ exclusion likely reintroduces the same bug on a different call path (packages/cli/src/rules/vale/run.ts:233-238). verifyValeRule (vale/verify.ts:253) legitimately points runVale at .taskless/rules/vale/<ruleId>/rule-testsisWholeProjectWalk is false for that explicit path, so the .taskless/** exclusion doesn't apply and Vale really does walk it. A malformed fixture there reports a relative Path starting with .taskless/, which this guard treats as "not a target file," so the retry never fires and runVale falls back to the pre-fix blocking failure. That surfaces in inspect.ts:409-418 as ok: false for the whole rule, with no per-fixture attribution — one bad fixture blocking evaluation of every other pass/fail fixture, which is the class of bug this PR exists to fix, just not on this path. Worth confirming whether the guard is needed at all given StylesPath errors are already caught by the isAbsolute check above it, or scoping it to whole-project walks only.

  2. Duplicate stderr parsing (run.ts:344-357): the non-zero-exit branch does its own JSON.parse(stderr) + asValeConfigError to get configError, then immediately calls describeValeStderr(stderr) which parses the same string again internally. Minor waste, easy to consolidate.

  3. Inconsistent message formatting (run.ts:400-406): the "Vale rejected the configuration" branch (reached when a config error arrives via a zero-exit stdout payload) builds its message by hand instead of the formatValeConfigError helper this PR extracted for exactly this type — so it skips the multi-line cleanup and code-dedup logic the other two call sites get. Marked as a defensive/non-live path in the surrounding comment, so low impact, but easy to make consistent now that the helper exists.

Nothing else stood out — the discriminator's absolute-vs-relative reasoning, the bounded-retry argument, and the excludedTargets.has guard against re-excluding the same path all check out against the code as written.

Comment thread packages/cli/src/rules/vale/run.ts Outdated
Comment thread packages/cli/src/rules/vale/run.ts
Comment thread packages/cli/src/rules/vale/run.ts
thecodedrift added a commit that referenced this pull request Sep 8, 2026
…use formatter

Addresses review feedback on #315:

- targetFileParseError's `.taskless/`-prefix carve-out reintroduced the
  exact #300 bug on a path the carve-out itself was blind to: verifyValeRule
  points runVale explicitly at `.taskless/rules/vale/<ruleId>/rule-tests`,
  which is an explicit path, not a whole-project walk, so the `.taskless/**`
  glob exclusion never applies there and Vale really does walk into it. A
  malformed fixture reported a relative Path starting with
  `.taskless/rules/vale/...`, which the carve-out misread as "not a target,"
  so the retry never fired and verifyValeRule failed the whole rule instead
  of excluding the one bad fixture. isAbsolute alone is the correct, and now
  the only, discriminator — a rule/style path reached through StylesPath is
  always absolute (pinned in vale-vendor-contract.test.ts), so any relative
  Path is by construction one of the run's own targets, .taskless/ or not.

- The non-zero-exit branch in spawnVale parsed stderr twice: once by hand to
  populate configError, once again inside describeValeStderr. Extracted
  parseValeConfigError so both call sites share one parse.

- The "Vale rejected the configuration" branch (reached via a zero-exit
  stdout payload, defensive/non-live per its own comment) built its message
  by hand instead of reusing formatValeConfigError, so it skipped that
  helper's multi-line cleanup and code-dedup logic.

Added a regression test in vale-verify.test.ts exercising verifyValeRule
with a malformed fail-fixture, mutation-checked by reinstating the removed
carve-out and confirming it fails with the pre-fix symptom
("expected a verification, got Vale failed").
@thecodedrift

Copy link
Copy Markdown
Member Author

Re: @claude — "Claude finished @thecodedrift's task in 6m 20s"

Addressed all three findings in 94ee60e:

  1. [likely bug] the .taskless/ exclusion (run.ts:238) — confirmed reachable and reproduced it: verifyValeRule points runVale explicitly at .taskless/rules/vale/<ruleId>/rule-tests, which is an explicit path (not a whole-project walk), so the .taskless/** glob exclusion never applies and Vale really does walk into it. A malformed fixture there reported a relative Path starting with .taskless/rules/vale/..., which the carve-out misread as "not a target" — reintroducing One unparseable file zeroes findings for the whole check run #300 on the one path meant to catch it. Removed the carve-out entirely; isAbsolute(path) is sufficient on its own, since StylesPath is always written absolute (now pinned as a vendor contract). Added a regression test in vale-verify.test.ts, mutation-checked by reinstating the removed carve-out and confirming it fails with the exact pre-fix symptom.

  2. [cleanup] hand-built message instead of formatValeConfigError (run.ts:406) — reused the helper. No test pinned the old wording.

  3. [cleanup] duplicate stderr parse (run.ts:357) — extracted parseValeConfigError and parse once, threading the result into describeValeStderr. Mutation-checked that the shared value is load-bearing (forcing it to always return undefined fails all three One unparseable file zeroes findings for the whole check run #300 retry tests).

pnpm typecheck, pnpm test (1352 passing), and pnpm lint all green after the changes.

A single markdown file with unparseable YAML front matter aborted the
entire Vale invocation before any result was written, so `check` reported
`results: []` for the whole run regardless of how many other files had
findings — indistinguishable from a genuinely clean pass.

runVale now retries: when Vale's own config-error payload attributes a
failure to one of the run's target files (as opposed to a rule config it
loaded through StylesPath, which always reports an absolute path), that
file is excluded and reported as a per-file finding
(ruleId: "vale-parse-error", severity: "error"), and the run continues over
everything else. A failure that cannot be attributed to a single target
file — a malformed rule, a timeout, a crash — still fails the run exactly
as before.

No YAML parser was added: Vale's own error object already names the file
and the reason it could not be parsed, so the fix reuses that rather than
re-deriving it with a second parser.
The #300 fix rests on a fact about VALE, not about this repository: a config
error names a bad target file by its relative path as passed, and a bad rule
file by an absolute path, because that one reaches Vale through StylesPath.
`targetFileParseError` uses exactly that distinction to tell "one unreadable
target, exclude it and retry" from "our own rule config is broken, stop".

Nothing pinned it. The behaviour tests in `vale-run.test.ts` do run against the
real binary and would fail if Vale changed, so this is not a coverage gap so
much as a legibility one: they would report "expected ok to be failed" and
leave someone to work backwards to the cause. This says which vendor assumption
broke.

It belongs in the vendor contract for a second reason. That file is what the
upgrade procedure re-probes on every Vale bump, which is the moment this answer
can change, and a bump is exactly when nobody is thinking about #300.

Verified both directions against the pinned binary, and confirmed the assertion
is real by inverting it and watching it fail.

The failure direction stays safe either way: an unrecognised target error stops
the run rather than excluding a rule config and continuing, so a change here
degrades #300 back to its old behaviour rather than silently checking nothing.

Refs #300
…use formatter

Addresses review feedback on #315:

- targetFileParseError's `.taskless/`-prefix carve-out reintroduced the
  exact #300 bug on a path the carve-out itself was blind to: verifyValeRule
  points runVale explicitly at `.taskless/rules/vale/<ruleId>/rule-tests`,
  which is an explicit path, not a whole-project walk, so the `.taskless/**`
  glob exclusion never applies there and Vale really does walk into it. A
  malformed fixture reported a relative Path starting with
  `.taskless/rules/vale/...`, which the carve-out misread as "not a target,"
  so the retry never fired and verifyValeRule failed the whole rule instead
  of excluding the one bad fixture. isAbsolute alone is the correct, and now
  the only, discriminator — a rule/style path reached through StylesPath is
  always absolute (pinned in vale-vendor-contract.test.ts), so any relative
  Path is by construction one of the run's own targets, .taskless/ or not.

- The non-zero-exit branch in spawnVale parsed stderr twice: once by hand to
  populate configError, once again inside describeValeStderr. Extracted
  parseValeConfigError so both call sites share one parse.

- The "Vale rejected the configuration" branch (reached via a zero-exit
  stdout payload, defensive/non-live per its own comment) built its message
  by hand instead of reusing formatValeConfigError, so it skipped that
  helper's multi-line cleanup and code-dedup logic.

Added a regression test in vale-verify.test.ts exercising verifyValeRule
with a malformed fail-fixture, mutation-checked by reinstating the removed
carve-out and confirming it fails with the pre-fix symptom
("expected a verification, got Vale failed").
@thecodedrift
thecodedrift force-pushed the fix/one-bad-file-zeroes-the-run branch from 94ee60e to 6a682d3 Compare September 8, 2026 16:51
@thecodedrift
thecodedrift changed the base branch from main to fix/vale-finding-line-numbers September 8, 2026 16:51
@thecodedrift
thecodedrift merged commit 12e5f92 into main Sep 8, 2026
8 checks passed
@thecodedrift
thecodedrift deleted the fix/one-bad-file-zeroes-the-run branch September 8, 2026 16:55
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.

One unparseable file zeroes findings for the whole check run

1 participant