362 lines
11 KiB
TypeScript
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();
|