diff --git a/.changeset/ast-grep-0-51-0.md b/.changeset/ast-grep-0-51-0.md new file mode 100644 index 00000000..b27c1026 --- /dev/null +++ b/.changeset/ast-grep-0-51-0.md @@ -0,0 +1,13 @@ +--- +"@taskless/cli": patch +--- + +Update the bundled ast-grep to 0.51.0 (from 0.45.3). + +What a rule author sees: + +- **Haskell rules on `kind: type_synomym` are renamed to `kind: type_synonym`.** ast-grep's Haskell grammar fixed the typo in that kind name, and a rule still using the old spelling fails to load. Because `taskless check` loads every ast-grep rule together, `check` would report the failure and no ast-grep findings at all. `taskless update --rules` now applies the rename itself when it records a reconciliation, naming each file it rewrote. No other Haskell kind or field changed. +- `taskless update --rules` applies mechanical rule rewrites for the engine versions a project's rules cross, keyed on the engine versions it last recorded rather than on the `.taskless/` layout version, so they never force a scaffold bump. `taskless update --json` lists them in advance. It now refuses with `SCAFFOLD_MIGRATION_REQUIRED` when the layout is behind; run `taskless init` first. +- `sg` rules can target `Zig`. +- A rule file ending in a stray `---` no longer stops `taskless check` from reporting any ast-grep findings for the project. ast-grep used to read the empty document after it as a broken rule and abort the whole scan. `taskless validate` still flags the file as multi-document YAML. +- The `.taskless/` layout does not change, so a pinned CLI keeps reading the project. diff --git a/packages/cli/package.json b/packages/cli/package.json index 6a1a617b..6a8d59a0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -59,7 +59,7 @@ "tsx": "^4.21.0" }, "devDependencies": { - "@ast-grep/cli": "0.45.3", + "@ast-grep/cli": "0.51.0", "@clack/prompts": "^1.2.0", "@jedmao/ini-parser": "0.2.4", "@types/sprintf-js": "^1.1.4", @@ -85,13 +85,13 @@ "taskless": "./dist/index.js" }, "optionalDependencies": { - "@ast-grep/cli-darwin-arm64": "0.45.3", - "@ast-grep/cli-darwin-x64": "0.45.3", - "@ast-grep/cli-linux-arm64-gnu": "0.45.3", - "@ast-grep/cli-linux-x64-gnu": "0.45.3", - "@ast-grep/cli-win32-arm64-msvc": "0.45.3", - "@ast-grep/cli-win32-ia32-msvc": "0.45.3", - "@ast-grep/cli-win32-x64-msvc": "0.45.3", + "@ast-grep/cli-darwin-arm64": "0.51.0", + "@ast-grep/cli-darwin-x64": "0.51.0", + "@ast-grep/cli-linux-arm64-gnu": "0.51.0", + "@ast-grep/cli-linux-x64-gnu": "0.51.0", + "@ast-grep/cli-win32-arm64-msvc": "0.51.0", + "@ast-grep/cli-win32-ia32-msvc": "0.51.0", + "@ast-grep/cli-win32-x64-msvc": "0.51.0", "@taskless/vale-darwin-arm64": "3.23.0-20260930045834", "@taskless/vale-darwin-x64": "3.23.0-20260930045834", "@taskless/vale-linux-arm64": "3.23.0-20260930045834", diff --git a/packages/cli/src/agent/update.md b/packages/cli/src/agent/update.md index 09c2c681..facbe62c 100644 --- a/packages/cli/src/agent/update.md +++ b/packages/cli/src/agent/update.md @@ -1,4 +1,4 @@ -# Topic: update (CLI v%(CLI_VERSION)s / topic v17) +# Topic: update (CLI v%(CLI_VERSION)s / topic v18) %(ONE_COMMAND_PER_CALL)s @@ -8,8 +8,10 @@ ALREADY IN THIS PROJECT, and what to do about them. It is not about installing. Running `%(TASKLESS_CLI)s` migrates the `.taskless/` layout and refreshes the installed skills on its own. That -handles the DIRECTORY. This recipe handles the RULES, which no migration -can rewrite for you: a rewriter that now needs a `fix`, a rule whose +handles the DIRECTORY. This recipe handles the RULES. A purely +mechanical rewrite, such as a kind an engine renamed, is applied by +`update --rules` when you record the walk. The rest needs judgment no +migration can supply: a rewriter that now needs a `fix`, a rule whose matching semantics shifted under a new engine, a rule that could now be expressed better with a newly supported language. @@ -103,6 +105,16 @@ that you finished. This also records the engine versions the rules are now valid against, which is what lets a later upgrade say what moved. + Before it records them, it applies every mechanical rewrite between + the engine versions the rules were last reconciled against and the + bundled ones, and names each file it changed (`migrated` with + `--json`). `ruleMigrations` in `update --json` lists them in advance. + A section below that says `update --rules` does something needs + nothing from you beyond this step. Read the rewritten files in the + diff, run `%(TASKLESS_CLI)s validate`, and commit them with the + marker. If the project's `.taskless/` layout is behind, this refuses + with `SCAFFOLD_MIGRATION_REQUIRED`; run `%(TASKLESS_CLI)s init` first. + ## The ledger ### Migrating to 0.11.x @@ -483,6 +495,37 @@ until now and starts reporting. by `check`, and `validate` rejected it before this release while Vale loaded it without complaint. +### Migrating to 0.13.3 + +The vendored ast-grep moves from 0.45.3 to 0.51.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 +from tree-sitter-haskell 0.23.1 to 0.24.1, which fixed the typo in that +kind's name. Diffed across both grammars, it is the only kind or field +that changed. A rule still naming the old kind is a HARD CONFIG ERROR: +exit 8, ``Kind `type_synomym` is invalid``, and since `check` loads +every sg rule into one config it reports no sg findings at all until +the rule is fixed. + +Do not rename it by hand. Step 5 rewrites every `kind: type_synomym` in +a rule whose `language` is Haskell, leaves everything else in the file +as it was, and names each file it changed. No rule Taskless issued uses +the old kind, so the rewrite never touches a signed rule. + +**A rule file ending in a stray `---` no longer breaks `check`.** On +0.45.3 ast-grep read the empty document after it as a second rule with +no `language`, exited 8, and `check` reported no sg findings at all. +It now loads. If `check` was failing on that, findings come back and +are real. `validate` still rejects the file as multi-document YAML, so +delete the trailing `---` when you next touch the rule. + +**`Zig` is a language.** `language: Zig` parses, so a rule over `.zig` +files that was routed to the runtime tier, or not written because +ast-grep could not parse Zig, can now be an sg rule. No existing rule +changes. + ## Errors With `--json`, `--rules` failures emit `{ ok: false, code, message }`: @@ -490,6 +533,7 @@ With `--json`, `--rules` failures emit `{ ok: false, code, message }`: | code | meaning | fix | |-----------------|------------------------------------------------|--------------------------------------------| | `INVALID_INPUT` | no `.taskless/`, or an older CLI would rewind the marker | set the project up, or upgrade first | +| `SCAFFOLD_MIGRATION_REQUIRED` | the `.taskless/` layout is behind this CLI, so rule rewrites have nowhere safe to write | run `%(TASKLESS_CLI)s init`, then `update --rules` again | ## See Also diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 1e7d1d28..163cb807 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -53,7 +53,11 @@ import { stampNewProjectRules, } from "../rules/reconcile-marker"; import { readManifest } from "../filesystem/manifest"; -import type { MigrationReport } from "../filesystem/migrate"; +import { + requireCurrentSchema, + type MigrationReport, +} from "../filesystem/migrate"; +import { pendingRuleMigrations } from "../rules/rule-migrate"; import { TASKLESS_DIRECTORY } from "../rules/vale/formats"; import { CLIError } from "../util/cli-error"; import { buildInvocation } from "../util/invocation"; @@ -291,6 +295,19 @@ export const updateCommand = defineCommand({ // never recorded a reconciliation, which is not the same as being // behind, or it is already current. walk: walk ?? null, + // The rewrites `update --rules` will apply before it stamps, so + // the walk can tell its reader what it does not need to do by + // hand. Empty without a project, which has nothing to rewrite. + ruleMigrations: + manifest === undefined + ? [] + : pendingRuleMigrations(manifest.rules?.engines).map( + ({ engine, engineVersion, summary }) => ({ + engine, + engineVersion, + summary, + }) + ), recipe, }) ); @@ -303,6 +320,10 @@ export const updateCommand = defineCommand({ const telemetry = await getTelemetry(cwd); try { + // Rule migrations rewrite files where the CURRENT layout puts them, so + // a scaffold that is behind has to be migrated first. Checked here and + // not in `recordReconciliation`, because `migrate` imports the marker. + await requireCurrentSchema(cwd); const result = await recordReconciliation(cwd); if (args.json) { console.log(JSON.stringify({ ok: true, ...result })); @@ -312,6 +333,14 @@ export const updateCommand = defineCommand({ ? `Rules reconciled to ${result.reconciledTo} (ast-grep ${result.engines.sg}, Vale ${result.engines.vale}).` : `Rules reconciled to ${result.reconciledTo}, was ${result.previous} (ast-grep ${result.engines.sg}, Vale ${result.engines.vale}).` ); + for (const migration of result.migrated) { + console.log( + `${migration.summary} (${migration.engine} ${migration.engineVersion}): ` + + (migration.files.length === 0 + ? "no rule needed it." + : `rewrote ${migration.files.join(", ")}.`) + ); + } } telemetry.capture("cli_rules_reconciled"); } catch (error) { diff --git a/packages/cli/src/generated/ast-grep-rule-schema.json b/packages/cli/src/generated/ast-grep-rule-schema.json index fe162bdb..a868e96c 100644 --- a/packages/cli/src/generated/ast-grep-rule-schema.json +++ b/packages/cli/src/generated/ast-grep-rule-schema.json @@ -743,5 +743,5 @@ "additionalProperties": true } }, - "$comment": "Generated by fetch-ast-grep-schema.ts from ast-grep v0.45.3 (https://raw.githubusercontent.com/ast-grep/ast-grep/0.45.3/schemas/rule.json)" + "$comment": "Generated by fetch-ast-grep-schema.ts from ast-grep v0.51.0 (https://raw.githubusercontent.com/ast-grep/ast-grep/0.51.0/schemas/rule.json)" } diff --git a/packages/cli/src/rules/capabilities.ts b/packages/cli/src/rules/capabilities.ts index 27303d32..cdbc37ed 100644 --- a/packages/cli/src/rules/capabilities.ts +++ b/packages/cli/src/rules/capabilities.ts @@ -39,7 +39,7 @@ * Pinned against the binary by `test/ast-grep-vendor-contract.test.ts` * ("engine capabilities" → "reports the pinned version"). */ -export const AST_GREP_VERSION = "0.45.3"; +export const AST_GREP_VERSION = "0.51.0"; /** * Every language ast-grep can parse, verbatim from @@ -91,6 +91,7 @@ export const AST_GREP_LANGUAGES = [ "Tsx", "TypeScript", "Yaml", + "Zig", ] as const; /** One of the spellings {@link AST_GREP_LANGUAGES} lists, canonically cased. */ diff --git a/packages/cli/src/rules/reconcile-marker.ts b/packages/cli/src/rules/reconcile-marker.ts index 45a3b43f..b0b7a6ac 100644 --- a/packages/cli/src/rules/reconcile-marker.ts +++ b/packages/cli/src/rules/reconcile-marker.ts @@ -2,6 +2,7 @@ import { access } from "node:fs/promises"; import { join } from "node:path"; import { AST_GREP_VERSION, VALE_VERSION } from "./capabilities"; +import { applyRuleMigrations, type AppliedRuleMigration } from "./rule-migrate"; import { readManifest, writeManifest } from "../filesystem/manifest"; import { TASKLESS_DIRECTORY } from "./vale/formats"; import { CLIError } from "../util/cli-error"; @@ -27,6 +28,8 @@ export interface ReconcileResult { engines: { sg: string; vale: string }; /** The value replaced, or `undefined` on a project that had never recorded one. */ previous: string | undefined; + /** Rule rewrites applied for the engine versions this stamp crosses. */ + migrated: AppliedRuleMigration[]; } /** @@ -98,6 +101,15 @@ export async function recordReconciliation( ); } + // Before the stamp, never after: the stamp records these engines, and a + // marker naming an engine whose rewrites did not run would excuse the next + // walk from them. A failure part-way leaves the marker where it was, and + // every migration is idempotent, so the rerun finishes the job. + const migrated = await applyRuleMigrations( + tasklessDirectory, + manifest.rules?.engines + ); + const rules = { ...manifest.rules, reconciledTo, @@ -108,7 +120,7 @@ export async function recordReconciliation( await writeManifest(tasklessDirectory, { ...manifest, rules }, raw); - return { reconciledTo, engines: rules.engines, previous }; + return { reconciledTo, engines: rules.engines, previous, migrated }; } /** diff --git a/packages/cli/src/rules/rule-migrate.ts b/packages/cli/src/rules/rule-migrate.ts new file mode 100644 index 00000000..7d585a96 --- /dev/null +++ b/packages/cli/src/rules/rule-migrate.ts @@ -0,0 +1,124 @@ +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 { compareVersions } from "../util/version-compare"; + +/** + * Mechanical rewrites of rule CONTENT, applied when a project's rules cross an + * engine version. + * + * Not scaffold migrations, and deliberately keyed on a different version. + * The scaffold `version` records the LAYOUT, and every bump of it makes a + * pinned older CLI refuse the project with `SCAFFOLD_VERSION_MISMATCH`. An + * engine upgrade that renames a kind or retires a key touches the few projects + * with a rule in that shape, so spending a scaffold bump on it would break + * every pinned CI for a change almost none of them need. These are keyed on + * `rules.engines` instead: the engine versions the rules were last reconciled + * against, recorded by `update --rules` and nowhere else. + * + * `update --rules` applies them, after the ledger walk and before it stamps + * the marker, so the marker never records an engine whose rewrites did not + * run. Each migration runs when the recorded engine version is below its + * `engineVersion` and the bundled engine is at or above it. + * + * A migration MUST: + * + * - be idempotent, and leave a file it has nothing to do in untouched, so a + * rerun after a partial failure (nothing is stamped then) changes nothing + * already done and `git status` stays clean on a project it does not apply + * to; + * - edit bytes in place rather than parse and re-serialize, so every comment + * and blank line stays where the author put it; + * - rewrite only shapes the Taskless service has not issued, or has already + * migrated in its own corpus. An issued rule is checked against the bytes + * the service recorded, and accepting any other bytes client-side would be a + * signature bypass. A rewritten issued rule reads as "edited since Taskless + * issued it", and `rule restore` then writes the old shape straight back. + */ +export type RuleEngine = "sg" | "vale"; + +export interface RuleMigration { + engine: RuleEngine; + /** The first engine version on which the rewritten shape is required. */ + engineVersion: string; + /** One line saying what changes, as `update --rules` reports it. */ + summary: string; + /** + * Rewrite the rules under `engineDirectory` (`.taskless/rules/`), + * returning each file written, relative to that directory. + */ + apply: (engineDirectory: string) => Promise; +} + +/** What one applied migration reports: its identity and the files it wrote. */ +export interface AppliedRuleMigration { + engine: RuleEngine; + engineVersion: string; + summary: string; + /** Written files, relative to the project root. */ + files: string[]; +} + +const RULE_MIGRATIONS: readonly RuleMigration[] = [haskellTypeSynonym]; + +const BUNDLED: Record = { + sg: AST_GREP_VERSION, + vale: VALE_VERSION, +}; + +/** + * A rules block that never recorded an engine predates the marker, so it has + * had none of these applied. The same reading `BASELINE_VERSION` gives an + * absent `reconciledTo`; restated rather than imported, because + * `reconcile-marker` imports this module. + */ +const UNRECORDED = "0.0.0"; + +/** The migrations a project with `recorded` engines still needs, in order. */ +export function pendingRuleMigrations( + recorded: Partial> | undefined, + migrations: readonly RuleMigration[] = RULE_MIGRATIONS +): RuleMigration[] { + return migrations + .filter( + (migration) => + compareVersions( + recorded?.[migration.engine] ?? UNRECORDED, + migration.engineVersion + ) < 0 && + compareVersions(migration.engineVersion, BUNDLED[migration.engine]) <= 0 + ) + .toSorted( + (a, b) => + a.engine.localeCompare(b.engine) || + compareVersions(a.engineVersion, b.engineVersion) + ); +} + +/** + * Apply every pending migration under `tasklessDirectory`, in order. + * + * Every migration is reported, including one that wrote nothing, so the + * output says what was checked and not only what changed. + */ +export async function applyRuleMigrations( + tasklessDirectory: string, + recorded: Partial> | undefined, + migrations: readonly RuleMigration[] = RULE_MIGRATIONS +): Promise { + const applied: AppliedRuleMigration[] = []; + for (const migration of pendingRuleMigrations(recorded, migrations)) { + const engineDirectory = join(tasklessDirectory, "rules", migration.engine); + const written = await migration.apply(engineDirectory); + applied.push({ + engine: migration.engine, + engineVersion: migration.engineVersion, + summary: migration.summary, + files: written.map((file) => + join(".taskless", "rules", migration.engine, file) + ), + }); + } + return applied; +} diff --git a/packages/cli/src/rules/rule-migrations/sg-0-50-0-haskell-type-synonym.ts b/packages/cli/src/rules/rule-migrations/sg-0-50-0-haskell-type-synonym.ts new file mode 100644 index 00000000..0745f82a --- /dev/null +++ b/packages/cli/src/rules/rule-migrations/sg-0-50-0-haskell-type-synonym.ts @@ -0,0 +1,86 @@ +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { parseAllDocuments } from "yaml"; + +import { resolveAstGrepLanguage } from "../capabilities"; +import type { RuleMigration } from "../rule-migrate"; + +/** + * Rename the Haskell kind `type_synomym` to `type_synonym`. + * + * ast-grep 0.50.0 moved tree-sitter-haskell from 0.23.1 to 0.24.1, which + * fixed the typo in that kind's name (`fix!: typo: 'synomym' -> 'synonym'`). + * Diffed across both grammars' node-types.json, it is the only named kind or + * field that changed. A rule still naming the old kind exits 8, and `check` + * loads every sg rule into one config, so it reports no sg findings at all. + * Pinned in `test/ast-grep-vendor-contract.test.ts`. + * + * Only `kind:` values are rewritten, and only in a rule whose `language` + * resolves to Haskell: the name means nothing in another grammar, and a + * message mentioning it is the author's prose. Fixtures under `.tests/` are + * Haskell source, not kind names, and are never read. + * + * The Taskless service has issued no rule naming the old kind, so no issued + * rule's signature is affected. + */ +const OLD_KIND = /(\bkind[ \t]*:[ \t]*)(["']?)type_synomym\2(?![\w-])/g; + +/** Whether any document in `source` declares a language that is Haskell. */ +function isHaskellRule(source: string): boolean { + try { + return parseAllDocuments(source).some((document) => { + const language: unknown = document.get("language"); + return ( + typeof language === "string" && + resolveAstGrepLanguage(language) === "Haskell" + ); + }); + } catch { + // A file that does not parse is not ours to rewrite; `validate` names it. + return false; + } +} + +/** `source` with every `kind: type_synomym` renamed. */ +export function renameTypeSynonymKind(source: string): string { + return source.replaceAll(OLD_KIND, "$1$2type_synonym$2"); +} + +const migration: RuleMigration = { + engine: "sg", + engineVersion: "0.50.0", + summary: "Haskell kind `type_synomym` renamed to `type_synonym`", + async apply(engineDirectory) { + let ruleDirectories; + try { + ruleDirectories = await readdir(engineDirectory, { withFileTypes: true }); + } catch (error) { + // No sg rules tree: nothing to rewrite. 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 files = await readdir(join(engineDirectory, ruleDirectory.name), { + withFileTypes: true, + }); + for (const file of files) { + if (!file.isFile() || !/\.ya?ml$/.test(file.name)) continue; + const path = join(engineDirectory, ruleDirectory.name, file.name); + const source = await readFile(path, "utf8"); + if (!isHaskellRule(source)) continue; + const renamed = renameTypeSynonymKind(source); + if (renamed === source) continue; + await writeFile(path, renamed, "utf8"); + written.push(join(ruleDirectory.name, file.name)); + } + } + return written; + }, +}; + +export default migration; diff --git a/packages/cli/test/ast-grep-vendor-contract.test.ts b/packages/cli/test/ast-grep-vendor-contract.test.ts index 9c225d4f..59c3dd40 100644 --- a/packages/cli/test/ast-grep-vendor-contract.test.ts +++ b/packages/cli/test/ast-grep-vendor-contract.test.ts @@ -73,6 +73,18 @@ const rule = (id: string, severity = "error") => "", ].join("\n"); +/** A Haskell rule that matches on `kind` alone. */ +const haskellKindRule = (kind: string) => + [ + "id: synonym", + "language: Haskell", + "severity: error", + "message: m", + "rule:", + ` kind: ${kind}`, + "", + ].join("\n"); + /** Valid test YAML, and invalid *rule* YAML — it carries no `language`. */ const testFile = (id: string, invalid: string[], valid: string[] = []) => [ @@ -560,13 +572,19 @@ withSg("ast-grep vendor contract", () => { // `validate` validates against the schema's `const` values, not their // position — so the exact text is pinned to make the next reorder // visible, not because anything depends on it. + // + // WORDING CHANGED AT 0.51.0, vocabulary and order unchanged. ast-grep + // replaced serde_yaml with serde-saphyr (ast-grep/ast-grep#2999), which + // drops the backticks around each variant and the `severity:` prefix. + // `validate` rejects this severity against the schema before ast-grep + // sees it, so no user reads this text through Taskless. const cwd = project({ rules: { "no-eval": rule("no-eval", "catastrophe") }, sources: evalSource, }); const result = scan(cwd); expect(result.stderr).toContain( - "unknown variant `catastrophe`, expected one of `off`, `hint`, `info`, `warning`, `error`" + "unknown variant `catastrophe`, expected one of off, hint, info, warning, error" ); }); @@ -618,6 +636,29 @@ withSg("ast-grep vendor contract", () => { expect(unparseable.status).toBeGreaterThan(1); expect(unparseable.status).toBe(8); }); + + it("loads a rule file that ends in an empty document", () => { + // RELAXED AT 0.51.0 by the serde-saphyr parser (ast-grep/ast-grep#2999). + // Measured on 0.45.3, a trailing `---` read as a second rule with no + // `language`, exited 8, and took every other rule in the config down + // with it. `validate` still rejects the file as multi-document YAML, + // but `check` hands rules to ast-grep as they are on disk, so this is + // the difference between a project's findings and none at all. + const cwd = project({ + rules: { + "no-eval": `${rule("no-eval")}---\n`, + other: rule("other"), + }, + sources: evalSource, + }); + const result = scan(cwd); + expect(result.status).toBe(1); + const ruleIds = result.stdout + .split("\n") + .filter((line) => line !== "") + .map((line) => (JSON.parse(line) as { ruleId: string }).ruleId); + expect(ruleIds.toSorted()).toEqual(["no-eval", "other"]); + }); }); describe("sg test", () => { @@ -1256,6 +1297,38 @@ withSg("ast-grep vendor contract", () => { * same features showed no difference at all, because neither bug fires * unless a metavariable is bound in the specific position the fix changed. */ + describe("Haskell names a type synonym `type_synonym`", () => { + // RENAMED AT 0.51.0, which moved tree-sitter-haskell from 0.23.1 to + // 0.24.1. Upstream fixed a typo in the kind (`fix!: typo: 'synomym' -> + // 'synonym'`). Diffed against both grammars' node-types.json, this is the + // only named kind and the only field that changed. A rule still on the + // old spelling exits 8, which takes every other sg rule in `check` down + // with it; `validate` names the bad kind. + const sources = { "src/a.hs": "type Name = String\n" }; + + it("matches `type_synonym`", () => { + const result = scan( + project({ + rules: { synonym: haskellKindRule("type_synonym") }, + sources, + }) + ); + expect(result.status).toBe(1); + expect(result.stdout).toContain('"ruleId":"synonym"'); + }); + + it("refuses the old `type_synomym` spelling", () => { + const result = scan( + project({ + rules: { synonym: haskellKindRule("type_synomym") }, + sources, + }) + ); + expect(result.status).toBe(8); + expect(result.stderr).toContain("Kind `type_synomym` is invalid"); + }); + }); + describe("metavariable bindings do not leak", () => { it("counts nthChild positions from 1, not from 0", () => { // Pinned because the vendored schema says otherwise. `NthChildSimple` diff --git a/packages/cli/test/reconcile-marker.test.ts b/packages/cli/test/reconcile-marker.test.ts index 1290cdd7..c0a6f1ab 100644 --- a/packages/cli/test/reconcile-marker.test.ts +++ b/packages/cli/test/reconcile-marker.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { promisify } from "node:util"; @@ -70,6 +70,10 @@ describe("recording a rules reconciliation", () => { await rm(cwd, { recursive: true, force: true }); }); + function rulePath(): string { + return join(cwd, ".taskless", "rules", "sg", "syn", "syn.yml"); + } + async function readRules(): Promise | undefined> { const raw = await readFile(join(cwd, ".taskless", "taskless.json"), "utf8"); return (JSON.parse(raw) as { rules?: Record }).rules; @@ -110,6 +114,95 @@ describe("recording a rules reconciliation", () => { expect(parsed.rules.reconciledTo).toBe(version); }); + describe("rule migrations", () => { + beforeEach(async () => { + await mkdir(join(cwd, ".taskless", "rules", "sg", "syn"), { + recursive: true, + }); + await writeFile( + rulePath(), + "id: syn\nlanguage: Haskell\nseverity: error\nmessage: m\nrule:\n kind: type_synomym\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: "0.45.3", vale: VALE_VERSION }, + }; + await writeFile(manifestPath, JSON.stringify(manifest, undefined, 2)); + }); + + it("lists what --rules will rewrite before it runs", async () => { + const result = await runCli(["update", "--json", "-d", cwd]); + const payload = JSON.parse(result.stdout) as { + ruleMigrations: { engine: string; engineVersion: string }[]; + }; + expect(payload.ruleMigrations).toContainEqual( + expect.objectContaining({ engine: "sg", engineVersion: "0.50.0" }) + ); + }); + + it("rewrites the rules for the engines it crosses, then stamps", async () => { + 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: "0.50.0", + files: [join(".taskless", "rules", "sg", "syn", "syn.yml")], + }) + ); + expect(await readFile(rulePath(), "utf8")).toContain( + "kind: type_synonym" + ); + const rules = await readRules(); + expect(rules?.engines).toEqual({ + sg: AST_GREP_VERSION, + vale: VALE_VERSION, + }); + + // Stamped, so the next run has nothing left to apply. + const again = await runCli(["update", "--rules", "--json", "-d", cwd]); + expect( + (JSON.parse(again.stdout) as { migrated: unknown[] }).migrated + ).toEqual([]); + }); + + it("names the rewritten files on the human path", async () => { + const result = await runCli(["update", "--rules", "-d", cwd]); + expect(result.stdout).toContain("`type_synonym`"); + expect(result.stdout).toContain(join("sg", "syn", "syn.yml")); + }); + + it("refuses a scaffold that is behind, and rewrites nothing", async () => { + // Rule migrations write where the CURRENT layout keeps rules. + const manifestPath = join(cwd, ".taskless", "taskless.json"); + const manifest = JSON.parse( + await readFile(manifestPath, "utf8") + ) as Record; + manifest["version"] = 1; + await writeFile(manifestPath, JSON.stringify(manifest, undefined, 2)); + + const result = await runCli(["update", "--rules", "--json", "-d", cwd]); + expect(result.exitCode).not.toBe(0); + expect((JSON.parse(result.stdout) as { code: string }).code).toBe( + "SCAFFOLD_MIGRATION_REQUIRED" + ); + expect(await readFile(rulePath(), "utf8")).toContain( + "kind: type_synomym" + ); + const rules = await readRules(); + expect(rules?.engines).toEqual({ + sg: "0.45.3", + vale: VALE_VERSION, + }); + }); + }); + it("refuses when there is no .taskless/ to reconcile", async () => { // `readManifest` tolerates a missing file but `writeManifest` does not // create parent directories, so this used to die on a raw ENOENT reported @@ -290,6 +383,16 @@ describe("taskless update with no flags", () => { expect(result.stdout).toContain("`action` name fails the run at load"); }); + it("carries the 0.13.3 ledger entry", async () => { + const result = await runCli(["update"]); + expect(result.stdout).toContain("Migrating to 0.13.3"); + // The rename is mechanical, so the entry hands it to `update --rules` + // rather than to the agent. + expect(result.stdout).toContain("`type_synonym` now, not `type_synomym`"); + expect(result.stdout).toContain("`update --rules` renames it for you"); + expect(result.stdout).toContain("Do not rename it by hand"); + }); + it("warns that kind: link is loud, not a silent zero-match", async () => { // The correction that took a verified 0.45.2 binary to establish: an // invalid kind aborts config parsing and takes every other rule down. diff --git a/packages/cli/test/rule-migrate.test.ts b/packages/cli/test/rule-migrate.test.ts new file mode 100644 index 00000000..46894129 --- /dev/null +++ b/packages/cli/test/rule-migrate.test.ts @@ -0,0 +1,191 @@ +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 { AST_GREP_VERSION, VALE_VERSION } from "../src/rules/capabilities"; +import { + applyRuleMigrations, + pendingRuleMigrations, + type RuleMigration, +} from "../src/rules/rule-migrate"; +import haskellTypeSynonym, { + renameTypeSynonymKind, +} from "../src/rules/rule-migrations/sg-0-50-0-haskell-type-synonym"; + +const noop = ( + engine: RuleMigration["engine"], + engineVersion: string +): RuleMigration => ({ + engine, + engineVersion, + summary: `${engine} ${engineVersion}`, + apply: () => Promise.resolve([]), +}); + +describe("which rule migrations are pending", () => { + const migrations = [ + noop("sg", "0.50.0"), + noop("vale", "3.0.0"), + noop("sg", "0.40.0"), + // Above the bundled engine: written ahead of the bump that needs it. + noop("sg", "999.0.0"), + ]; + + it("runs those the recorded engine has not crossed, oldest first", () => { + const pending = pendingRuleMigrations( + { sg: "0.45.3", vale: "2.0.0" }, + migrations + ); + expect(pending.map((m) => m.summary)).toEqual(["sg 0.50.0", "vale 3.0.0"]); + }); + + it("runs none once the recorded engines are the bundled ones", () => { + expect( + pendingRuleMigrations( + { sg: AST_GREP_VERSION, vale: VALE_VERSION }, + migrations + ) + ).toEqual([]); + }); + + it("runs every one up to the bundled engine when none was recorded", () => { + // A rules block with no engines predates the marker, so nothing in it has + // had a migration applied. Reading absence as current would skip them all. + expect( + pendingRuleMigrations(undefined, migrations).map((m) => m.summary) + ).toEqual(["sg 0.40.0", "sg 0.50.0", "vale 3.0.0"]); + }); + + it("keys each engine on its own recorded version", () => { + expect( + pendingRuleMigrations({ sg: AST_GREP_VERSION }, migrations).map( + (m) => m.summary + ) + ).toEqual(["vale 3.0.0"]); + }); +}); + +describe("the Haskell type_synonym rename", () => { + it.each([ + ["block", " kind: type_synomym\n", " kind: type_synonym\n"], + ["flow", "has: { kind: type_synomym }\n", "has: { kind: type_synonym }\n"], + ["double-quoted", 'kind: "type_synomym"\n', 'kind: "type_synonym"\n'], + ["single-quoted", "kind: 'type_synomym'\n", "kind: 'type_synonym'\n"], + ["list item", " - kind: type_synomym\n", " - kind: type_synonym\n"], + ])("renames a %s kind", (_shape, before, after) => { + expect(renameTypeSynonymKind(before)).toBe(after); + }); + + it("leaves prose and look-alike kinds alone", () => { + const source = + "message: avoid type_synomym here\nkind: type_synomym_x\nkind: type_synomyms\n"; + expect(renameTypeSynonymKind(source)).toBe(source); + }); +}); + +/** An sg rule naming the old kind, in `language`. */ +const haskell = (language: string) => + [ + `id: syn`, + `language: ${language}`, + "severity: error", + "message: m", + "# a comment the rewrite must keep", + "rule:", + " kind: type_synomym", + "", + ].join("\n"); + +describe("applying the Haskell rename to a project", () => { + let tasklessDirectory: string; + const sg = () => join(tasklessDirectory, "rules", "sg"); + + async function rule(id: string, body: string, tests?: string) { + await mkdir(join(sg(), id, ".tests"), { recursive: true }); + await writeFile(join(sg(), id, `${id}.yml`), body); + if (tests !== undefined) { + await writeFile(join(sg(), id, ".tests", `${id}-test.yml`), tests); + } + } + const read = (id: string) => readFile(join(sg(), id, `${id}.yml`), "utf8"); + + beforeEach(async () => { + tasklessDirectory = join( + await mkdtemp(join(tmpdir(), "rule-migrate-")), + ".taskless" + ); + }); + + afterEach(async () => { + await rm(join(tasklessDirectory, ".."), { recursive: true, force: true }); + }); + + it("rewrites a Haskell rule in place and reports it", async () => { + await rule("syn", haskell("Haskell")); + const applied = await applyRuleMigrations(tasklessDirectory, { + sg: "0.45.3", + }); + expect(applied).toEqual([ + { + engine: "sg", + engineVersion: "0.50.0", + summary: haskellTypeSynonym.summary, + files: [join(".taskless", "rules", "sg", "syn", "syn.yml")], + }, + ]); + expect(await read("syn")).toBe( + haskell("Haskell").replace("type_synomym", "type_synonym") + ); + }); + + it("reads the language the way ast-grep does, alias included", async () => { + await rule("syn", haskell("hs")); + await haskellTypeSynonym.apply(sg()); + expect(await read("syn")).toContain("kind: type_synonym"); + }); + + it("does not touch a rule in another language", async () => { + await rule("syn", haskell("TypeScript")); + expect(await haskellTypeSynonym.apply(sg())).toEqual([]); + expect(await read("syn")).toBe(haskell("TypeScript")); + }); + + it("never reads fixtures", async () => { + const fixture = "id: syn\ninvalid:\n - 'kind: type_synomym'\n"; + await rule("syn", haskell("Haskell"), fixture); + await haskellTypeSynonym.apply(sg()); + expect( + await readFile(join(sg(), "syn", ".tests", "syn-test.yml"), "utf8") + ).toBe(fixture); + }); + + it("writes nothing on a second run", async () => { + await rule("syn", haskell("Haskell")); + await haskellTypeSynonym.apply(sg()); + expect(await haskellTypeSynonym.apply(sg())).toEqual([]); + }); + + it("is a no-op on a project with no sg rules", async () => { + expect(await haskellTypeSynonym.apply(sg())).toEqual([]); + }); + + it("fails, rather than reporting nothing to do, when the tree is unreadable", async () => { + // A file where the directory should be: ENOTDIR, not ENOENT. Read as + // "nothing to do", the stamp would record the rename as applied. + await mkdir(tasklessDirectory, { recursive: true }); + await mkdir(join(tasklessDirectory, "rules")); + await writeFile(sg(), ""); + await expect(haskellTypeSynonym.apply(sg())).rejects.toMatchObject({ + code: "ENOTDIR", + }); + }); + + 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 }) + ).toEqual([]); + expect(await read("syn")).toBe(haskell("Haskell")); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db7cabec..d8a76ea5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,8 +79,8 @@ importers: version: 4.21.0 devDependencies: '@ast-grep/cli': - specifier: 0.45.3 - version: 0.45.3 + specifier: 0.51.0 + version: 0.51.0 '@clack/prompts': specifier: ^1.2.0 version: 1.2.0 @@ -143,26 +143,26 @@ importers: version: 4.3.6 optionalDependencies: '@ast-grep/cli-darwin-arm64': - specifier: 0.45.3 - version: 0.45.3 + specifier: 0.51.0 + version: 0.51.0 '@ast-grep/cli-darwin-x64': - specifier: 0.45.3 - version: 0.45.3 + specifier: 0.51.0 + version: 0.51.0 '@ast-grep/cli-linux-arm64-gnu': - specifier: 0.45.3 - version: 0.45.3 + specifier: 0.51.0 + version: 0.51.0 '@ast-grep/cli-linux-x64-gnu': - specifier: 0.45.3 - version: 0.45.3 + specifier: 0.51.0 + version: 0.51.0 '@ast-grep/cli-win32-arm64-msvc': - specifier: 0.45.3 - version: 0.45.3 + specifier: 0.51.0 + version: 0.51.0 '@ast-grep/cli-win32-ia32-msvc': - specifier: 0.45.3 - version: 0.45.3 + specifier: 0.51.0 + version: 0.51.0 '@ast-grep/cli-win32-x64-msvc': - specifier: 0.45.3 - version: 0.45.3 + specifier: 0.51.0 + version: 0.51.0 '@taskless/vale-darwin-arm64': specifier: 3.23.0-20260930045834 version: 3.23.0-20260930045834 @@ -196,50 +196,50 @@ importers: packages: - '@ast-grep/cli-darwin-arm64@0.45.3': - resolution: {integrity: sha512-6RZg4gRMJcSJtEJuaW5z33qfwXD7kRDGZ0T0dTxVxsLSw5BkeAjR8REysxUwPWZn+oOkzPyZ3y7/qNnSI/diIA==} + '@ast-grep/cli-darwin-arm64@0.51.0': + resolution: {integrity: sha512-LYh8du1Qd1xFMi15Lh6xnWgHBZGbdCVyQVHOsaqEbICXvd8oAdEIex8ywTt2PwODYAMt6TD7p94S2/kt5dJ1tQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@ast-grep/cli-darwin-x64@0.45.3': - resolution: {integrity: sha512-4z6ZknTMSlTirQuJ4xihXoLfG7YwAOxFCqkKuGAxAiP+K70zivib8R19vUoFJDy4s+rjyGNDdrm45/ClC5tITQ==} + '@ast-grep/cli-darwin-x64@0.51.0': + resolution: {integrity: sha512-RF3FsO3XsGt+qLqpqbHiKKhly9/Pd08mVnvxx86Qli3DokvIFRAR8KlsTDnql4hsNgAbacp4vyDJsf/pBhh8iw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@ast-grep/cli-linux-arm64-gnu@0.45.3': - resolution: {integrity: sha512-T/N+Fl/pMqNjuyPV9PbTSR2bTlZrLKiCyHzJax6QNaOOVviXjEscROrAc6c0lFfc+Z07gW7Rt3oZKZdLEExutQ==} + '@ast-grep/cli-linux-arm64-gnu@0.51.0': + resolution: {integrity: sha512-aYT07uAVSA3nfoofdVXJuL44DGt1lIOj/GGSNy9mrIiD3TeBv5wxNNXHjMw96bkACdOp8pFMwiuCLY8pMtCT7Q==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@ast-grep/cli-linux-x64-gnu@0.45.3': - resolution: {integrity: sha512-HbxIy6tZa8zn4J2hG8KVIo9n4+INAgVB8l2DyHqYxGKxnod8u2B4hfTGxbMm+BjBvYxKW4oGLZtYqInMyxidyQ==} + '@ast-grep/cli-linux-x64-gnu@0.51.0': + resolution: {integrity: sha512-ljsdvSrmnmQZaB/lrtLuOP3k3AYCb+TNpPCMi4OQuF1vRBFwMdsES0xrL9iUpJ8ioyIy1h5qTM1T4z5OeoGIvw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@ast-grep/cli-win32-arm64-msvc@0.45.3': - resolution: {integrity: sha512-X0+81Mgr8zsH6hu4Pqdr5h1IyAFUWbKS1PMkKT6awYiiTo/1uhVM/6WzJ+ohOQPmMTyt4poyyg46u2E4WzKECg==} + '@ast-grep/cli-win32-arm64-msvc@0.51.0': + resolution: {integrity: sha512-G0eb9EmwWvsaJSZI929PCcJhBBeFiB7oAndml5weI1iDjDVn7f5u8jb4wEsovA9W5wy70MjYfH7/H59ogQr8Nw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@ast-grep/cli-win32-ia32-msvc@0.45.3': - resolution: {integrity: sha512-W4H27lU/xzzIOvRo4vDTjzq91PioLCjupR7jLmLVkAqAkX2YiNRixf8NjMcQgzAgr33jUsMlyutiTElLgi1O9g==} + '@ast-grep/cli-win32-ia32-msvc@0.51.0': + resolution: {integrity: sha512-68oC9L73Nq7Wi34aEqcUSIhIYaaM4PpVrrlcbobn4cAKSlUEMIC/9aKsKo6pknQYwSNr9dyIFX92zXIoTRNbcg==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] - '@ast-grep/cli-win32-x64-msvc@0.45.3': - resolution: {integrity: sha512-UZrpVbjLQqQIRxWqeMcwyLSIhlDZyhYb8SinssM38Oo6mEB2jMfHCEoigay9UOZTfUR268n72BBV48nW2h+QwA==} + '@ast-grep/cli-win32-x64-msvc@0.51.0': + resolution: {integrity: sha512-lYsKBG0Wn/vk8Zbeo60l+Lms317mL1xpW6Dvv68k3F57vqlMHkWR2tuNcyWC3OVJXbas8dFMoe1R+6wkofIC+g==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@ast-grep/cli@0.45.3': - resolution: {integrity: sha512-Cm07SHb8Q8dJfAQhrPOveAGuuzznmf6IjQa2+0Yvjt8Qfo+XIVbEIo0Prng+UDVMlaCS/FkF2eA8M3F6z4+0Wg==} + '@ast-grep/cli@0.51.0': + resolution: {integrity: sha512-sIZEt4L4iQJRM2QDKQnbltQxddIsxzt9nmguw3/X7eP3cFwBopsZtFRvaEVht/3/6C1xe3Opn/pbsxSFVJDvpA==} engines: {node: '>= 12.0.0'} hasBin: true @@ -2695,38 +2695,38 @@ packages: snapshots: - '@ast-grep/cli-darwin-arm64@0.45.3': + '@ast-grep/cli-darwin-arm64@0.51.0': optional: true - '@ast-grep/cli-darwin-x64@0.45.3': + '@ast-grep/cli-darwin-x64@0.51.0': optional: true - '@ast-grep/cli-linux-arm64-gnu@0.45.3': + '@ast-grep/cli-linux-arm64-gnu@0.51.0': optional: true - '@ast-grep/cli-linux-x64-gnu@0.45.3': + '@ast-grep/cli-linux-x64-gnu@0.51.0': optional: true - '@ast-grep/cli-win32-arm64-msvc@0.45.3': + '@ast-grep/cli-win32-arm64-msvc@0.51.0': optional: true - '@ast-grep/cli-win32-ia32-msvc@0.45.3': + '@ast-grep/cli-win32-ia32-msvc@0.51.0': optional: true - '@ast-grep/cli-win32-x64-msvc@0.45.3': + '@ast-grep/cli-win32-x64-msvc@0.51.0': optional: true - '@ast-grep/cli@0.45.3': + '@ast-grep/cli@0.51.0': dependencies: detect-libc: 2.1.2 optionalDependencies: - '@ast-grep/cli-darwin-arm64': 0.45.3 - '@ast-grep/cli-darwin-x64': 0.45.3 - '@ast-grep/cli-linux-arm64-gnu': 0.45.3 - '@ast-grep/cli-linux-x64-gnu': 0.45.3 - '@ast-grep/cli-win32-arm64-msvc': 0.45.3 - '@ast-grep/cli-win32-ia32-msvc': 0.45.3 - '@ast-grep/cli-win32-x64-msvc': 0.45.3 + '@ast-grep/cli-darwin-arm64': 0.51.0 + '@ast-grep/cli-darwin-x64': 0.51.0 + '@ast-grep/cli-linux-arm64-gnu': 0.51.0 + '@ast-grep/cli-linux-x64-gnu': 0.51.0 + '@ast-grep/cli-win32-arm64-msvc': 0.51.0 + '@ast-grep/cli-win32-ia32-msvc': 0.51.0 + '@ast-grep/cli-win32-x64-msvc': 0.51.0 '@babel/code-frame@7.29.0': dependencies: