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