Initial Commit
This commit is contained in:
370
scripts/mark-migrated.ts
Normal file
370
scripts/mark-migrated.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
#!/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();
|
||||
Reference in New Issue
Block a user