#!/usr/bin/env bun /** * Generates MANIFEST.md at repo root from on-disk packages////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; devDependencies?: Record; peerDependencies?: Record; optionalDependencies?: Record; browser?: unknown; bin?: unknown; engines?: Record; python?: unknown; } interface PkgRecord { name: string; // @suaveplan/ short: string; // 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(); 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(); 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(); 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();