#!/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//README.md)`. * Wrapped in ``…`` * 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////package.json (depth 4) // Fallback: packages///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();