#!/usr/bin/env bun /** * mark-migrated — flip STATUS.md rows from `–` / `❌` to `✅` for the given * package names. Optionally adds new ✅ rows for genesis-original packages * that have no existing STATUS.md entry. * * Usage: * bun scripts/mark-migrated.ts cron job-scheduler * bun scripts/mark-migrated.ts @suaveplan/retry @suaveplan/types * bun scripts/mark-migrated.ts --add cron * bun scripts/mark-migrated.ts --add --section primitives cron * * Args may include or omit the `@suaveplan/` prefix. * * Flags: * --add Create ✅ rows for packages not already in STATUS.md. * The description is read from each package's * package.json; the section defaults to the on-disk * plugin folder (`packages////`) * unless overridden by `--section`. * --section Explicit section under which `--add` inserts new * rows. Must match an existing `### \`\`` heading * in STATUS.md. * * Recomputes the count table at the top of STATUS.md after substitutions. * * Exit codes: * 0 — every supplied package was already ✅, flipped to ✅, or added. * 1 — at least one supplied package could not be flipped or added. * 2 — STATUS.md is malformed (missing counts table or required section). */ import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { CONFIG } from "./lib/config.js"; import { discoverPackageDirs } from "./lib/discover-packages.js"; const STATUS_PATH = join(CONFIG.paths.root, "STATUS.md"); const PKG_PREFIX = "@suaveplan/"; interface MarkResult { flipped: string[]; alreadyMigrated: string[]; added: string[]; notFound: string[]; } interface PackageMeta { name: string; // short name without @suaveplan/ description: string; plugin: string; // e.g. "primitives", "control-flow" } function normalize(name: string): string { const trimmed = name.trim(); return trimmed.startsWith(PKG_PREFIX) ? trimmed.slice(PKG_PREFIX.length) : trimmed; } /** * Parse a STATUS.md row, returning `{ name, status }` if it's a package row, * `null` otherwise. Rows look like: * | `@suaveplan/` | | | */ function parseRow(line: string): { name: string; status: string } | null { const match = line.match( /^\|\s*`@suaveplan\/([^`]+)`\s*\|\s*(✅|–|❌)\s*\|/u ); if (!match) return null; return { name: match[1] ?? "", status: match[2] ?? "" }; } function flipRow(line: string): string { return line.replace(/^(\|\s*`@suaveplan\/[^`]+`\s*\|\s*)(–|❌)/u, "$1✅"); } function recomputeCounts(lines: string[]): string[] { let migrated = 0; let pending = 0; let unwritten = 0; for (const line of lines) { const parsed = parseRow(line); if (!parsed) continue; if (parsed.status === "✅") migrated++; else if (parsed.status === "–") pending++; else if (parsed.status === "❌") unwritten++; } const total = migrated + pending + unwritten; const out = [...lines]; let countsTableSeen = false; for (let i = 0; i < out.length; i++) { const line = out[i] ?? ""; if (line.startsWith("## Counts")) { countsTableSeen = true; continue; } if (!countsTableSeen) continue; if (line.startsWith("| ✅ Migrated |")) { out[i] = `| ✅ Migrated | ${migrated} |`; } else if (line.startsWith("| – Yet to be migrated |")) { out[i] = `| – Yet to be migrated | ${pending} |`; } else if (line.startsWith("| ❌ Hasn't been written yet |")) { out[i] = `| ❌ Hasn't been written yet | ${unwritten} |`; } else if (line.startsWith("| **Total tracked** |")) { out[i] = `| **Total tracked** | **${total}** |`; break; } } if (!countsTableSeen) { throw new Error("STATUS.md is missing the '## Counts' section"); } return out; } async function loadPackageMetaIndex(): Promise> { const pkgDirs = await discoverPackageDirs(); const index = new Map(); for (const dir of pkgDirs) { const pkgJsonPath = join(dir, "package.json"); let raw: string; try { raw = readFileSync(pkgJsonPath, "utf-8"); } catch { continue; } let pkg: { name?: string; description?: string }; try { pkg = JSON.parse(raw) as { name?: string; description?: string }; } catch { continue; } if (!pkg.name?.startsWith(PKG_PREFIX)) continue; const shortName = pkg.name.slice(PKG_PREFIX.length); // Plugin folder is the parent of in the canonical 3-level // layout: `packages////`. const segments = dir.split(/[\\/]/); const plugin = segments[segments.length - 2] ?? ""; index.set(shortName, { name: shortName, description: pkg.description ?? "", plugin, }); } return index; } function findSectionInsertIndex( lines: readonly string[], sectionName: string ): number { const sectionHeader = `### \`${sectionName}\``; let start = -1; for (let i = 0; i < lines.length; i++) { if (lines[i] === sectionHeader) { start = i; break; } } if (start === -1) { throw new Error( `STATUS.md has no section "### \`${sectionName}\`" — pass --section to target an existing section` ); } // Insert at the last contiguous table row in this section (before the // next heading or blank gap leading to a heading). let insertAt = start; for (let i = start + 1; i < lines.length; i++) { const line = lines[i] ?? ""; if (line.startsWith("### ") || line.startsWith("## ")) break; if (line.startsWith("| `@suaveplan/")) insertAt = i; } return insertAt + 1; } function buildRow(meta: PackageMeta): string { const desc = meta.description.replace(/\s+/g, " ").trim(); return `| \`@suaveplan/${meta.name}\` | ✅ | ${desc} |`; } interface MarkOptions { add: boolean; section: string | undefined; } function markMigrated( statusMd: string, targets: ReadonlySet, options: MarkOptions, metaIndex: ReadonlyMap ): { content: string; result: MarkResult } { const lines = statusMd.split("\n"); const flipped: string[] = []; const alreadyMigrated: string[] = []; const added: string[] = []; const seen = new Set(); for (let i = 0; i < lines.length; i++) { const parsed = parseRow(lines[i] ?? ""); if (!parsed) continue; if (!targets.has(parsed.name)) continue; seen.add(parsed.name); if (parsed.status === "✅") { alreadyMigrated.push(parsed.name); } else { lines[i] = flipRow(lines[i] ?? ""); flipped.push(parsed.name); } } const stillMissing = [...targets].filter((name) => !seen.has(name)); const notFound: string[] = []; if (options.add) { // Process from last-to-first so insertion indexes stay valid as we mutate. // Group by target section to insert all rows of one section together. const grouped = new Map(); for (const name of stillMissing) { const meta = metaIndex.get(name); if (!meta) { notFound.push(name); continue; } const section = options.section ?? meta.plugin; if (!section) { notFound.push(name); continue; } const bucket = grouped.get(section) ?? []; bucket.push(meta); grouped.set(section, bucket); } for (const [section, metas] of grouped) { let insertAt: number; try { insertAt = findSectionInsertIndex(lines, section); } catch (err) { for (const meta of metas) { notFound.push(meta.name); console.error( err instanceof Error ? err.message : String(err) ); } continue; } // Insert all rows for this section at the same point in order. const rows = metas.map(buildRow); lines.splice(insertAt, 0, ...rows); for (const meta of metas) added.push(meta.name); } } else { notFound.push(...stillMissing); } const recomputed = recomputeCounts(lines); return { content: recomputed.join("\n"), result: { flipped, alreadyMigrated, added, notFound }, }; } function printSummary(result: MarkResult): void { if (result.flipped.length > 0) { console.log(`✅ Flipped to migrated (${result.flipped.length}):`); for (const name of result.flipped) console.log(` - @suaveplan/${name}`); } if (result.added.length > 0) { console.log(`➕ Added new ✅ rows (${result.added.length}):`); for (const name of result.added) console.log(` - @suaveplan/${name}`); } if (result.alreadyMigrated.length > 0) { console.log(`⏭️ Already migrated (${result.alreadyMigrated.length}):`); for (const name of result.alreadyMigrated) console.log(` - @suaveplan/${name}`); } if (result.notFound.length > 0) { console.log(`❌ Not found in STATUS.md (${result.notFound.length}):`); for (const name of result.notFound) console.log(` - @suaveplan/${name}`); if (result.added.length === 0) { console.log( " (pass --add to create new ✅ rows for genesis-original packages)" ); } } } interface ParsedArgs { targets: Set; add: boolean; section: string | undefined; } function parseArgs(argv: readonly string[]): ParsedArgs { const targets = new Set(); let add = false; let section: string | undefined; for (let i = 0; i < argv.length; i++) { const arg = argv[i] ?? ""; if (arg === "--add") { add = true; } else if (arg === "--section") { const next = argv[i + 1]; if (!next || next.startsWith("--")) { throw new Error("--section requires a value"); } section = next; i++; } else if (arg.startsWith("--section=")) { section = arg.slice("--section=".length); } else if (arg.startsWith("--")) { throw new Error(`Unknown flag: ${arg}`); } else { targets.add(normalize(arg)); } } return { targets, add, section }; } async function main(): Promise { let parsed: ParsedArgs; try { parsed = parseArgs(process.argv.slice(2)); } catch (err) { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); } if (parsed.targets.size === 0) { console.error( "Usage: bun scripts/mark-migrated.ts [--add] [--section ] [ ...]" ); console.error("Names may include or omit the `@suaveplan/` prefix."); process.exit(1); } const statusMd = readFileSync(STATUS_PATH, "utf-8"); const metaIndex = parsed.add ? await loadPackageMetaIndex() : new Map(); let next: { content: string; result: MarkResult }; try { next = markMigrated( statusMd, parsed.targets, { add: parsed.add, section: parsed.section }, metaIndex ); } catch (err) { console.error(err instanceof Error ? err.message : String(err)); process.exit(2); } if (next.content !== statusMd) { writeFileSync(STATUS_PATH, next.content); console.log(`✏️ Wrote ${STATUS_PATH}`); } else { console.log("No changes to STATUS.md."); } printSummary(next.result); process.exit(next.result.notFound.length === 0 ? 0 : 1); } void main();