371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
#!/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();
|