Files
suaveplan/scripts/folder-reorg/generate-manifest.ts
2026-05-27 23:47:53 +01:00

362 lines
11 KiB
TypeScript

#!/usr/bin/env bun
/**
* Generates MANIFEST.md at repo root from on-disk packages/<super>/<plugin>/<pkg>/package.json.
*
* Replaces @suaveplan/overview. Sections emitted:
* 1. Plugins table — plugin | package count | total workspace deps
* 2. Packages table — npm name | plugin | tier | version | workspace deps
* 3. Tier overlay — packages grouped by inferred tier
* 4. Dependency graph — DOT block for Graphviz
*
* Usage:
* bun scripts/folder-reorg/generate-manifest.ts # write MANIFEST.md
* bun scripts/folder-reorg/generate-manifest.ts --check # exit 1 if drift
* bun scripts/folder-reorg/generate-manifest.ts --stdout # print only
*
* Tier inference priority (matches openspec/specs/monorepo-structure/spec.md):
* python → r3f → react → browser → server → universal
*/
import {
existsSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, "..", "..");
const manifestPath = join(repoRoot, "MANIFEST.md");
const packagesRoot = join(repoRoot, "packages");
type Tier = "python" | "r3f" | "react" | "browser" | "server" | "universal";
interface PkgJson {
name: string;
version: string;
description?: string;
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
browser?: unknown;
bin?: unknown;
engines?: Record<string, string>;
python?: unknown;
}
interface PkgRecord {
name: string; // @suaveplan/<x>
short: string; // <x>
category: string; // top-level super-category (e.g. foundation, threejs)
plugin: string; // mid-level plugin folder (e.g. core, r3f)
dirName: string;
version: string;
description: string;
tier: Tier;
workspaceDeps: string[]; // sorted, dedup
}
const SERVER_DEP_HINTS = new Set([
"bun:sqlite",
"node:fs",
"node:net",
"node:cluster",
"node:dgram",
"node:tls",
"node:dns",
"node:child_process",
"pg",
"ioredis",
"better-sqlite3",
"@sqlite.org/sqlite-wasm-node",
]);
function readPkgJson(absDir: string): PkgJson | null {
const f = join(absDir, "package.json");
if (!existsSync(f)) return null;
try {
return JSON.parse(readFileSync(f, "utf8")) as PkgJson;
} catch {
return null;
}
}
function inferTier(short: string, p: PkgJson): Tier {
if (p.python !== undefined || short.startsWith("python-")) return "python";
const peerKeys = Object.keys(p.peerDependencies ?? {});
if (
peerKeys.includes("@react-three/fiber") ||
peerKeys.includes("three") ||
short.startsWith("r3f-")
)
return "r3f";
if (peerKeys.includes("react") || short.startsWith("react-"))
return "react";
if (p.browser !== undefined) return "browser";
const enginesKeys = Object.keys(p.engines ?? {});
if (
enginesKeys.length > 0 &&
!enginesKeys.includes("node") &&
!enginesKeys.includes("bun")
)
return "browser";
if (p.bin !== undefined) return "server";
const allDeps = { ...p.dependencies, ...p.devDependencies };
for (const key of Object.keys(allDeps)) {
if (SERVER_DEP_HINTS.has(key)) return "server";
}
return "universal";
}
function collectWorkspaceDeps(p: PkgJson): string[] {
const out = new Set<string>();
for (const map of [
p.dependencies,
p.peerDependencies,
p.optionalDependencies,
]) {
if (!map) continue;
for (const [name, range] of Object.entries(map)) {
if (
typeof range === "string" &&
range.startsWith("workspace:") &&
name.startsWith("@suaveplan/")
) {
out.add(name);
}
}
}
return [...out].sort();
}
function discoverPackages(): PkgRecord[] {
if (!existsSync(packagesRoot)) return [];
const records: PkgRecord[] = [];
for (const category of readdirSync(packagesRoot)) {
const categoryPath = join(packagesRoot, category);
if (!statSync(categoryPath).isDirectory()) continue;
for (const plugin of readdirSync(categoryPath)) {
const pluginPath = join(categoryPath, plugin);
if (!statSync(pluginPath).isDirectory()) continue;
for (const pkgDir of readdirSync(pluginPath)) {
const pkgPath = join(pluginPath, pkgDir);
if (!statSync(pkgPath).isDirectory()) continue;
const pj = readPkgJson(pkgPath);
if (pj === null) continue;
const short = pj.name.replace(/^@suaveplan\//, "");
records.push({
name: pj.name,
short,
category,
plugin,
dirName: pkgDir,
version: pj.version ?? "0.1.0",
description: pj.description ?? "",
tier: inferTier(short, pj),
workspaceDeps: collectWorkspaceDeps(pj),
});
}
}
}
records.sort((a, b) => a.name.localeCompare(b.name));
return records;
}
function truncate(s: string, max: number): string {
if (s.length <= max) return s;
return `${s.slice(0, max - 1).trimEnd()}…`;
}
function emitPluginsSection(records: PkgRecord[]): string {
const counts = new Map<
string,
{ category: string; pkgs: number; deps: number }
>();
for (const r of records) {
const key = `${r.category}/${r.plugin}`;
const cur = counts.get(key) ?? {
category: r.category,
pkgs: 0,
deps: 0,
};
cur.pkgs += 1;
cur.deps += r.workspaceDeps.length;
counts.set(key, cur);
}
const lines: string[] = [];
lines.push("## Plugins");
lines.push("");
lines.push("| Category | Plugin | Packages | Workspace deps |");
lines.push("|---|---|---:|---:|");
for (const [key, c] of [...counts.entries()].sort()) {
const plugin = key.split("/")[1];
lines.push(
`| \`${c.category}\` | \`${plugin}\` | ${c.pkgs} | ${c.deps} |`
);
}
return lines.join("\n");
}
function emitPackagesSection(records: PkgRecord[]): string {
const lines: string[] = [];
lines.push("## Packages");
lines.push("");
lines.push(
"| Package | Category | Plugin | Tier | Version | Deps | Description |"
);
lines.push("|---|---|---|---|---|---:|---|");
for (const r of records) {
lines.push(
`| \`${r.name}\` | \`${r.category}\` | \`${r.plugin}\` | ${r.tier} | ${r.version} | ${r.workspaceDeps.length} | ${truncate(r.description, 80)} |`
);
}
return lines.join("\n");
}
function emitTierOverlaySection(records: PkgRecord[]): string {
const byTier = new Map<Tier, PkgRecord[]>();
const tierOrder: Tier[] = [
"universal",
"browser",
"react",
"r3f",
"server",
"python",
];
for (const t of tierOrder) byTier.set(t, []);
for (const r of records) byTier.get(r.tier)?.push(r);
const lines: string[] = [];
lines.push("## Tier overlay");
lines.push("");
lines.push(
"Tier is inferred per `openspec/specs/monorepo-structure/spec.md` priority order: python → r3f → react → browser → server → universal."
);
lines.push("");
for (const tier of tierOrder) {
const list = byTier.get(tier) ?? [];
if (list.length === 0) continue;
lines.push(`### ${tier} (${list.length})`);
lines.push("");
for (const r of list)
lines.push(`- \`${r.name}\` (\`${r.category}/${r.plugin}\`)`);
lines.push("");
}
return lines.join("\n").trimEnd();
}
function emitDependencyGraphSection(records: PkgRecord[]): string {
const known = new Set(records.map((r) => r.name));
const lines: string[] = [];
lines.push("## Dependency graph");
lines.push("");
lines.push(
"Workspace edges only. Pipe to Graphviz: `awk '/^```dot/,/^```$/' MANIFEST.md | sed '1d;$d' | dot -Tsvg > graph.svg`."
);
lines.push("");
lines.push("```dot");
lines.push("digraph workspace {");
lines.push(" rankdir=LR;");
lines.push(' node [shape=box, fontname="monospace", fontsize=10];');
for (const r of records) {
for (const dep of r.workspaceDeps) {
if (!known.has(dep)) continue;
lines.push(` "${r.name}" -> "${dep}";`);
}
}
lines.push("}");
lines.push("```");
return lines.join("\n");
}
function emitHeader(records: PkgRecord[]): string {
const tierCounts = new Map<Tier, number>();
for (const r of records)
tierCounts.set(r.tier, (tierCounts.get(r.tier) ?? 0) + 1);
const tiers = [...tierCounts.entries()]
.map(([t, c]) => `${t}: ${c}`)
.sort()
.join(", ");
const plugins = new Set(records.map((r) => `${r.category}/${r.plugin}`))
.size;
const categories = new Set(records.map((r) => r.category)).size;
const lines: string[] = [];
lines.push("# Suaveplan Monorepo Manifest");
lines.push("");
lines.push(
"> **Auto-generated by `scripts/folder-reorg/generate-manifest.ts`. Do not edit by hand — run the script after package changes.**"
);
lines.push("");
lines.push(
`Packages: **${records.length}** across **${plugins}** plugins in **${categories}** categories. Tiers: ${tiers || "—"}.`
);
return lines.join("\n");
}
function buildManifest(records: PkgRecord[]): string {
if (records.length === 0) {
return [
"# Suaveplan Monorepo Manifest",
"",
"> **Auto-generated by `scripts/folder-reorg/generate-manifest.ts`.**",
"",
"_No packages discovered under `packages/`. Run the folder reorg migration first._",
"",
].join("\n");
}
const sections = [
emitHeader(records),
emitPluginsSection(records),
emitPackagesSection(records),
emitTierOverlaySection(records),
emitDependencyGraphSection(records),
];
return `${sections.join("\n\n")}\n`;
}
function main(): void {
const argv = process.argv.slice(2);
const checkMode = argv.includes("--check");
const stdoutMode = argv.includes("--stdout");
const records = discoverPackages();
const generated = buildManifest(records);
if (stdoutMode) {
process.stdout.write(generated);
return;
}
if (checkMode) {
const onDisk = existsSync(manifestPath)
? readFileSync(manifestPath, "utf8")
: "";
if (onDisk === generated) {
console.log(
`✓ MANIFEST.md is up to date (${records.length} packages).`
);
process.exit(0);
}
console.error(
"✗ MANIFEST.md is stale. Re-run: bun scripts/folder-reorg/generate-manifest.ts"
);
process.exit(1);
}
writeFileSync(manifestPath, generated);
console.log(
`✓ Wrote ${manifestPath} (${records.length} packages, ${new Set(records.map((r) => `${r.category}/${r.plugin}`)).size} plugins, ${new Set(records.map((r) => r.category)).size} categories).`
);
}
main();