upgrading/app/upgrades/0.11-to-0.12/strip-migration-labels-hints.ts
upgrading/app/upgrades/0.11-to-0.12/strip-migration-labels-hints.tsBrowse 76 files
3,237 tokens
12,558 bytes
Token encoding: o200k_base
Snapshot fac8604
← Back to SKILL.md
1/**2 * Brings on-disk `migration.json` manifests into the slimmed 0.12 metadata3 * model: drops the now-removed `labels` and `hints` keys and recomputes4 * `migrationHash` over the surviving metadata envelope + sibling `ops.json`.5 *6 * Background: starting at the 0.12 release the migration manifest schema is7 * closed (`'+': 'reject'`) — `labels` and `hints` are no longer part of the8 * model, so any manifest still carrying either key fails to load with9 * `INVALID_MANIFEST` naming the offending key. The two fields also no longer10 * participate in the content-addressed migration identity: `migrationHash` is11 * now computed over `{ from, to, providedInvariants, createdAt }` plus the12 * sibling operations, so every migrated manifest gets a freshly recomputed13 * hash over the slimmed envelope.14 *15 * Before 0.12 the on-disk shape was:16 *17 * {18 * "from": null,19 * "to": "sha256:…",20 * "labels": [],21 * "providedInvariants": ["…"],22 * "createdAt": "2026-…",23 * "hints": { "used": [], "applied": [], "plannerVersion": "2.0.0" },24 * "migrationHash": "sha256:…"25 * }26 *27 * Starting at 0.12 the same manifest is:28 *29 * {30 * "from": null,31 * "to": "sha256:…",32 * "providedInvariants": ["…"],33 * "createdAt": "2026-…",34 * "migrationHash": "sha256:…" // recomputed over the slimmed envelope35 * }36 *37 * Format-preserving edit: rather than reparse-and-reserialise (which would38 * reflow every value to a single canonical style and bloat the diff), this39 * codemod performs a surgical text edit — it removes only the `labels` and40 * `hints` top-level key lines and swaps the `migrationHash` value in place.41 * Every other byte (key order, indentation, and whether arrays like42 * `providedInvariants` are written inline or expanded) is left exactly as the43 * authoring tool wrote it, so the diff is limited to the two removed keys and44 * the new hash value.45 *46 * Confinement: an on-disk migration package is a `migration.json` paired with47 * a sibling `ops.json` (the operations the hash is computed over). The walk48 * keys off that pair rather than off a `migrations/` directory name, because49 * migration packages live under several roots in practice (`migrations/`,50 * `migration-fixtures/`, …); a `migration.json` with no sibling `ops.json` is51 * not a complete package and is left untouched.52 *53 * The hash algorithm is replicated inline (canonicalisation rules from54 * `@internal/framework-components` `canonicalizeJson` + the migration-tools55 * `computeMigrationHash`) so this script stays self-contained — consumers run56 * it via `pnpm exec tsx` from their project root with no dependency on any57 * `@internal/*` package being resolvable from that root.58 *59 * The codemod is idempotent: an already-slimmed manifest carries no60 * `labels`/`hints` and already has its recomputed hash, so the edit is a no-op61 * and the file is left untouched.62 *63 * Flags:64 * --check dry-run; lists manifests that still need fixing and exits 1 if65 * any remain.66 */67import { createHash } from 'node:crypto';68import { readdir, readFile, writeFile } from 'node:fs/promises';69import { dirname, join } from 'node:path';70 71const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);72 73const dryRun = process.argv.includes('--check');74const projectRoot = process.cwd();75 76// --- Inline canonicalisation + hash --------------------------------------77// Replicated from `@internal/framework-components` `canonicalizeJson`78// (sortKeys + JSON.stringify) and the migration-tools `computeMigrationHash`.79// Kept inline so the script has no `@internal/*` import — pnpm's strict80// node_modules layout won't resolve transitive framework deps from a81// consumer's project root.82 83function sortKeys(value: unknown): unknown {84 if (value === null || typeof value !== 'object') {85 return value;86 }87 if (Array.isArray(value)) {88 return value.map(sortKeys);89 }90 const sorted: Record<string, unknown> = Object.create(null);91 for (const [key, entry] of Object.entries(value).sort(([a], [b]) =>92 a < b ? -1 : a > b ? 1 : 0,93 )) {94 sorted[key] = sortKeys(entry);95 }96 return sorted;97}98 99function canonicalizeJson(value: unknown): string {100 return JSON.stringify(sortKeys(value));101}102 103function sha256Hex(input: string): string {104 return createHash('sha256').update(input).digest('hex');105}106 107/**108 * Content-addressed migration hash over (metadata envelope, ops). The109 * `migrationHash` field is stripped before hashing so the same function works110 * at write time (no hash yet) and at recompute time (rehashing an111 * already-attested record over the slimmed envelope).112 */113function computeMigrationHash(metadata: Record<string, unknown>, ops: unknown): string {114 const { migrationHash: _migrationHash, ...strippedMeta } = metadata;115 116 const partHashes = [canonicalizeJson(strippedMeta), canonicalizeJson(ops)].map(sha256Hex);117 return `sha256:${sha256Hex(canonicalizeJson(partHashes))}`;118}119 120// --- Format-preserving text surgery --------------------------------------121 122/**123 * Returns the index just past the end of the JSON value that starts at124 * `start` (which must point at the value's first character). Handles strings125 * (with escapes), nested objects/arrays, and primitives. Used to locate the126 * full span of a top-level key's value when removing the key from the raw127 * text without reparsing the whole document.128 */129function scanValueEnd(text: string, start: number): number {130 const c = text[start];131 132 if (c === '"') {133 let i = start + 1;134 while (i < text.length) {135 if (text[i] === '\\') {136 i += 2;137 continue;138 }139 if (text[i] === '"') return i + 1;140 i += 1;141 }142 throw new Error('unterminated string while scanning JSON value');143 }144 145 if (c === '{' || c === '[') {146 const open = c;147 const close = c === '{' ? '}' : ']';148 let depth = 0;149 let i = start;150 while (i < text.length) {151 const ch = text[i];152 if (ch === '"') {153 i = scanValueEnd(text, i);154 continue;155 }156 if (ch === open) depth += 1;157 else if (ch === close) {158 depth -= 1;159 if (depth === 0) return i + 1;160 }161 i += 1;162 }163 throw new Error('unterminated container while scanning JSON value');164 }165 166 // Primitive (number / true / false / null) — run to the next structural167 // terminator.168 let i = start;169 while (i < text.length && !',}]\r\n \t'.includes(text[i]!)) i += 1;170 return i;171}172 173/**174 * Removes a top-level object key (and its value) from `text`, preserving the175 * surrounding bytes exactly. No-op (returns `text`) if the key is absent.176 * Only the top-level `labels` / `hints` keys are ever passed here; both always177 * precede the trailing `migrationHash` key, so a removed key always carries a178 * trailing comma that is consumed along with the line.179 */180function removeTopLevelKey(text: string, key: string): string {181 // A top-level key is the only occurrence of `"key":` preceded by a newline182 // (line 1 is the opening `{`). Tolerant of any indentation width.183 const re = new RegExp(`\\n([ \\t]*)"${key}"[ \\t]*:[ \\t]*`);184 const match = re.exec(text);185 if (match === null) return text;186 187 const lineStart = match.index + 1; // position just after the leading newline188 const valueStart = match.index + match[0].length;189 let after = scanValueEnd(text, valueStart);190 191 while (text[after] === ' ' || text[after] === '\t') after += 1;192 if (text[after] === ',') after += 1;193 if (text[after] === '\r') after += 1;194 if (text[after] === '\n') after += 1;195 196 return text.slice(0, lineStart) + text.slice(after);197}198 199function replaceMigrationHash(text: string, oldHash: string, newHash: string): string {200 if (oldHash === newHash) return text;201 // Tolerate any whitespace around the colon (`"migrationHash":"…"`,202 // `"migrationHash" : "…"`), matching the leniency of `removeTopLevelKey`.203 const escapedOld = oldHash.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');204 const re = new RegExp(`("migrationHash"[ \\t]*:[ \\t]*)"${escapedOld}"`);205 const match = re.exec(text);206 if (match === null) {207 throw new Error('could not locate the migrationHash value to replace');208 }209 return text.replace(re, (_full, prefix: string) => `${prefix}"${newHash}"`);210}211 212// --- Filesystem walk ------------------------------------------------------213 214async function findMigrationManifests(root: string): Promise<string[]> {215 const out: string[] = [];216 217 async function walk(dir: string): Promise<void> {218 let entries: Awaited<ReturnType<typeof readdir>>;219 try {220 entries = await readdir(dir, { withFileTypes: true });221 } catch {222 // Unreadable directory — skip silently. The consumer's project root may223 // legitimately contain restricted directories.224 return;225 }226 for (const entry of entries) {227 if (entry.isDirectory()) {228 if (SKIP_DIRS.has(entry.name)) continue;229 await walk(join(dir, entry.name));230 } else if (entry.isFile() && entry.name === 'migration.json') {231 out.push(join(dir, entry.name));232 }233 }234 }235 236 await walk(root);237 return out.sort();238}239 240// --- Per-file transform ---------------------------------------------------241 242/** Narrows an arbitrary JSON-parsed value to a plain object (manifest shape). */243function isJsonObject(value: unknown): value is Record<string, unknown> {244 return typeof value === 'object' && value !== null && !Array.isArray(value);245}246 247type Status = 'already-clean' | 'needs-fix' | 'fixed' | 'skipped-no-ops';248 249interface Result {250 readonly path: string;251 readonly status: Status;252}253 254async function processFile(path: string): Promise<Result> {255 const raw = await readFile(path, 'utf-8');256 257 let parsed: unknown;258 try {259 parsed = JSON.parse(raw);260 } catch (error) {261 throw new Error(262 `${path}: not valid JSON (${error instanceof Error ? error.message : String(error)})`,263 );264 }265 if (!isJsonObject(parsed)) {266 return { path, status: 'already-clean' }; // not a manifest object267 }268 const metadata = parsed;269 270 // A complete on-disk migration package pairs `migration.json` with a sibling271 // `ops.json` (the operations the hash is computed over); without it we cannot272 // recompute the hash, so this is not a package we should touch.273 const opsPath = join(dirname(path), 'ops.json');274 let ops: unknown;275 try {276 ops = JSON.parse(await readFile(opsPath, 'utf-8'));277 } catch {278 return { path, status: 'skipped-no-ops' };279 }280 281 // Recompute over the slimmed envelope (canonicalisation is order/whitespace282 // independent, so the parsed object is the right input regardless of on-disk283 // formatting). `computeMigrationHash` strips `migrationHash` internally.284 const slimmed = { ...metadata };285 delete slimmed['labels'];286 delete slimmed['hints'];287 const newHash = computeMigrationHash(slimmed, ops);288 289 let out = raw;290 out = removeTopLevelKey(out, 'labels');291 out = removeTopLevelKey(out, 'hints');292 293 const oldHash = metadata['migrationHash'];294 if (typeof oldHash === 'string') {295 out = replaceMigrationHash(out, oldHash, newHash);296 } else if (out !== raw) {297 // labels/hints were present but there is no string migrationHash to update298 // — a malformed manifest we refuse to guess at.299 throw new Error(`${path}: manifest is missing a string \`migrationHash\` field`);300 }301 302 if (out === raw) {303 return { path, status: 'already-clean' };304 }305 if (!dryRun) await writeFile(path, out, 'utf-8');306 return { path, status: dryRun ? 'needs-fix' : 'fixed' };307}308 309// --- Driver ---------------------------------------------------------------310 311const manifests = await findMigrationManifests(projectRoot);312if (manifests.length === 0) {313 console.error(`No migration.json files found under ${projectRoot}.`);314 process.exit(1);315}316 317let changed = 0;318let alreadyClean = 0;319let skipped = 0;320for (const path of manifests) {321 const result = await processFile(path);322 const rel = path.slice(projectRoot.length + 1);323 if (result.status === 'already-clean') {324 alreadyClean += 1;325 } else if (result.status === 'skipped-no-ops') {326 skipped += 1;327 console.log(`SKIP ${rel} (no sibling ops.json — not a migration package)`);328 } else {329 changed += 1;330 const verb = dryRun ? 'WOULD FIX' : 'FIXED';331 console.log(`${verb} ${rel}`);332 }333}334 335console.log();336console.log(337 `${manifests.length} manifest(s) scanned: ${changed} ${dryRun ? 'needing fix' : 'fixed'}, ${alreadyClean} already clean${skipped > 0 ? `, ${skipped} skipped (no ops.json)` : ''}.`,338);339 340if (dryRun && changed > 0) process.exit(1);341