From 6bdce9f8eb9c8165fac3ab0e355719af6d09fc43 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:11:18 +0000 Subject: [PATCH 1/3] chore(ast-grep): upgrade to ast-grep 0.51.0 --- .changeset/ast-grep-0-51-0.md | 5 + packages/cli/package.json | 16 ++-- .../src/generated/ast-grep-rule-schema.json | 2 +- packages/cli/src/rules/capabilities.ts | 2 +- pnpm-lock.yaml | 94 +++++++++---------- 5 files changed, 62 insertions(+), 57 deletions(-) create mode 100644 .changeset/ast-grep-0-51-0.md diff --git a/.changeset/ast-grep-0-51-0.md b/.changeset/ast-grep-0-51-0.md new file mode 100644 index 00000000..85eb771b --- /dev/null +++ b/.changeset/ast-grep-0-51-0.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +Update the bundled ast-grep to 0.51.0 (from 0.45.3). 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/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..67d3633a 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 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: From c460931cab472966f29baa3e54ef4d68b27d66da Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 20 Oct 2026 16:21:14 -0700 Subject: [PATCH 2/3] test(ast-grep): make the ast-grep 0.51.0 upgrade land green The pin bump alone left two contract pins red. ast-grep 0.50.0 added Zig, so AST_GREP_LANGUAGES gains it. 0.51.0 replaced serde_yaml with serde-saphyr, which drops the backticks from the severity error; the pin records the new wording. No Taskless user reads that text, since validate rejects the severity against the schema first. Two measured changes no test caught are now pinned. A rule file ending in a stray `---` loads on 0.51.0, where 0.45.3 exited 8 and took every sg rule in the config down with it. And tree-sitter-haskell 0.24.1 renamed the kind `type_synomym` to `type_synonym`, the only kind or field that changed across the two grammars' node-types.json; a rule on the old spelling now exits 8. That rename is fixed deterministically. `update --rules` now applies rule migrations keyed on the engine versions the rules were last reconciled against (`rules.engines`), not on the scaffold version, so a rewrite that touches a handful of projects never bumps every project's scaffold and breaks pinned CLIs in CI. Migrations run before the stamp, are idempotent, edit bytes in place, and must only rewrite shapes the service never issued; the service has issued no rule on the old Haskell kind. `update --json` lists pending migrations, and `update --rules` refuses with SCAFFOLD_MIGRATION_REQUIRED when the layout is behind. The ledger gains a 0.13.3 entry and the update topic moves to v18. --- .changeset/ast-grep-0-51-0.md | 8 + packages/cli/src/agent/update.md | 50 ++++- packages/cli/src/commands/init.ts | 31 ++- packages/cli/src/rules/capabilities.ts | 1 + packages/cli/src/rules/reconcile-marker.ts | 14 +- packages/cli/src/rules/rule-migrate.ts | 124 ++++++++++++ .../sg-0-50-0-haskell-type-synonym.ts | 83 ++++++++ .../cli/test/ast-grep-vendor-contract.test.ts | 75 +++++++- packages/cli/test/reconcile-marker.test.ts | 105 +++++++++- packages/cli/test/rule-migrate.test.ts | 180 ++++++++++++++++++ 10 files changed, 664 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/rules/rule-migrate.ts create mode 100644 packages/cli/src/rules/rule-migrations/sg-0-50-0-haskell-type-synonym.ts create mode 100644 packages/cli/test/rule-migrate.test.ts diff --git a/.changeset/ast-grep-0-51-0.md b/.changeset/ast-grep-0-51-0.md index 85eb771b..b27c1026 100644 --- a/.changeset/ast-grep-0-51-0.md +++ b/.changeset/ast-grep-0-51-0.md @@ -3,3 +3,11 @@ --- 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/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/rules/capabilities.ts b/packages/cli/src/rules/capabilities.ts index 67d3633a..cdbc37ed 100644 --- a/packages/cli/src/rules/capabilities.ts +++ b/packages/cli/src/rules/capabilities.ts @@ -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..cefacd19 --- /dev/null +++ b/packages/cli/src/rules/rule-migrations/sg-0-50-0-haskell-type-synonym.ts @@ -0,0 +1,83 @@ +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 { + // No sg rules tree: nothing to rewrite. + return []; + } + 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..f26ad91e --- /dev/null +++ b/packages/cli/test/rule-migrate.test.ts @@ -0,0 +1,180 @@ +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("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")); + }); +}); From 5471e2e499995e202e901c8c4d0f8a86f7da1f74 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 20 Oct 2026 16:35:35 -0700 Subject: [PATCH 3/3] fix(update): fail the Haskell rule migration on an unreadable rules tree Only a missing sg rules tree means there is nothing to rewrite. Any other readdir error was read the same way, and the stamp that follows would have recorded the rename as applied with no later run retrying it. --- .../rule-migrations/sg-0-50-0-haskell-type-synonym.ts | 9 ++++++--- packages/cli/test/rule-migrate.test.ts | 11 +++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) 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 index cefacd19..0745f82a 100644 --- 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 @@ -55,9 +55,12 @@ const migration: RuleMigration = { let ruleDirectories; try { ruleDirectories = await readdir(engineDirectory, { withFileTypes: true }); - } catch { - // No sg rules tree: nothing to rewrite. - return []; + } 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) { diff --git a/packages/cli/test/rule-migrate.test.ts b/packages/cli/test/rule-migrate.test.ts index f26ad91e..46894129 100644 --- a/packages/cli/test/rule-migrate.test.ts +++ b/packages/cli/test/rule-migrate.test.ts @@ -170,6 +170,17 @@ describe("applying the Haskell rename to a project", () => { 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(