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