Skip to content

Commit 94ee60e

Browse files
committed
fix(cli): narrow the target-file discriminator, drop double parse, reuse 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").
1 parent 3fb4b3e commit 94ee60e

2 files changed

Lines changed: 95 additions & 32 deletions

File tree

packages/cli/src/rules/vale/run.ts

Lines changed: 53 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,25 @@ export type ValeRunOutcome =
9999
| { status: "timeout"; blocking: true; message: string }
100100
| { status: "failed"; blocking: true; message: string };
101101

102+
/**
103+
* Parse Vale's stderr as its one-object config-error document, or `undefined`
104+
* when it is not that shape.
105+
*
106+
* Split out from {@link describeValeStderr} so the non-zero-exit branch in
107+
* {@link spawnVale} can parse `stderr` exactly once and use the result both to
108+
* build the failure message and to populate `ValeAttempt.configError` — the
109+
* value {@link targetFileParseError} reads to decide whether this failure can
110+
* be narrowed to one target file and retried. Without this split, the same
111+
* bytes were parsed twice: once here, once again inside `describeValeStderr`.
112+
*/
113+
function parseValeConfigError(stderr: string): ValeConfigError | undefined {
114+
try {
115+
return asValeConfigError(JSON.parse(stderr));
116+
} catch {
117+
return undefined;
118+
}
119+
}
120+
102121
/**
103122
* Vale's stderr, rendered as a sentence instead of a JSON blob.
104123
*
@@ -110,19 +129,17 @@ export type ValeRunOutcome =
110129
* decoding ast-grep's stderr rather than forwarding bytes — the message is the
111130
* only thing the user has to act on.
112131
*
113-
* Anything that is not that shape is returned untouched. A best-effort decoder
114-
* that swallows what it cannot read would be worse than none.
132+
* Takes the already-parsed error rather than re-parsing `stderr` itself — see
133+
* {@link parseValeConfigError}. Anything that did not parse to that shape is
134+
* returned untouched. A best-effort decoder that swallows what it cannot read
135+
* would be worse than none.
115136
*/
116-
function describeValeStderr(stderr: string): string {
117-
let parsed: unknown;
118-
try {
119-
parsed = JSON.parse(stderr);
120-
} catch {
121-
return stderr;
122-
}
123-
const error = asValeConfigError(parsed);
124-
if (error === undefined) return stderr;
125-
return formatValeConfigError(error, { withPath: true });
137+
function describeValeStderr(
138+
stderr: string,
139+
configError: ValeConfigError | undefined
140+
): string {
141+
if (configError === undefined) return stderr;
142+
return formatValeConfigError(configError, { withPath: true });
126143
}
127144

128145
/**
@@ -213,10 +230,27 @@ function parseErrorResult(file: string, error: ValeConfigError): CheckResult {
213230
* absolute `Path`. A target file, by contrast, is named on Vale's command
214231
* line exactly as this module passed it — always relative to `cwd`, per
215232
* `targets` below — so a problem reading a target file reports the relative
216-
* path we asked Vale to check. Measured against the real binary: a bad
217-
* `level:` in a rule file reports that rule's absolute path on disk; an
218-
* unquoted colon in a document's front matter reports the relative path this
219-
* module handed to Vale.
233+
* path we asked Vale to check. Measured against the real binary, and pinned as
234+
* a vendor contract in `vale-vendor-contract.test.ts`: a bad `level:` in a rule
235+
* file reports that rule's absolute path on disk; an unquoted colon in a
236+
* document's front matter reports the relative path this module handed to
237+
* Vale.
238+
*
239+
* `isAbsolute` is therefore the WHOLE discriminator, deliberately with no
240+
* additional `.taskless/`-prefix carve-out. An earlier version of this
241+
* function also rejected any relative path starting with `.taskless/`, on the
242+
* theory that Taskless's own directory could not hold a legitimate target.
243+
* That reasoning was wrong: `verifyValeRule` (`verify.ts`) points `runVale`
244+
* explicitly at `.taskless/rules/vale/<ruleId>/rule-tests`, and `check` accepts
245+
* an explicit path under `.taskless/` and checks it (see
246+
* `mixed-engine-check.test.ts`, "still checks an explicitly named path inside
247+
* .taskless"). Neither call passes through the `.taskless/**` glob exclusion
248+
* below — that exclusion applies ONLY on a whole-project walk. A malformed
249+
* fixture under `rule-tests/` therefore reports a relative `Path` starting
250+
* with `.taskless/rules/vale/...`, which the old carve-out misread as "not a
251+
* target" — reintroducing the exact #300 failure on the one path meant to
252+
* catch it: `verifyValeRule` returned one blocking failure for the whole rule
253+
* instead of excluding just the bad fixture and reporting the rest.
220254
*
221255
* The existence check is defensive, not load-bearing: if it is ever wrong for
222256
* a real target file, the failure mode is "this file could not be excluded,
@@ -230,12 +264,6 @@ async function targetFileParseError(
230264
if (path === undefined || path === "" || isAbsolute(path)) {
231265
return undefined;
232266
}
233-
if (
234-
path === TASKLESS_DIRECTORY ||
235-
path.startsWith(`${TASKLESS_DIRECTORY}/`)
236-
) {
237-
return undefined;
238-
}
239267
try {
240268
const stats = await stat(resolvePath(cwd, path));
241269
if (!stats.isFile()) return undefined;
@@ -342,16 +370,11 @@ async function spawnVale(
342370
// With --no-exit, a non-zero code is Vale failing, not Vale finding.
343371
if (code !== null && code !== 0) {
344372
const stderr = stderrChunks.join("").trim();
345-
let configError: ValeConfigError | undefined;
346-
try {
347-
configError = asValeConfigError(JSON.parse(stderr));
348-
} catch {
349-
configError = undefined;
350-
}
373+
const configError = parseValeConfigError(stderr);
351374
settle({
352375
status: "failed",
353376
message: `Vale exited ${String(code)}${
354-
stderr === "" ? "" : `: ${describeValeStderr(stderr)}`
377+
stderr === "" ? "" : `: ${describeValeStderr(stderr, configError)}`
355378
}`,
356379
...(configError === undefined ? {} : { configError }),
357380
});
@@ -400,9 +423,7 @@ async function spawnVale(
400423
if (configError !== undefined) {
401424
settle({
402425
status: "failed",
403-
message: `Vale rejected the configuration (${configError.Code}): ${configError.Text}${
404-
configError.Path === undefined ? "" : ` in ${configError.Path}`
405-
}`,
426+
message: `Vale rejected the configuration: ${formatValeConfigError(configError, { withPath: true })}`,
406427
configError,
407428
});
408429
return;

packages/cli/test/vale-verify.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,48 @@ withVale("verifyValeRule", () => {
363363
const result = verification(await verifyValeRule(cwd, "no-simply"));
364364
expect(result.passed).toBe(true);
365365
});
366+
367+
it("excludes a fixture with unparseable front matter instead of blocking every other fixture (taskless/cli#300)", async () => {
368+
// `verifyValeRule` points `runVale` at `.taskless/rules/vale/<ruleId>/.tests`
369+
// directly — an explicit path, not a whole-project walk — so the
370+
// `.taskless/**` glob exclusion in `runVale` never applies here and Vale
371+
// really does walk into this directory. A malformed fixture therefore
372+
// reports a config-error `Path` that starts with `.taskless/rules/vale/…`.
373+
// `targetFileParseError` must still recognize that as a target file (no
374+
// `.taskless/`-prefix carve-out) or this call path falls back to the
375+
// pre-#300 behaviour: one bad fixture returns `{ outcome: { status:
376+
// "failed" } }` for the WHOLE rule, and neither `a.md` nor `c.md` below is
377+
// ever evaluated.
378+
const cwd = makeProject(
379+
{ "no-simply": existence("simply") },
380+
{
381+
"no-simply": {
382+
fail: {
383+
"a.md": "Just simply do it.\n",
384+
"bad.md":
385+
"---\ndescription: has a colon: right here\n---\n\nJust simply do it.\n",
386+
},
387+
pass: { "c.md": "Nothing objectionable.\n" },
388+
},
389+
}
390+
);
391+
392+
const result = verification(await verifyValeRule(cwd, "no-simply"));
393+
394+
// The good fixtures are still evaluated normally: `a.md` fires, `c.md`
395+
// stays clean.
396+
expect(result.missingFailures).not.toContain(
397+
".taskless/rules/vale/no-simply/.tests/fail/a.md"
398+
);
399+
expect(result.unexpectedFindings).toEqual([]);
400+
// `bad.md` could not be parsed, so it never fires under its own rule id —
401+
// it is reported as a missing failure rather than silently dropped, and
402+
// rather than taking `a.md` and `c.md` down with it.
403+
expect(result.missingFailures).toContain(
404+
".taskless/rules/vale/no-simply/.tests/fail/bad.md"
405+
);
406+
expect(result.passed).toBe(false);
407+
});
366408
});
367409

368410
withVale("verifyValeRules", () => {

0 commit comments

Comments
 (0)