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