From 8b7925e00ce60efb3245889317f380fc11654aa1 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 20 Oct 2026 17:06:36 -0700 Subject: [PATCH] feat(update): move a Vale rule's tests: cases into fixtures on 3.24.0 `validate` refuses a Vale rule's `tests:` key from 3.24.0 on, and 0.12.0 had accepted it. A rule migration keyed on Vale 3.24.0 now writes each case's input as a fixture under .tests/fail when it expects an alert and .tests/pass when it expects none, named for the case with its format as the extension, then deletes the block by its byte range. It never overwrites a fixture, reruns as a no-op, and leaves a rule whose cases read through a view or name another rule file untouched. No issued rule carries the key. --- .changeset/vale-3-24-0.md | 2 +- packages/cli/src/agent/update.md | 30 ++- packages/cli/src/rules/rule-migrate.ts | 6 +- .../vale-3-24-0-tests-to-fixtures.ts | 253 ++++++++++++++++++ packages/cli/test/reconcile-marker.test.ts | 50 ++++ packages/cli/test/rule-migrate.test.ts | 233 +++++++++++++++- 6 files changed, 559 insertions(+), 15 deletions(-) create mode 100644 packages/cli/src/rules/rule-migrations/vale-3-24-0-tests-to-fixtures.ts diff --git a/.changeset/vale-3-24-0.md b/.changeset/vale-3-24-0.md index 9fd0aa15..f365aff8 100644 --- a/.changeset/vale-3-24-0.md +++ b/.changeset/vale-3-24-0.md @@ -8,4 +8,4 @@ Vale 3.24.0 warns about any key it does not recognise inside a matcher, includin 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. -`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. +`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. `update --rules` moves each case into one of those fixtures for you. diff --git a/packages/cli/src/agent/update.md b/packages/cli/src/agent/update.md index ef079eb8..7b1f20fd 100644 --- a/packages/cli/src/agent/update.md +++ b/packages/cli/src/agent/update.md @@ -498,8 +498,7 @@ loaded it without complaint. ### Migrating to 0.13.3 The vendored ast-grep moves from 0.45.3 to 0.51.0, and Vale from 3.23.0 -to 3.24.0. Nothing here needs work by hand except a Vale rule carrying -a `tests:` key, described at the end. +to 3.24.0. Nothing here needs work by hand. **A Haskell type synonym is `type_synonym` now, not `type_synomym`, and `update --rules` renames it for you.** ast-grep's Haskell grammar moved @@ -553,17 +552,24 @@ Additions a rule config can use: a matcher may carry a condition, `[*.md if .Meta.draft]`, which applies it only to the matched files whose front matter satisfies it. `validate` accepts it. -**A rule's `tests:` key is refused.** Vale 3.24.0 ships `vale test`, -which runs cases written in a rule's `tests:` list. Taskless never runs -it: `verify` judges a Vale rule by the documents under `.tests/pass` -and `.tests/fail`. So `validate` and `verify` now reject the key, which +**A Vale rule's `tests:` cases move into its fixtures, and +`update --rules` moves them for you.** Vale 3.24.0 ships `vale test`, which runs +cases written in a rule's `tests:` list. Taskless never runs it: +`verify` judges a Vale rule by the documents under `.tests/pass` and +`.tests/fail`. So `validate` and `verify` now reject the key, which 0.12.0 had started accepting, rather than let a rule look tested by -cases nothing runs. `check` still runs such a rule. Search -`.taskless/rules/vale/*/*.yml` for a top-level `tests:`; for each case, -write its `input` as a document under `.tests/fail` if the case expects -the rule to fire, or `.tests/pass` if it expects no alert, named with -the case's `format` as its extension (`.md` when it has none). Then -delete the key and run `verify` on the rule. +cases nothing runs. `check` still runs such a rule. + +Do not move them by hand. Step 5 writes each case's `input` as a +document named for the case, under `.tests/fail` when the case expects +an alert and `.tests/pass` when it expects none, with the case's +`format` as its extension. It then deletes the `tests:` block, leaves +everything else in the rule as it was, never overwrites a fixture, and +names each file it wrote. No rule Taskless issued carries `tests:`, so +it never touches a signed rule. A rule with a case that reads through a +`view`, or names another rule file in `rule:`, is left as it is, and +`validate` keeps naming it; move that rule's cases by hand the same +way. ## Errors diff --git a/packages/cli/src/rules/rule-migrate.ts b/packages/cli/src/rules/rule-migrate.ts index 7d585a96..c5c70452 100644 --- a/packages/cli/src/rules/rule-migrate.ts +++ b/packages/cli/src/rules/rule-migrate.ts @@ -2,6 +2,7 @@ import { join } from "node:path"; import { AST_GREP_VERSION, VALE_VERSION } from "./capabilities"; import haskellTypeSynonym from "./rule-migrations/sg-0-50-0-haskell-type-synonym"; +import valeTestsToFixtures from "./rule-migrations/vale-3-24-0-tests-to-fixtures"; import { compareVersions } from "../util/version-compare"; /** @@ -60,7 +61,10 @@ export interface AppliedRuleMigration { files: string[]; } -const RULE_MIGRATIONS: readonly RuleMigration[] = [haskellTypeSynonym]; +const RULE_MIGRATIONS: readonly RuleMigration[] = [ + haskellTypeSynonym, + valeTestsToFixtures, +]; const BUNDLED: Record = { sg: AST_GREP_VERSION, diff --git a/packages/cli/src/rules/rule-migrations/vale-3-24-0-tests-to-fixtures.ts b/packages/cli/src/rules/rule-migrations/vale-3-24-0-tests-to-fixtures.ts new file mode 100644 index 00000000..e87a23ea --- /dev/null +++ b/packages/cli/src/rules/rule-migrations/vale-3-24-0-tests-to-fixtures.ts @@ -0,0 +1,253 @@ +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { isMap, isScalar, isSeq, parseAllDocuments } from "yaml"; + +import { RULE_TESTS_DIRECTORY } from "../layout"; +import type { RuleMigration } from "../rule-migrate"; + +/** + * Move a Vale rule's `tests:` cases into `.tests/pass` and `.tests/fail`. + * + * Vale 3.24.0 runs the cases a rule carries under `tests:` with `vale test`. + * Taskless never runs it: `verify` judges a Vale rule by the documents under + * `.tests/`, and `validate` refuses the key from 3.24.0 on, so a rule written + * against 0.12.0, which accepted it, would otherwise stop validating. + * + * A case is one document and whether the rule fires on it, which is exactly + * what a fixture is. An in-rule case loads only the rule it lives in, so any + * alert it expects is that rule's: + * + * | the case | becomes | + * | ------------------------------ | ---------------- | + * | `want` with any text | `.tests/fail/` | + * | `want` that is empty | `.tests/pass/` | + * | no `want`, but `contains` | `.tests/fail/` | + * | only `absent` | `.tests/pass/` | + * + * What `want` pins beyond that, the exact line, column and message, is not + * something a rule's verification needs, and it is not carried. + * + * The fixture holds the case's `input` byte for byte, is named for the case, + * and takes its extension from `format` (`md` when there is none, as Vale + * reads it). A rule with a case this cannot place is left exactly as it is: + * one that reads its input through a `view`, which this layout never defines, + * or that names another rule file in `rule:`. `validate` keeps refusing that + * rule, and its message says what to do. + * + * The `tests:` block is removed by the parser's byte range, not by + * re-serializing the file, so everything else in the rule stays as written. + * An existing fixture with the same bytes counts as written, which is what + * makes a rerun after a partial failure a no-op; one with other bytes is + * never overwritten, and the case takes the next free name. + * + * The Taskless service has issued no Vale rule carrying `tests:` (confirmed + * against its corpus, 2026-10-20), so no issued rule's signature is affected. + */ + +/** One case, reduced to the two facts a fixture records. */ +interface PlacedCase { + bucket: "pass" | "fail"; + name: string; + extension: string; + input: string; +} + +/** A rule this migration rewrites: its cases, and the block to remove. */ +interface Plan { + cases: PlacedCase[]; + blockStart: number; + blockEnd: number; +} + +function text(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +/** Whether `contains` or `absent` says anything: one excerpt, or a list. */ +function listed(value: unknown): boolean { + return ( + typeof value === "string" || (Array.isArray(value) && value.length > 0) + ); +} + +/** A case's bucket, or `undefined` when it asserts nothing Vale would run. */ +function bucketOf( + item: Record +): PlacedCase["bucket"] | undefined { + const want = text(item.want); + if (want !== undefined) return want.trim() === "" ? "pass" : "fail"; + if (listed(item.contains)) return "fail"; + if (listed(item.absent)) return "pass"; + return undefined; +} + +/** `flags a hedge` as `flags-a-hedge`; `case-` when nothing survives. */ +function slug(name: string | undefined, index: number): string { + const cleaned = (name ?? "") + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, "-") + .replaceAll(/^-+|-+$/g, ""); + return cleaned === "" ? `case-${String(index + 1)}` : cleaned; +} + +/** + * What to do with one rule file, or `undefined` to leave it alone. + * + * Alone means: not a single-document rule with a top-level `tests` list, or + * one whose cases cannot all be placed. Partly migrating a rule would leave + * it refused by `validate` with half its cases moved, which is harder to + * finish by hand than none. + */ +export function planTestsMigration( + source: string, + ruleFile: string +): Plan | undefined { + let documents; + try { + documents = parseAllDocuments(source); + } catch { + return undefined; + } + if (!Array.isArray(documents) || documents.length !== 1) return undefined; + const [document] = documents; + if (document === undefined || document.errors.length > 0) return undefined; + const root = document.contents; + if (!isMap(root) || !root.has("extends")) return undefined; + const pair = root.items.find( + (item) => isScalar(item.key) && item.key.value === "tests" + ); + if ( + pair === undefined || + !isScalar(pair.key) || + !isSeq(pair.value) || + pair.key.range === undefined || + pair.key.range === null || + pair.value.range === undefined || + pair.value.range === null + ) { + return undefined; + } + + const items: unknown = pair.value.toJSON(); + if (!Array.isArray(items)) return undefined; + const cases: PlacedCase[] = []; + for (const [index, raw] of items.entries()) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return undefined; + } + const item = raw as Record; + if (item.view !== undefined && item.view !== null) return undefined; + const rule = text(item.rule); + if (rule !== undefined && rule !== "" && rule !== ruleFile) { + return undefined; + } + const bucket = bucketOf(item); + if (bucket === undefined) return undefined; + const format = text(item.format) ?? ""; + cases.push({ + bucket, + name: slug(text(item.name), index), + extension: format.replace(/^\./, "") || "md", + input: text(item.input) ?? "", + }); + } + + // From the start of the key's line through the end of the value's line. + const keyStart = pair.key.range[0]; + const blockStart = source.lastIndexOf("\n", keyStart - 1) + 1; + let blockEnd = pair.value.range[1]; + if (blockEnd > 0 && source[blockEnd - 1] !== "\n") { + const newline = source.indexOf("\n", blockEnd); + blockEnd = newline === -1 ? source.length : newline + 1; + } + return { cases, blockStart, blockEnd }; +} + +async function readExisting(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +/** + * Write one case's fixture, returning its path relative to the rule + * directory, or `undefined` when identical bytes were already there. + */ +async function writeFixture( + ruleDirectory: string, + placed: PlacedCase, + taken: Set +): Promise { + const bucket = join(RULE_TESTS_DIRECTORY, placed.bucket); + await mkdir(join(ruleDirectory, bucket), { recursive: true }); + for (let attempt = 1; ; attempt += 1) { + const stem = + attempt === 1 ? placed.name : `${placed.name}-${String(attempt)}`; + const relative = join(bucket, `${stem}.${placed.extension}`); + if (taken.has(relative)) continue; + const existing = await readExisting(join(ruleDirectory, relative)); + if (existing === placed.input) { + taken.add(relative); + return undefined; + } + if (existing !== undefined) continue; + taken.add(relative); + await writeFile(join(ruleDirectory, relative), placed.input, "utf8"); + return relative; + } +} + +const migration: RuleMigration = { + engine: "vale", + engineVersion: "3.24.0", + summary: + "Vale rule `tests:` cases moved into `.tests/pass` and `.tests/fail`", + async apply(engineDirectory) { + let ruleDirectories; + try { + ruleDirectories = await readdir(engineDirectory, { withFileTypes: true }); + } catch (error) { + // No Vale rules tree: nothing to move. Anything else has to fail, not + // read as "nothing to do": the stamp that follows would record this + // migration as applied, and no later run would retry it. + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } + const written: string[] = []; + for (const ruleDirectory of ruleDirectories) { + if (!ruleDirectory.isDirectory()) continue; + const directory = join(engineDirectory, ruleDirectory.name); + const files = await readdir(directory, { withFileTypes: true }); + for (const file of files) { + if (!file.isFile() || !/\.ya?ml$/.test(file.name)) continue; + const path = join(directory, file.name); + const source = await readFile(path, "utf8"); + const plan = planTestsMigration(source, file.name); + if (plan === undefined) continue; + // Fixtures first, rule last: a failure in between leaves the cases + // in the rule, so the rerun finds them, sees its own fixtures, and + // only finishes the removal. + const taken = new Set(); + for (const placed of plan.cases) { + const fixture = await writeFixture(directory, placed, taken); + if (fixture !== undefined) { + written.push(join(ruleDirectory.name, fixture)); + } + } + await writeFile( + path, + source.slice(0, plan.blockStart) + source.slice(plan.blockEnd), + "utf8" + ); + written.push(join(ruleDirectory.name, file.name)); + } + } + return written; + }, +}; + +export default migration; diff --git a/packages/cli/test/reconcile-marker.test.ts b/packages/cli/test/reconcile-marker.test.ts index c0a6f1ab..690bca51 100644 --- a/packages/cli/test/reconcile-marker.test.ts +++ b/packages/cli/test/reconcile-marker.test.ts @@ -201,6 +201,56 @@ describe("recording a rules reconciliation", () => { vale: VALE_VERSION, }); }); + + it("moves a Vale rule's tests: cases into fixtures when it crosses 3.24.0", async () => { + const ruleDirectory = join(cwd, ".taskless", "rules", "vale", "hedge"); + await mkdir(ruleDirectory, { recursive: true }); + await writeFile( + join(ruleDirectory, "hedge.yml"), + 'extends: existence\nmessage: "x"\nlevel: warning\ntokens:\n - perhaps\n' + + "tests:\n - name: flags it\n input: Perhaps.\n contains: x\n" + ); + const manifestPath = join(cwd, ".taskless", "taskless.json"); + const manifest = JSON.parse( + await readFile(manifestPath, "utf8") + ) as Record; + manifest["rules"] = { + reconciledTo: "0.11.0", + engines: { sg: AST_GREP_VERSION, vale: "3.23.0" }, + }; + await writeFile(manifestPath, JSON.stringify(manifest, undefined, 2)); + + const result = await runCli(["update", "--rules", "--json", "-d", cwd]); + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout) as { + migrated: { engineVersion: string; files: string[] }[]; + }; + expect(payload.migrated).toContainEqual( + expect.objectContaining({ + engineVersion: "3.24.0", + files: [ + join( + ".taskless", + "rules", + "vale", + "hedge", + ".tests", + "fail", + "flags-it.md" + ), + join(".taskless", "rules", "vale", "hedge", "hedge.yml"), + ], + }) + ); + expect( + await readFile(join(ruleDirectory, "hedge.yml"), "utf8") + ).not.toContain("tests:"); + const rules = await readRules(); + expect(rules?.engines).toEqual({ + sg: AST_GREP_VERSION, + vale: VALE_VERSION, + }); + }); }); it("refuses when there is no .taskless/ to reconcile", async () => { diff --git a/packages/cli/test/rule-migrate.test.ts b/packages/cli/test/rule-migrate.test.ts index 46894129..1dc187e9 100644 --- a/packages/cli/test/rule-migrate.test.ts +++ b/packages/cli/test/rule-migrate.test.ts @@ -2,8 +2,10 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { parse as parseYaml } from "yaml"; import { AST_GREP_VERSION, VALE_VERSION } from "../src/rules/capabilities"; +import { validateValeRule } from "../src/schemas/vale-rule"; import { applyRuleMigrations, pendingRuleMigrations, @@ -12,6 +14,9 @@ import { import haskellTypeSynonym, { renameTypeSynonymKind, } from "../src/rules/rule-migrations/sg-0-50-0-haskell-type-synonym"; +import valeTestsToFixtures, { + planTestsMigration, +} from "../src/rules/rule-migrations/vale-3-24-0-tests-to-fixtures"; const noop = ( engine: RuleMigration["engine"], @@ -125,6 +130,7 @@ describe("applying the Haskell rename to a project", () => { await rule("syn", haskell("Haskell")); const applied = await applyRuleMigrations(tasklessDirectory, { sg: "0.45.3", + vale: VALE_VERSION, }); expect(applied).toEqual([ { @@ -184,8 +190,233 @@ describe("applying the Haskell rename to a project", () => { it("does not run once the recorded engine has crossed it", async () => { await rule("syn", haskell("Haskell")); expect( - await applyRuleMigrations(tasklessDirectory, { sg: AST_GREP_VERSION }) + await applyRuleMigrations(tasklessDirectory, { + sg: AST_GREP_VERSION, + vale: VALE_VERSION, + }) ).toEqual([]); expect(await read("syn")).toBe(haskell("Haskell")); }); }); + +/** A Vale rule with `tests` spliced in where the caller puts it. */ +const valeRule = (tests: string, after = "level: warning\n") => + `extends: existence\nmessage: "Avoid '%s'"\n# kept\n${tests}${after}tokens:\n - simply\n`; + +describe("planning the Vale tests: move", () => { + const without = valeRule(""); + + it.each([ + ["an indented list", "tests:\n - name: a\n input: x\n want: ''\n"], + [ + "a list at the key's own column", + "tests:\n- name: a\n input: x\n want: ''\n", + ], + [ + "a list with a blank line inside", + "tests:\n - name: a\n\n input: x\n want: ''\n", + ], + ])("removes %s and keeps every other byte", (_shape, tests) => { + const source = valeRule(tests); + const plan = planTestsMigration(source, "r.yml"); + expect(plan).toBeDefined(); + if (plan === undefined) return; + expect(source.slice(0, plan.blockStart) + source.slice(plan.blockEnd)).toBe( + without + ); + }); + + it("removes a block that ends the file with no newline", () => { + const source = + 'extends: existence\nmessage: "x"\ntests:\n - name: a\n want: ""'; + const plan = planTestsMigration(source, "r.yml"); + expect(plan).toBeDefined(); + if (plan === undefined) return; + expect(source.slice(0, plan.blockStart)).toBe( + 'extends: existence\nmessage: "x"\n' + ); + expect(plan.blockEnd).toBe(source.length); + }); + + it.each([ + ["want with text", "want: '1:1:r.r:Avoid'", "fail"], + ["an empty want", "want: ''", "pass"], + ["an empty block want", "want: |\n", "pass"], + ["contains", "contains: r.r", "fail"], + ["a contains list", "contains:\n - Avoid", "fail"], + ["only absent", "absent: r.r", "pass"], + ["want beside contains", "want: ''\n contains: x", "pass"], + ])("files a case with %s under %s", (_shape, assertion, bucket) => { + const plan = planTestsMigration( + valeRule(`tests:\n - name: a\n input: x\n ${assertion}\n`), + "r.yml" + ); + expect(plan?.cases[0]?.bucket).toBe(bucket); + }); + + it("names the fixture for the case and reads its format as Vale does", () => { + const plan = planTestsMigration( + valeRule( + "tests:\n - name: Flags a hedge!\n input: x\n format: .rst\n want: ''\n" + + " - name: b\n input: y\n format: txt\n want: ''\n" + + " - input: z\n want: ''\n" + ), + "r.yml" + ); + expect(plan?.cases.map((c) => `${c.name}.${c.extension}`)).toEqual([ + "flags-a-hedge.rst", + "b.txt", + "case-3.md", + ]); + }); + + it.each([ + ["reads through a view", "view: Docs\n want: ''"], + ["names another rule file", "rule: ../other/other.yml\n want: ''"], + ["asserts nothing", "about: no assertion"], + ])("leaves a rule alone when a case %s", (_shape, extra) => { + expect( + planTestsMigration( + valeRule(`tests:\n - name: a\n input: x\n ${extra}\n`), + "r.yml" + ) + ).toBeUndefined(); + }); + + it("accepts a case that names its own rule file", () => { + expect( + planTestsMigration( + valeRule("tests:\n - name: a\n rule: r.yml\n want: ''\n"), + "r.yml" + ) + ).toBeDefined(); + }); + + it.each([ + ["no tests key", valeRule("")], + ["no extends", "message: x\ntests:\n - name: a\n want: ''\n"], + [ + "two documents", + `${valeRule("tests:\n - name: a\n want: ''\n")}---\nextends: x\n`, + ], + ["a tests key that is not a list", valeRule("tests: fixture.md\n")], + ])("is not a rule to move with %s", (_shape, source) => { + expect(planTestsMigration(source, "r.yml")).toBeUndefined(); + }); +}); + +describe("applying the Vale tests: move to a project", () => { + let tasklessDirectory: string; + const vale = () => join(tasklessDirectory, "rules", "vale"); + const path = (...segments: string[]) => join(vale(), ...segments); + + const source = valeRule( + "tests:\n - name: flags it\n input: Just simply do it.\n contains: Avoid\n" + + " - name: quiet\n input: |\n Just do it.\n want: ''\n" + ); + + async function rule(id: string, body: string) { + await mkdir(path(id), { recursive: true }); + await writeFile(path(id, `${id}.yml`), body); + } + + beforeEach(async () => { + tasklessDirectory = join( + await mkdtemp(join(tmpdir(), "rule-migrate-vale-")), + ".taskless" + ); + }); + + afterEach(async () => { + await rm(join(tasklessDirectory, ".."), { recursive: true, force: true }); + }); + + it("writes each case as a fixture, removes the key, and reports both", async () => { + await rule("no-simply", source); + const written = await valeTestsToFixtures.apply(vale()); + expect(written).toEqual([ + join("no-simply", ".tests", "fail", "flags-it.md"), + join("no-simply", ".tests", "pass", "quiet.md"), + join("no-simply", "no-simply.yml"), + ]); + expect( + await readFile(path("no-simply", ".tests", "fail", "flags-it.md"), "utf8") + ).toBe("Just simply do it."); + expect( + await readFile(path("no-simply", ".tests", "pass", "quiet.md"), "utf8") + ).toBe("Just do it.\n"); + const migrated = await readFile(path("no-simply", "no-simply.yml"), "utf8"); + expect(migrated).toBe(valeRule("")); + // The point of the move: the rule validates again. + expect(validateValeRule("no-simply", parseYaml(migrated)).valid).toBe(true); + }); + + it("writes nothing on a second run", async () => { + await rule("no-simply", source); + await valeTestsToFixtures.apply(vale()); + expect(await valeTestsToFixtures.apply(vale())).toEqual([]); + }); + + it("finishes a run interrupted after the fixtures, without duplicating them", async () => { + await rule("no-simply", source); + await mkdir(path("no-simply", ".tests", "fail"), { recursive: true }); + await writeFile( + path("no-simply", ".tests", "fail", "flags-it.md"), + "Just simply do it." + ); + expect(await valeTestsToFixtures.apply(vale())).toEqual([ + join("no-simply", ".tests", "pass", "quiet.md"), + join("no-simply", "no-simply.yml"), + ]); + }); + + it("never overwrites a different fixture of the same name", async () => { + await rule("no-simply", source); + await mkdir(path("no-simply", ".tests", "fail"), { recursive: true }); + await writeFile(path("no-simply", ".tests", "fail", "flags-it.md"), "mine"); + const written = await valeTestsToFixtures.apply(vale()); + expect(written).toContain( + join("no-simply", ".tests", "fail", "flags-it-2.md") + ); + expect( + await readFile(path("no-simply", ".tests", "fail", "flags-it.md"), "utf8") + ).toBe("mine"); + }); + + it("leaves a rule it cannot place untouched", async () => { + const body = valeRule( + "tests:\n - name: a\n view: Docs\n want: ''\n" + ); + await rule("viewed", body); + expect(await valeTestsToFixtures.apply(vale())).toEqual([]); + expect(await readFile(path("viewed", "viewed.yml"), "utf8")).toBe(body); + }); + + it("is a no-op on a project with no Vale rules", async () => { + expect(await valeTestsToFixtures.apply(vale())).toEqual([]); + }); + + it("fails, rather than reporting nothing to do, when the tree is unreadable", async () => { + await mkdir(join(tasklessDirectory, "rules"), { recursive: true }); + await writeFile(vale(), "not a directory"); + await expect(valeTestsToFixtures.apply(vale())).rejects.toThrow(); + }); + + it("runs for rules recorded below 3.24.0 and not after", async () => { + await rule("no-simply", source); + expect( + await applyRuleMigrations(tasklessDirectory, { + sg: AST_GREP_VERSION, + vale: VALE_VERSION, + }) + ).toEqual([]); + const applied = await applyRuleMigrations(tasklessDirectory, { + sg: AST_GREP_VERSION, + vale: "3.23.0", + }); + expect(applied.map((m) => m.engineVersion)).toEqual(["3.24.0"]); + expect(applied[0]?.files).toContain( + join(".taskless", "rules", "vale", "no-simply", "no-simply.yml") + ); + }); +});