|
| 1 | +import { join } from "node:path"; |
| 2 | + |
| 3 | +import { AST_GREP_VERSION, VALE_VERSION } from "./capabilities"; |
| 4 | +import haskellTypeSynonym from "./rule-migrations/sg-0-50-0-haskell-type-synonym"; |
| 5 | +import { compareVersions } from "../util/version-compare"; |
| 6 | + |
| 7 | +/** |
| 8 | + * Mechanical rewrites of rule CONTENT, applied when a project's rules cross an |
| 9 | + * engine version. |
| 10 | + * |
| 11 | + * Not scaffold migrations, and deliberately keyed on a different version. |
| 12 | + * The scaffold `version` records the LAYOUT, and every bump of it makes a |
| 13 | + * pinned older CLI refuse the project with `SCAFFOLD_VERSION_MISMATCH`. An |
| 14 | + * engine upgrade that renames a kind or retires a key touches the few projects |
| 15 | + * with a rule in that shape, so spending a scaffold bump on it would break |
| 16 | + * every pinned CI for a change almost none of them need. These are keyed on |
| 17 | + * `rules.engines` instead: the engine versions the rules were last reconciled |
| 18 | + * against, recorded by `update --rules` and nowhere else. |
| 19 | + * |
| 20 | + * `update --rules` applies them, after the ledger walk and before it stamps |
| 21 | + * the marker, so the marker never records an engine whose rewrites did not |
| 22 | + * run. Each migration runs when the recorded engine version is below its |
| 23 | + * `engineVersion` and the bundled engine is at or above it. |
| 24 | + * |
| 25 | + * A migration MUST: |
| 26 | + * |
| 27 | + * - be idempotent, and leave a file it has nothing to do in untouched, so a |
| 28 | + * rerun after a partial failure (nothing is stamped then) changes nothing |
| 29 | + * already done and `git status` stays clean on a project it does not apply |
| 30 | + * to; |
| 31 | + * - edit bytes in place rather than parse and re-serialize, so every comment |
| 32 | + * and blank line stays where the author put it; |
| 33 | + * - rewrite only shapes the Taskless service has not issued, or has already |
| 34 | + * migrated in its own corpus. An issued rule is checked against the bytes |
| 35 | + * the service recorded, and accepting any other bytes client-side would be a |
| 36 | + * signature bypass. A rewritten issued rule reads as "edited since Taskless |
| 37 | + * issued it", and `rule restore` then writes the old shape straight back. |
| 38 | + */ |
| 39 | +export type RuleEngine = "sg" | "vale"; |
| 40 | + |
| 41 | +export interface RuleMigration { |
| 42 | + engine: RuleEngine; |
| 43 | + /** The first engine version on which the rewritten shape is required. */ |
| 44 | + engineVersion: string; |
| 45 | + /** One line saying what changes, as `update --rules` reports it. */ |
| 46 | + summary: string; |
| 47 | + /** |
| 48 | + * Rewrite the rules under `engineDirectory` (`.taskless/rules/<engine>`), |
| 49 | + * returning each file written, relative to that directory. |
| 50 | + */ |
| 51 | + apply: (engineDirectory: string) => Promise<string[]>; |
| 52 | +} |
| 53 | + |
| 54 | +/** What one applied migration reports: its identity and the files it wrote. */ |
| 55 | +export interface AppliedRuleMigration { |
| 56 | + engine: RuleEngine; |
| 57 | + engineVersion: string; |
| 58 | + summary: string; |
| 59 | + /** Written files, relative to the project root. */ |
| 60 | + files: string[]; |
| 61 | +} |
| 62 | + |
| 63 | +const RULE_MIGRATIONS: readonly RuleMigration[] = [haskellTypeSynonym]; |
| 64 | + |
| 65 | +const BUNDLED: Record<RuleEngine, string> = { |
| 66 | + sg: AST_GREP_VERSION, |
| 67 | + vale: VALE_VERSION, |
| 68 | +}; |
| 69 | + |
| 70 | +/** |
| 71 | + * A rules block that never recorded an engine predates the marker, so it has |
| 72 | + * had none of these applied. The same reading `BASELINE_VERSION` gives an |
| 73 | + * absent `reconciledTo`; restated rather than imported, because |
| 74 | + * `reconcile-marker` imports this module. |
| 75 | + */ |
| 76 | +const UNRECORDED = "0.0.0"; |
| 77 | + |
| 78 | +/** The migrations a project with `recorded` engines still needs, in order. */ |
| 79 | +export function pendingRuleMigrations( |
| 80 | + recorded: Partial<Record<RuleEngine, string>> | undefined, |
| 81 | + migrations: readonly RuleMigration[] = RULE_MIGRATIONS |
| 82 | +): RuleMigration[] { |
| 83 | + return migrations |
| 84 | + .filter( |
| 85 | + (migration) => |
| 86 | + compareVersions( |
| 87 | + recorded?.[migration.engine] ?? UNRECORDED, |
| 88 | + migration.engineVersion |
| 89 | + ) < 0 && |
| 90 | + compareVersions(migration.engineVersion, BUNDLED[migration.engine]) <= 0 |
| 91 | + ) |
| 92 | + .toSorted( |
| 93 | + (a, b) => |
| 94 | + a.engine.localeCompare(b.engine) || |
| 95 | + compareVersions(a.engineVersion, b.engineVersion) |
| 96 | + ); |
| 97 | +} |
| 98 | + |
| 99 | +/** |
| 100 | + * Apply every pending migration under `tasklessDirectory`, in order. |
| 101 | + * |
| 102 | + * Every migration is reported, including one that wrote nothing, so the |
| 103 | + * output says what was checked and not only what changed. |
| 104 | + */ |
| 105 | +export async function applyRuleMigrations( |
| 106 | + tasklessDirectory: string, |
| 107 | + recorded: Partial<Record<RuleEngine, string>> | undefined, |
| 108 | + migrations: readonly RuleMigration[] = RULE_MIGRATIONS |
| 109 | +): Promise<AppliedRuleMigration[]> { |
| 110 | + const applied: AppliedRuleMigration[] = []; |
| 111 | + for (const migration of pendingRuleMigrations(recorded, migrations)) { |
| 112 | + const engineDirectory = join(tasklessDirectory, "rules", migration.engine); |
| 113 | + const written = await migration.apply(engineDirectory); |
| 114 | + applied.push({ |
| 115 | + engine: migration.engine, |
| 116 | + engineVersion: migration.engineVersion, |
| 117 | + summary: migration.summary, |
| 118 | + files: written.map((file) => |
| 119 | + join(".taskless", "rules", migration.engine, file) |
| 120 | + ), |
| 121 | + }); |
| 122 | + } |
| 123 | + return applied; |
| 124 | +} |
0 commit comments