upgrading/app/upgrades/0.8-to-0.9/strip-inline-contracts.ts
upgrading/app/upgrades/0.8-to-0.9/strip-inline-contracts.tsBrowse 76 files
2,132 tokens
8,001 bytes
Token encoding: o200k_base
Snapshot fac8604
← Back to SKILL.md
1/**2 * Removes the inlined `fromContract` / `toContract` fields from every3 * committed `migration.json` manifest reachable from the project root.4 *5 * Background: starting at the 0.9 release, `migration.json` no longer6 * carries `fromContract` / `toContract` (the schema rejects them as7 * unknown keys). The destination contract continues to live next door8 * as `end-contract.json` (and the source as `start-contract.json`); the9 * manifest copy was redundant. `migrationHash` is unaffected — it was10 * already computed without those two fields, so stripping them does11 * not change the stored hash.12 *13 * Behaviour:14 * - Walks the project root recursively, ignoring `node_modules`, `.git`,15 * `dist`, and `build`. Picks up every file named `migration.json`16 * whose JSON object has the migration-manifest shape (`from`, `to`,17 * and `migrationHash` keys). Other `migration.json` files (e.g.18 * unrelated artefacts that happen to share the name) are skipped.19 * - Manifests that already lack both removed keys are left untouched.20 * - Manifests with either removed key are rewritten with the two key /21 * value spans excised at the text level, so the formatting of all22 * surviving fields (whitespace, inline-vs-multiline arrays, key23 * ordering, trailing newline) is preserved byte-for-byte. Only the24 * key being removed and its trailing comma+newline disappear from25 * the diff.26 * - Idempotent: re-running the script after success is a no-op.27 *28 * Flags:29 * --check dry-run; exit 1 if any manifest still needs fixing.30 */31import { readdir, readFile, writeFile } from 'node:fs/promises';32import { join } from 'node:path';33 34const REMOVED_KEYS = ['fromContract', 'toContract'] as const;35const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);36 37const dryRun = process.argv.includes('--check');38const projectRoot = process.cwd();39 40interface Result {41 readonly path: string;42 readonly status: 'already-clean' | 'needs-fix' | 'fixed';43 readonly removed: readonly string[];44}45 46async function findManifests(root: string): Promise<string[]> {47 const out: string[] = [];48 49 async function walk(dir: string): Promise<void> {50 const entries = await readdir(dir, { withFileTypes: true });51 for (const entry of entries) {52 if (entry.isDirectory()) {53 if (SKIP_DIRS.has(entry.name)) continue;54 await walk(join(dir, entry.name));55 } else if (entry.isFile() && entry.name === 'migration.json') {56 const path = join(dir, entry.name);57 try {58 const parsed: unknown = JSON.parse(await readFile(path, 'utf-8'));59 if (looksLikeMigrationManifest(parsed)) out.push(path);60 } catch {61 // Not valid JSON, or not the manifest shape — skip silently.62 }63 }64 }65 }66 67 await walk(root);68 return out.sort();69}70 71function looksLikeMigrationManifest(value: unknown): value is Record<string, unknown> {72 if (typeof value !== 'object' || value === null) return false;73 const obj = value as Record<string, unknown>;74 return 'from' in obj && 'to' in obj && 'migrationHash' in obj;75}76 77/**78 * Find the end index (exclusive) of a JSON value starting at `start`79 * inside `text`. Handles strings, numbers, booleans, null, arrays, and80 * objects. Brace / bracket nesting is tracked while respecting string81 * literals (with escapes). Throws if `text` is not well-formed JSON82 * starting at `start`.83 */84function jsonValueEnd(text: string, start: number): number {85 const head = text[start];86 let depth = 0;87 let inString = false;88 let inEscape = false;89 90 if (head === '"') {91 let i = start + 1;92 while (i < text.length) {93 const ch = text[i];94 if (inEscape) inEscape = false;95 else if (ch === '\\') inEscape = true;96 else if (ch === '"') return i + 1;97 i += 1;98 }99 throw new Error(`Unterminated string starting at ${start}`);100 }101 102 if (head === '{' || head === '[') {103 let i = start;104 while (i < text.length) {105 const ch = text[i];106 if (inString) {107 if (inEscape) inEscape = false;108 else if (ch === '\\') inEscape = true;109 else if (ch === '"') inString = false;110 } else {111 if (ch === '"') inString = true;112 else if (ch === '{' || ch === '[') depth += 1;113 else if (ch === '}' || ch === ']') {114 depth -= 1;115 if (depth === 0) return i + 1;116 }117 }118 i += 1;119 }120 throw new Error(`Unterminated container starting at ${start}`);121 }122 123 let i = start;124 while (i < text.length && !',}\n\r\t '.includes(text[i] ?? '')) i += 1;125 return i;126}127 128/**129 * Remove a top-level key (and its `: value` and trailing comma) from a130 * pretty-printed JSON object text. Returns the new text. If the key131 * isn't present, returns the input unchanged.132 *133 * Preserves all surrounding whitespace and the formatting of every134 * other field byte-for-byte. Handles both "key in the middle" (eats the135 * trailing comma + newline) and "key at the end" (eats the leading136 * comma + newline).137 */138function removeTopLevelKey(text: string, key: string): string {139 const needle = `"${key}"`;140 const keyIndex = text.indexOf(needle);141 if (keyIndex < 0) return text;142 143 let cursor = keyIndex + needle.length;144 while (cursor < text.length && /\s/.test(text[cursor] ?? '')) cursor += 1;145 if (text[cursor] !== ':') {146 throw new Error(`Expected ':' after ${needle} at ${cursor}`);147 }148 cursor += 1;149 while (cursor < text.length && /\s/.test(text[cursor] ?? '')) cursor += 1;150 151 const valueEnd = jsonValueEnd(text, cursor);152 153 let removeStart = keyIndex;154 let removeEnd = valueEnd;155 156 if (text[removeEnd] === ',') {157 removeEnd += 1;158 if (text[removeEnd] === '\n') removeEnd += 1;159 let lineStart = removeStart;160 while (lineStart > 0 && text[lineStart - 1] !== '\n') lineStart -= 1;161 if (text.slice(lineStart, removeStart).trim() === '') removeStart = lineStart;162 } else {163 let back = removeStart - 1;164 while (back > 0 && /[ \t]/.test(text[back] ?? '')) back -= 1;165 if (text[back] === '\n') {166 let prev = back - 1;167 while (prev > 0 && /[ \t]/.test(text[prev] ?? '')) prev -= 1;168 if (text[prev] === ',') {169 removeStart = prev;170 if (text[removeEnd] === '\n') removeEnd += 1;171 }172 }173 }174 175 return text.slice(0, removeStart) + text.slice(removeEnd);176}177 178async function processManifest(path: string): Promise<Result> {179 const raw = await readFile(path, 'utf-8');180 const data: Record<string, unknown> = JSON.parse(raw);181 const removed = REMOVED_KEYS.filter((key) => key in data);182 if (removed.length === 0) return { path, status: 'already-clean', removed: [] };183 184 let stripped = raw;185 for (const key of removed) stripped = removeTopLevelKey(stripped, key);186 187 // Sanity: stripped output must still be valid JSON and must agree on188 // every field except the two we removed.189 const reparsed: Record<string, unknown> = JSON.parse(stripped);190 for (const key of removed) {191 if (key in reparsed) {192 throw new Error(`Internal: ${key} survived strip in ${path}`);193 }194 }195 196 if (!dryRun) await writeFile(path, stripped, 'utf-8');197 return { path, status: dryRun ? 'needs-fix' : 'fixed', removed };198}199 200const manifests = await findManifests(projectRoot);201if (manifests.length === 0) {202 console.error(`No migration.json files found under ${projectRoot}.`);203 process.exit(1);204}205 206let changed = 0;207let alreadyClean = 0;208for (const path of manifests) {209 const result = await processManifest(path);210 const rel = path.slice(projectRoot.length + 1);211 if (result.status === 'already-clean') {212 alreadyClean += 1;213 console.log(`OK ${rel} (already clean)`);214 } else {215 changed += 1;216 const verb = dryRun ? 'WOULD FIX' : 'FIXED';217 console.log(`${verb} ${rel} (removed: ${result.removed.join(', ')})`);218 }219}220 221console.log();222console.log(223 `${manifests.length} manifest(s) scanned: ${changed} ${dryRun ? 'needing fix' : 'fixed'}, ${alreadyClean} already clean.`,224);225 226if (dryRun && changed > 0) process.exit(1);227