Files
suaveplan/scripts/generate-root-readme.ts
2026-05-27 23:47:53 +01:00

207 lines
6.6 KiB
TypeScript
Raw Permalink 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
/**
* generate-root-readme — rebuild `README.md` at the repo root.
*
* The output has two regions:
*
* 1. **Header.** If `_intro.md` exists at the repo root, its contents
* are dropped in verbatim. Otherwise a small default header is
* generated.
* 2. **Auto-generated category index.** A markdown table whose rows are
* `(category, # packages, link to packages/<category>/README.md)`.
* Wrapped in `<!-- begin:auto-docs -->`…`<!-- end:auto-docs -->`
* markers so future rewrites are surgical.
*
* The per-category READMEs are owned by `generate-category-readmes.ts`;
* the per-package documentation trees are owned by
* `generate-package-module-tree.ts`. This script intentionally stays at
* the top of the doc spine — it does not duplicate package-level detail.
*
* Usage:
* bun scripts/generate-root-readme.ts # rewrite README.md
* bun scripts/generate-root-readme.ts --check # exit 1 if drift
*/
import { type Dirent, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { CONFIG } from "./lib/config.js";
import {
AUTO_MARKER_BEGIN,
AUTO_MARKER_END,
isDirectorySafe,
tryRead,
} from "./lib/doc-tree.js";
const ROOT = CONFIG.paths.root;
const README_PATH = join(ROOT, "README.md");
const INTRO_PATH = join(ROOT, "_intro.md");
const PACKAGES_ROOT = join(ROOT, CONFIG.paths.packagesRoot);
const PKG_PREFIX = "@suaveplan/";
interface CategoryEntry {
name: string;
packageCount: number;
}
function countSuaveplanPackagesAt(dir: string): number {
const pkgJson = join(dir, "package.json");
const raw = tryRead(pkgJson);
if (!raw) return 0;
try {
const pkg = JSON.parse(raw) as { name?: string };
return pkg.name?.startsWith(PKG_PREFIX) ? 1 : 0;
} catch {
return 0;
}
}
function countPackagesInCategory(categoryDir: string): number {
let entries: Dirent[];
try {
entries = readdirSync(categoryDir, { withFileTypes: true });
} catch {
return 0;
}
let n = 0;
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith(".")) continue;
const pluginDir = join(categoryDir, entry.name);
// Canonical: packages/<category>/<plugin>/<pkg>/package.json (depth 4)
// Fallback: packages/<category>/<pkg>/package.json (legacy 2-level)
const direct = countSuaveplanPackagesAt(pluginDir);
if (direct > 0) {
n += direct;
continue;
}
let pkgEntries: Dirent[];
try {
pkgEntries = readdirSync(pluginDir, { withFileTypes: true });
} catch {
continue;
}
for (const pkg of pkgEntries) {
if (!pkg.isDirectory()) continue;
if (pkg.name.startsWith(".")) continue;
n += countSuaveplanPackagesAt(join(pluginDir, pkg.name));
}
}
return n;
}
function collectCategories(): CategoryEntry[] {
if (!isDirectorySafe(PACKAGES_ROOT)) return [];
let entries: Dirent[];
try {
entries = readdirSync(PACKAGES_ROOT, { withFileTypes: true });
} catch {
return [];
}
const out: CategoryEntry[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith(".")) continue;
const count = countPackagesInCategory(join(PACKAGES_ROOT, entry.name));
if (count === 0) continue;
out.push({ name: entry.name, packageCount: count });
}
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}
function renderAutoBlock(categories: readonly CategoryEntry[]): string {
const totalPackages = categories.reduce(
(acc, c) => acc + c.packageCount,
0
);
const rows = categories
.map((c) => {
const link = `[\`${c.name}\`](./packages/${c.name}/README.md)`;
return `| ${link} | ${c.packageCount} |`;
})
.join("\n");
const lines = [
"## Packages by category",
"",
`${totalPackages} \`@suaveplan/*\` packages across ${categories.length} categories. Each row links to the category's own README, which in turn lists every package and its first-paragraph description.`,
"",
"| Category | Packages |",
"|---|---:|",
rows,
];
return lines.join("\n");
}
function defaultHeader(): string {
return [
"# SuavePlan",
"",
"Enterprise-grade monorepo, rebuilt bottom-up on the `genesis` branch. Drop an `_intro.md` at the repo root to replace this default header.",
"",
"## Companion docs",
"",
"- [`STATUS.md`](./STATUS.md) — migration dashboard (✅ migrated / – pending / ❌ unwritten).",
"- [`SUMMARY.md`](./SUMMARY.md) — long-form per-package summaries.",
"- [`MANIFEST.md`](./MANIFEST.md) — dependency graph + tier overlay (auto-generated).",
"- [`MISSING.yml`](./MISSING.yml) — machine-readable list of unbuilt packages.",
"- [`CLAUDE.md`](./CLAUDE.md) — repo rules and AI-agent contract.",
"- [`openspec/`](./openspec/) — capability specs + change proposals + archived changes.",
].join("\n");
}
interface CliOptions {
check: boolean;
}
function parseArgs(argv: readonly string[]): CliOptions {
let check = false;
for (const arg of argv) {
if (arg === "--check") check = true;
else throw new Error(`Unknown flag: ${arg}`);
}
return { check };
}
function main(): void {
let options: CliOptions;
try {
options = parseArgs(process.argv.slice(2));
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
const intro = tryRead(INTRO_PATH);
const header = intro?.trim() ? intro.trim() : defaultHeader();
const categories = collectCategories();
const auto = renderAutoBlock(categories);
const next = `${header}\n\n${AUTO_MARKER_BEGIN}\n${auto}\n${AUTO_MARKER_END}\n`;
let current = "";
try {
current = readFileSync(README_PATH, "utf-8");
} catch {
current = "";
}
if (current === next) {
console.log(
`✅ README.md already up to date (${categories.length} categories).`
);
process.exit(0);
}
if (options.check) {
console.error(
"❌ README.md is stale — run without --check to rewrite."
);
process.exit(1);
}
writeFileSync(README_PATH, next);
console.log(`✏️ Wrote ${README_PATH} (${categories.length} categories).`);
}
main();