Skip to content

Commit 3d15404

Browse files
committed
fix(vale): make the Vale 3.24.0 upgrade land green, and refuse a rule's tests: key
Vale 3.24.0 warns W101 about every key it does not recognise inside a matcher, including the `tskl) rule` breadcrumb. That printed one notice per matcher on every check, and the warning ahead of a fatal error's JSON broke the per-file narrowing from #300. Assembly now comments the breadcrumb out of the config Vale reads, and runVale peels Vale's warning lines off before parsing its error, one notice each. The converter E100 dropped its `[lintAdoc]` checker tag, so the contract asserts the format Vale now names instead. New 3.24.0 pins cover the W101, warning-then-error order, C# tool directives, a non-breaking space after a link, and files no rule runs on. `validate` and `verify` now reject a Vale rule's `tests:` key: Taskless verifies Vale rules by their .tests/pass and .tests/fail documents and never runs `vale test`.
1 parent e9d5cb9 commit 3d15404

14 files changed

Lines changed: 411 additions & 62 deletions

‎.changeset/vale-3-24-0.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,9 @@
33
---
44

55
Update the bundled Vale to 3.24.0.
6+
7+
Vale 3.24.0 warns about any key it does not recognise inside a matcher, including the `tskl) rule` breadcrumb every Vale rule config carries. The CLI now comments the breadcrumb out of the config it assembles for Vale, so `check` no longer prints one notice per matcher, and a file Vale cannot parse is again set aside with its own finding instead of failing the whole Vale run. Vale's warnings now arrive as one notice each.
8+
9+
Two findings change for existing rules: C# `// ReSharper disable` and `// NOSONAR` comments are no longer linted, and a word after a link and a non-breaking space is now matched. `update` (topic v18) walks both.
10+
11+
`validate` and `verify` now reject a Vale rule's `tests:` key. Vale 3.24.0 runs those cases with `vale test`, which Taskless never calls; a Vale rule is verified by its `.tests/pass` and `.tests/fail` documents.

‎packages/cli/src/agent/update.md‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -497,8 +497,9 @@ loaded it without complaint.
497497

498498
### Migrating to 0.13.3
499499

500-
The vendored ast-grep moves from 0.45.3 to 0.51.0. Nothing here needs
501-
work by hand.
500+
The vendored ast-grep moves from 0.45.3 to 0.51.0, and Vale from 3.23.0
501+
to 3.24.0. Nothing here needs work by hand except a Vale rule carrying
502+
a `tests:` key, described at the end.
502503

503504
**A Haskell type synonym is `type_synonym` now, not `type_synomym`, and
504505
`update --rules` renames it for you.** ast-grep's Haskell grammar moved
@@ -526,6 +527,44 @@ files that was routed to the runtime tier, or not written because
526527
ast-grep could not parse Zig, can now be an sg rule. No existing rule
527528
changes.
528529

530+
Two things in Vale 3.24.0 change which findings an existing rule
531+
reports, each measured against both binaries on the same fixture.
532+
533+
**A C# tool directive is no longer read.** A `// ReSharper disable`,
534+
`// ReSharper restore` or `// NOSONAR` line in a `.cs` file was linted
535+
as an ordinary comment, so a rule over comments fired on a suppression
536+
list. It is now skipped, as 3.23.0 already did for `//nolint`, `# noqa`
537+
and `eslint-disable`. Findings disappear; none appear.
538+
539+
**A word after a link and a non-breaking space is matched.** Through
540+
3.23.0 a U+00A0 or U+202F after inline markup was dropped, fusing the
541+
link text and the next word, so a token there went unreported. It is
542+
now a word break, and a rule over that word starts reporting.
543+
544+
What the CLI absorbed, so you do not have to: Vale 3.24.0 warns `W101`
545+
about every key it does not recognise inside a matcher, which includes
546+
the `tskl) rule` breadcrumb every rule config carries. Keep writing the
547+
breadcrumb; the CLI comments it out of the file it hands Vale, so no
548+
notice appears. A file no rule runs on is no longer parsed, which
549+
`check` already approximated by setting converter-dependent files
550+
aside, so nothing changes there.
551+
552+
Additions a rule config can use: a matcher may carry a condition,
553+
`[*.md if .Meta.draft]`, which applies it only to the matched files
554+
whose front matter satisfies it. `validate` accepts it.
555+
556+
**A rule's `tests:` key is refused.** Vale 3.24.0 ships `vale test`,
557+
which runs cases written in a rule's `tests:` list. Taskless never runs
558+
it: `verify` judges a Vale rule by the documents under `.tests/pass`
559+
and `.tests/fail`. So `validate` and `verify` now reject the key, which
560+
0.12.0 had started accepting, rather than let a rule look tested by
561+
cases nothing runs. `check` still runs such a rule. Search
562+
`.taskless/rules/vale/*/*.yml` for a top-level `tests:`; for each case,
563+
write its `input` as a document under `.tests/fail` if the case expects
564+
the rule to fire, or `.tests/pass` if it expects no alert, named with
565+
the case's `format` as its extension (`.md` when it has none). Then
566+
delete the key and run `verify` on the rule.
567+
529568
## Errors
530569

531570
With `--json`, `--rules` failures emit `{ ok: false, code, message }`:

‎packages/cli/src/rules/assemble.ts‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,25 +83,45 @@ function valeHeader(): string {
8383
}
8484

8585
/**
86-
* A rule's assembled block: its breadcrumb comment, then its config verbatim.
86+
* A rule's assembled block: its breadcrumb comment, then its config verbatim
87+
* except that each breadcrumb is commented out.
8788
*
8889
* The config is the generator's structured input and is never re-serialized.
8990
* The schema has already read it as an AST, for validation and for the matcher
9091
* list; what Vale reads is the file's own bytes, so matcher order, comments and
9192
* spacing survive exactly as the author wrote them. Matcher order inside a rule
9293
* is the author's expression of precedence, so anything short of verbatim
93-
* would be a semantic edit. The only addition is a newline after a source that
94+
* would be a semantic edit. The one addition is a newline after a source that
9495
* lacks one, so the next rule's breadcrumb starts its own line.
9596
*
97+
* The one edit is `# ` in front of each `tskl) rule` line, at the lines the
98+
* schema located (`breadcrumbLines`), so nothing here searches the text for
99+
* it. That is not a semantic edit: Vale read the key as a rule named
100+
* `tskl) rule` switched off, which is nothing. What changed in 3.24.0 is that
101+
* it says so, `W101 'tskl) rule' isn't a section option`, once per matcher on
102+
* every run, and that line on stderr in front of a real error's JSON is what
103+
* kept a file Vale cannot parse from being narrowed out of the run
104+
* (taskless/cli#300). Commenting rather than deleting keeps every line where
105+
* the author put it, so a line number Vale reports is still the author's.
106+
*
96107
* This is also why the schema, not this module, turns away a copied-in
97108
* `StylesPath` or `MinAlertLevel`. Assembly used to strip those two by
98109
* splitting each line on `=`; that was the one string edit it performed, and
99110
* it meant the file Vale read was not the file the author wrote. A run-level
100111
* key above the first matcher is now a rejection, so there is nothing left to
101112
* strip.
102113
*/
103-
function valeRuleBlock(ruleId: string, source: string): string {
104-
const terminated = source.endsWith("\n") ? source : `${source}\n`;
114+
function valeRuleBlock(
115+
ruleId: string,
116+
source: string,
117+
breadcrumbLines: readonly number[]
118+
): string {
119+
const lines = source.split("\n");
120+
for (const line of breadcrumbLines) {
121+
lines[line - 1] = `# ${lines[line - 1] ?? ""}`;
122+
}
123+
const commented = lines.join("\n");
124+
const terminated = commented.endsWith("\n") ? commented : `${commented}\n`;
105125
return `# tskl) rule = ${ruleId}\n${terminated}`;
106126
}
107127

@@ -243,7 +263,7 @@ export async function assembleValeConfig(
243263
if (selected !== undefined && !selected.has(ruleId)) continue;
244264
for (const pattern of verdict.sections) sections.add(pattern);
245265
advisories.push(...verdict.advisories);
246-
blocks.push(valeRuleBlock(ruleId, source));
266+
blocks.push(valeRuleBlock(ruleId, source, verdict.breadcrumbLines));
247267
}
248268

249269
if (refusals.length > 0) return { status: "refused", refusals };

‎packages/cli/src/rules/capabilities.ts‎

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -569,30 +569,34 @@ export const VALE_CONVERTER_DEPENDENT: readonly ValeConverterFormat[] =
569569
groupByConverter();
570570

571571
/**
572-
* Vale's checker tag per converter-dependent extension, from the `E100` text.
573-
*
574-
* This is the host-independent half of the failure. The prose after the tag is
575-
* not: `.xml` reports `xsltproc not found` where the program is absent and
576-
* `no XSLT transform provided` where it is present, and the two are split by
577-
* platform — macOS ships `/usr/bin/xsltproc`, the Linux CI image does not. A
578-
* contract test that matched on the program name therefore passed locally and
579-
* failed in CI, which is how this list came to exist.
572+
* The format Vale names per converter-dependent extension, from the `E100` text.
573+
*
574+
* Since 3.24.0 the error reads `asciidoctor not found; it's needed to read
575+
* AsciiDoc`, and the format at the end is the host-independent half. Through
576+
* 3.23.0 that half was a checker tag, `E100 [lintAdoc] Runtime error`, which
577+
* 3.24.0's typed errors dropped. The program name is not host-independent:
578+
* `.xml` reports `xsltproc not found` where the program is absent and `no XSLT
579+
* transform provided` where it is present, and the two are split by platform —
580+
* macOS ships `/usr/bin/xsltproc`, the Linux CI image does not. A contract
581+
* test that matched on the program name therefore passed locally and failed in
582+
* CI, which is how this list came to exist.
580583
*
581584
* `.xml` is also the one entry whose converter is not sufficient on its own. An
582585
* XSLT transform is document-specific, so there is no default to ship and
583586
* installing `xsltproc` does not make `.xml` lintable — unlike `asciidoctor`,
584587
* which genuinely fixes `.adoc`. That is why its `converter` names the
585-
* stylesheet as well as the program.
588+
* stylesheet as well as the program, and why the stylesheet message, which
589+
* names no format, is the one case the contract test accepts in its place.
586590
*/
587-
export const VALE_CONVERTER_CHECKERS: Readonly<Record<string, string>> = {
588-
".rst": "lintRST",
589-
".rest": "lintRST",
590-
".adoc": "lintAdoc",
591-
".asciidoc": "lintAdoc",
592-
".asc": "lintAdoc",
593-
".xml": "lintXML",
594-
".dita": "lintDITA",
595-
".typ": "lintTypst",
591+
export const VALE_CONVERTER_READERS: Readonly<Record<string, string>> = {
592+
".rst": "reStructuredText",
593+
".rest": "reStructuredText",
594+
".adoc": "AsciiDoc",
595+
".asciidoc": "AsciiDoc",
596+
".asc": "AsciiDoc",
597+
".xml": "XML",
598+
".dita": "DITA",
599+
".typ": "Typst",
596600
};
597601

598602
/** Every converter-dependent extension, flattened. */

‎packages/cli/src/rules/vale/formats.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ export const TASKLESS_DIRECTORY = ".taskless";
2424
* program to convert the source into something it can lint, and the
2525
* `@taskless/vale-*` platform packages ship the Vale binary as pure payload
2626
* with none of those programs alongside it. On a host without the converter the
27-
* conversion fails, and Vale does not degrade: it prints one
28-
* `E100 [lintAdoc] Runtime error` object on stderr, writes **nothing** to
29-
* stdout, and exits 2. The abort is Vale's, not ours, and it is not scoped to
27+
* conversion fails, and Vale does not degrade: it prints one `E100` object
28+
* (`asciidoctor not found; it's needed to read AsciiDoc`) on stderr, writes
29+
* **nothing** to stdout, and exits 2. The abort is Vale's, not ours, and it is not scoped to
3030
* the offending file — every finding from every other file in the same run is
3131
* lost inside Vale before it is ever serialized.
3232
*
@@ -108,8 +108,8 @@ export const CONVERTER_DEPENDENT_EXTENSIONS: readonly string[] = [
108108
* The converter Vale would need for `path`, or `undefined` if it needs none.
109109
*
110110
* Extension comparison is **case-sensitive, because Vale's own routing is**.
111-
* Measured against the pinned binary: `docs/guide.adoc` exits 2 with
112-
* `E100 [lintAdoc]`, while `docs/guide.ADOC` and `docs/guide.AdOc` are read as
111+
* Measured against the pinned binary: `docs/guide.adoc` exits 2 with an
112+
* `E100`, while `docs/guide.ADOC` and `docs/guide.AdOc` are read as
113113
* plain text and exit 0 with findings — including when the uppercase spelling
114114
* names a lowercase file on a case-insensitive filesystem, since Vale routes on
115115
* the path string it was handed, not on the name the disk holds.

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

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,39 @@ function parseValeConfigError(stderr: string): ValeConfigError | undefined {
129129
}
130130
}
131131

132+
/** A line Vale's `core.Warn` wrote: its code, a space, then the message. */
133+
const VALE_WARNING_LINE = /^W\d{3} /;
134+
135+
/**
136+
* Vale's stderr, split into the warnings it opens with and what follows.
137+
*
138+
* Vale writes each warning as its own `W101 <message>` line as it meets it,
139+
* and a fatal error as one JSON object after them, so a run can produce both:
140+
* since 3.24.0 a misspelled level (`<id>.<id> = maybe`) warns and the run then
141+
* dies on a document it cannot parse. Parsing the whole stream as JSON failed
142+
* on the warning, which threw away the error's `Path` and with it the
143+
* narrowing that keeps one unparseable file from taking every other file's
144+
* findings down (taskless/cli#300).
145+
*
146+
* Only the leading run of warning lines is peeled off. Anything after the
147+
* first line that is not one is returned whole as `rest`, so a JSON object
148+
* whose `Text` happens to contain such a line is never split.
149+
*/
150+
function splitValeWarnings(stderr: string): {
151+
warnings: string[];
152+
rest: string;
153+
} {
154+
const lines = stderr.split("\n");
155+
const firstOther = lines.findIndex(
156+
(line) => !VALE_WARNING_LINE.test(line.trim())
157+
);
158+
const count = firstOther === -1 ? lines.length : firstOther;
159+
return {
160+
warnings: lines.slice(0, count).map((line) => line.trim()),
161+
rest: lines.slice(count).join("\n").trim(),
162+
};
163+
}
164+
132165
/**
133166
* Vale's stderr, rendered as a sentence instead of a JSON blob.
134167
*
@@ -390,13 +423,20 @@ async function spawnVale(
390423

391424
// With --no-exit, a non-zero code is Vale failing, not Vale finding.
392425
if (code !== null && code !== 0) {
393-
const stderr = stderrChunks.join("").trim();
394-
const configError = parseValeConfigError(stderr);
426+
const { warnings, rest } = splitValeWarnings(
427+
stderrChunks.join("").trim()
428+
);
429+
const configError = parseValeConfigError(rest);
430+
// The error first, because it is why the run stopped; the warnings
431+
// after it, because one may be the reason (a rule switched off by a
432+
// misspelled level).
433+
const said = [
434+
...(rest === "" ? [] : [describeValeStderr(rest, configError)]),
435+
...warnings,
436+
].join(" ");
395437
settle({
396438
status: "failed",
397-
message: `Vale exited ${String(code)}${
398-
stderr === "" ? "" : `: ${describeValeStderr(stderr, configError)}`
399-
}`,
439+
message: `Vale exited ${String(code)}${said === "" ? "" : `: ${said}`}`,
400440
...(configError === undefined ? {} : { configError }),
401441
});
402442
return;
@@ -406,16 +446,20 @@ async function spawnVale(
406446
// otherwise succeeded — the `W101` ignored-assignment warning above all.
407447
// Attached to every `ok` path so a diagnostic cannot be dropped by which
408448
// branch happened to produce the (empty) results.
409-
const diagnostic = stderrChunks.join("").trim();
410-
// Both advisories ride the same field as separate elements, rather than
449+
const { warnings, rest } = splitValeWarnings(
450+
stderrChunks.join("").trim()
451+
);
452+
// Every advisory rides the same field as its own element, rather than
411453
// one overwriting the other: a project can perfectly well have a
412454
// section-less rule assignment *and* an AsciiDoc file, and dropping
413455
// either message would be a silent skip wearing the other's clothes.
456+
// One element per warning for the same reason: each `W101` line is its
457+
// own message, and Vale since 3.24.0 writes one per misread key.
414458
const notices = collectNotices([
415459
skipped,
416-
diagnostic === ""
417-
? undefined
418-
: `Vale reported while running: ${diagnostic}`,
460+
...[...warnings, rest]
461+
.filter((said) => said !== "")
462+
.map((said) => `Vale reported while running: ${said}`),
419463
]);
420464

421465
const stdout = stdoutChunks.join("").trim();

‎packages/cli/src/schemas/vale-config.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,18 @@ export interface ValeRuleConfigVerdict {
503503
advisories: string[];
504504
/** Every matcher's pattern, in source order, exactly as the header spells it. */
505505
sections: string[];
506+
/**
507+
* The 1-based line of every breadcrumb that could be located, in source
508+
* order, so assembly can hide them from Vale without re-reading the text.
509+
*
510+
* Since Vale 3.24.0 a key Vale does not recognise inside a matcher is said
511+
* aloud, `W101 'tskl) rule' isn't a section option`, once per matcher on
512+
* every run. The breadcrumb is ours and Vale never needed it, so the file
513+
* Vale reads carries it as a comment instead. A breadcrumb whose line could
514+
* not be located is left as written: Vale then warns about it, which is
515+
* noise, and the alternative of guessing a line is a wrong edit.
516+
*/
517+
breadcrumbLines: number[];
506518
}
507519

508520
/**
@@ -545,5 +557,12 @@ export function validateValeRuleConfig(
545557
sections: ast.sections
546558
.map((section) => section.name)
547559
.filter((name): name is string => name !== undefined),
560+
breadcrumbLines: ast.sections.flatMap((section) =>
561+
properties(section).flatMap((property) =>
562+
property.key === VALE_BREADCRUMB_KEY && property.line !== undefined
563+
? [property.line]
564+
: []
565+
)
566+
),
548567
};
549568
}

‎packages/cli/src/schemas/vale-rule.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -819,6 +819,36 @@ function actionMessages(
819819
];
820820
}
821821

822+
/**
823+
* A rule's own `tests:` cases, refused as policy rather than as vocabulary.
824+
*
825+
* Vale reads the key: it is a common field of every check, measured accepted
826+
* since 3.22.0 and announced with `vale test` in 3.24.0, and the generated
827+
* vocabulary records it as one. Taskless refuses it anyway. `verify` judges a
828+
* Vale rule by the documents under `.tests/pass` and `.tests/fail`, which are
829+
* whole files in any format the rule reaches, and it never runs `vale test`. A
830+
* rule carrying cases there would read as tested while nothing ran them, and
831+
* a second place to write a rule's cases is a second place for them to drift.
832+
*
833+
* `test/vale-corpus.ts` carries this as a counted divergence, so the
834+
* disagreement with the binary stays visible.
835+
*/
836+
function testsMessages(
837+
rule: Record<string, unknown>
838+
): { path: PropertyKey[]; message: string }[] {
839+
if (!("tests" in rule)) return [];
840+
return [
841+
{
842+
path: ["tests"],
843+
message:
844+
"'tests' holds cases for `vale test`, which Taskless never runs. " +
845+
"A Vale rule is verified by its fixtures: write each case as a " +
846+
"document under .tests/pass (the rule must stay quiet) or " +
847+
".tests/fail (the rule must fire), then delete the key.",
848+
},
849+
];
850+
}
851+
822852
/**
823853
* The union's members, spelled out, and the guard that keeps them honest.
824854
*
@@ -924,6 +954,7 @@ const valeBodySchema = z
924954
...fatalShapeMessages(rule),
925955
...swapBackreferenceMessages(rule),
926956
...actionMessages(rule),
957+
...testsMessages(rule),
927958
]) {
928959
context.issues.push({ code: "custom", input: rule, path, message });
929960
}

‎packages/cli/test/assemble.test.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,15 +201,20 @@ describe("Vale config assembly", () => {
201201

202202
// The config is the generator's structured input and is never re-serialized:
203203
// the schema reads it as an AST, Vale reads the author's own bytes.
204-
it("writes an accepted config verbatim under its breadcrumb", async () => {
204+
// Verbatim except the breadcrumbs, which are commented out in place: Vale
205+
// 3.24.0 warns W101 about an unknown key in a matcher, once per matcher on
206+
// every run. In place, so every other line keeps its number.
207+
it("writes an accepted config verbatim under its breadcrumb, breadcrumbs commented", async () => {
205208
const source =
206-
"# scope\n[docs/**]\ntskl) rule = no-simply\n\nno-simply.no-simply = YES \n";
209+
"# scope\n[docs/**]\ntskl) rule = no-simply\n\nno-simply.no-simply = YES \n\n[*.md]\n tskl) rule = no-simply\r\nno-simply.no-simply = NO\n";
207210
await valeRule("no-simply", source);
208211
const assembled = await assembleValeConfig(cwd);
209212
expect(assembled?.status).toBe("ok");
210213
if (assembled?.status !== "ok") return;
211214
const contents = await readFile(join(cwd, assembled.path), "utf8");
212-
expect(contents).toContain(`# tskl) rule = no-simply\n${source}`);
215+
expect(contents).toContain(
216+
"# tskl) rule = no-simply\n# scope\n[docs/**]\n# tskl) rule = no-simply\n\nno-simply.no-simply = YES \n\n[*.md]\n# tskl) rule = no-simply\r\nno-simply.no-simply = NO\n"
217+
);
213218
});
214219

215220
// What the schema notes without refusing rides on the result, so `check` can

0 commit comments

Comments
 (0)