Files
suaveplan/scripts/mark-migrated.ts
2026-05-27 23:47:53 +01:00

371 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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/<category>/<plugin>/<pkg>/`)
* unless overridden by `--section`.
* --section <name> Explicit section under which `--add` inserts new
* rows. Must match an existing `### \`<name>\`` 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/<name>` | <status> | <description> |
*/
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<Map<string, PackageMeta>> {
const pkgDirs = await discoverPackageDirs();
const index = new Map<string, PackageMeta>();
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 <pkg> in the canonical 3-level
// layout: `packages/<category>/<plugin>/<pkg>/`.
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 <name> 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<string>,
options: MarkOptions,
metaIndex: ReadonlyMap<string, PackageMeta>
): { content: string; result: MarkResult } {
const lines = statusMd.split("\n");
const flipped: string[] = [];
const alreadyMigrated: string[] = [];
const added: string[] = [];
const seen = new Set<string>();
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<string, PackageMeta[]>();
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<string>;
add: boolean;
section: string | undefined;
}
function parseArgs(argv: readonly string[]): ParsedArgs {
const targets = new Set<string>();
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<void> {
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 <name>] <pkg> [<pkg> ...]"
);
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<string, PackageMeta>();
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();