Files
suaveplan/scripts/folder-reorg/realign-to-categories.ts
2026-05-27 23:47:53 +01:00

410 lines
13 KiB
TypeScript

#!/usr/bin/env bun
/**
* Category realignment migration script.
*
* Reads scripts/folder-reorg/assignment.yml and moves every package currently
* under packages/<old-category>/<pkg>/ to packages/<new-category>/<pkg>/
* via `git mv`.
*
* Run modes:
* bun scripts/folder-reorg/realign-to-categories.ts --dry-run # plan only
* bun scripts/folder-reorg/realign-to-categories.ts # execute
* bun scripts/folder-reorg/realign-to-categories.ts --check # validate manifest only
*
* Exits non-zero on:
* - duplicate package across categories
* - on-disk package not present in any category's list
* - destination path already exists
* - git working tree dirty (execute mode only)
*/
import { execFileSync } from "node:child_process";
import {
cpSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmdirSync,
rmSync,
statSync,
} from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parse as parseYaml } from "yaml";
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, "..", "..");
const assignmentPath = join(here, "assignment.yml");
const packagesRoot = join(repoRoot, "packages");
type Mode = "dry-run" | "execute" | "check";
interface Assignment {
[category: string]: string[];
}
interface PlannedMove {
pkg: string;
category: string;
from: string; // absolute
to: string; // absolute
}
const log = (...args: unknown[]) => console.log(...args);
const warn = (...args: unknown[]) => console.warn("⚠ ", ...args);
const fail = (msg: string): never => {
console.error(`✗ ${msg}`);
process.exit(1);
};
function git(args: string[], cwd: string = repoRoot): string {
return execFileSync("git", args, { cwd, encoding: "utf8" });
}
function parseMode(argv: string[]): Mode {
if (argv.includes("--dry-run")) return "dry-run";
if (argv.includes("--check")) return "check";
return "execute";
}
function loadAssignment(): Assignment {
if (!existsSync(assignmentPath)) {
fail(`Assignment manifest not found: ${assignmentPath}`);
}
const raw = readFileSync(assignmentPath, "utf8");
const parsed = parseYaml(raw) as unknown;
if (typeof parsed !== "object" || parsed === null) {
fail("assignment.yml did not parse to an object");
}
const a = parsed as Record<string, unknown>;
for (const [category, pkgs] of Object.entries(a)) {
if (!Array.isArray(pkgs)) fail(`Category "${category}" is not a list`);
for (const p of pkgs) {
if (typeof p !== "string")
fail(`Category "${category}" has non-string entry`);
}
}
return a as Assignment;
}
interface ParsedAssignment {
pkgToCategory: Map<string, string>;
retiredPackages: Set<string>;
retiredCategories: Set<string>;
}
function validateUniqueness(assignment: Assignment): ParsedAssignment {
const pkgToCategory = new Map<string, string>();
const retiredPackages = new Set<string>(assignment.__retired__ ?? []);
const retiredCategories = new Set<string>(
Array.isArray(assignment.__retired_categories__)
? assignment.__retired_categories__
: []
);
const duplicates: string[] = [];
for (const [category, pkgs] of Object.entries(assignment)) {
if (category === "__retired__" || category === "__retired_categories__")
continue;
for (const pkg of pkgs) {
if (retiredPackages.has(pkg)) {
duplicates.push(
` ${pkg}: listed in category "${category}" AND in __retired__`
);
continue;
}
const prior = pkgToCategory.get(pkg);
if (prior !== undefined) {
duplicates.push(` ${pkg}: in "${prior}" AND "${category}"`);
} else {
pkgToCategory.set(pkg, category);
}
}
}
if (duplicates.length > 0) {
fail(`Duplicate package assignments found:\n${duplicates.join("\n")}`);
}
return { pkgToCategory, retiredPackages, retiredCategories };
}
function discoverOnDiskPackages(): Map<string, string> {
const result = new Map<string, string>();
if (!existsSync(packagesRoot) || !statSync(packagesRoot).isDirectory()) {
fail(`Packages root does not exist: ${packagesRoot}`);
}
for (const category of readdirSync(packagesRoot)) {
const categoryPath = join(packagesRoot, category);
if (!statSync(categoryPath).isDirectory()) continue;
for (const pkg of readdirSync(categoryPath)) {
const pkgPath = join(categoryPath, pkg);
if (!statSync(pkgPath).isDirectory()) continue;
if (!existsSync(join(pkgPath, "package.json"))) continue;
if (result.has(pkg)) {
fail(
`On-disk duplicate package directory: "${pkg}" exists in both ${result.get(pkg)} and ${pkgPath}`
);
}
result.set(pkg, pkgPath);
}
}
return result;
}
function planMoves(
parsed: ParsedAssignment,
onDisk: Map<string, string>
): { moves: PlannedMove[]; orphans: string[]; retiredOnDisk: string[] } {
const moves: PlannedMove[] = [];
const orphans: string[] = [];
const retiredOnDisk: string[] = [];
for (const [pkg, fromAbs] of onDisk) {
if (parsed.retiredPackages.has(pkg)) {
retiredOnDisk.push(pkg);
continue;
}
const category = parsed.pkgToCategory.get(pkg);
if (category === undefined) {
orphans.push(pkg);
continue;
}
const toAbs = join(packagesRoot, category, pkg);
if (toAbs !== fromAbs) {
moves.push({ pkg, category, from: fromAbs, to: toAbs });
}
}
moves.sort((a, b) =>
a.category === b.category
? a.pkg.localeCompare(b.pkg)
: a.category.localeCompare(b.category)
);
return { moves, orphans, retiredOnDisk };
}
function checkGitClean(): void {
const out = git(["status", "--porcelain"]);
if (out.trim().length > 0) {
fail(
`Working tree is dirty — commit or stash before migrating.\n\n${out}\n` +
"If you really want to proceed with uncommitted changes, run with --dry-run first to verify the plan."
);
}
}
function ensureDestinationsClear(moves: PlannedMove[]): void {
const collisions: string[] = [];
for (const m of moves) {
if (existsSync(m.to)) collisions.push(` ${relative(repoRoot, m.to)}`);
}
if (collisions.length > 0) {
fail(`Destination paths already exist:\n${collisions.join("\n")}`);
}
}
const COPY_SKIP = new Set([
"node_modules",
"dist",
".turbo",
"coverage",
".bd_modules",
".tsbuildinfo",
]);
function copyTrackedOnly(fromAbs: string, toAbs: string): void {
cpSync(fromAbs, toAbs, {
recursive: true,
filter: (src) => {
const base = src.split("/").pop() ?? "";
if (COPY_SKIP.has(base)) return false;
if (base.endsWith(".tsbuildinfo")) return false;
return true;
},
});
}
function moveOne(fromRel: string, toRel: string): "git-mv" | "cp-rm" {
try {
git(["mv", fromRel, toRel]);
return "git-mv";
} catch (err) {
const msg = (err as Error).message;
if (!msg.includes("Permission denied")) throw err;
}
const fromAbs = join(repoRoot, fromRel);
const toAbs = join(repoRoot, toRel);
copyTrackedOnly(fromAbs, toAbs);
rmSync(fromAbs, { recursive: true, force: true });
git(["add", toRel]);
git(["rm", "-r", "--quiet", fromRel]);
return "cp-rm";
}
function executeMoves(moves: PlannedMove[]): void {
const seenCategoryDirs = new Set<string>();
let cpRmCount = 0;
for (const m of moves) {
const categoryDir = join(packagesRoot, m.category);
if (!seenCategoryDirs.has(categoryDir)) {
mkdirSync(categoryDir, { recursive: true });
seenCategoryDirs.add(categoryDir);
}
const fromRel = relative(repoRoot, m.from);
const toRel = relative(repoRoot, m.to);
const method = moveOne(fromRel, toRel);
const tag = method === "cp-rm" ? " cp+rm " : " git mv ";
log(`${tag}${fromRel} → ${toRel}`);
if (method === "cp-rm") cpRmCount += 1;
}
if (cpRmCount > 0) {
log("");
log(
`(${cpRmCount}/${moves.length} moves used cp+rm fallback due to WSL2 rename block.)`
);
}
}
function pruneEmptyDir(absPath: string): boolean {
if (!existsSync(absPath) || !statSync(absPath).isDirectory()) return false;
const entries = readdirSync(absPath);
let allRemoved = true;
for (const entry of entries) {
const child = join(absPath, entry);
if (statSync(child).isDirectory()) {
if (!pruneEmptyDir(child)) allRemoved = false;
} else {
allRemoved = false;
}
}
if (allRemoved && readdirSync(absPath).length === 0) {
rmdirSync(absPath);
return true;
}
return false;
}
function pruneRetiredCategories(retiredCategories: Set<string>): void {
for (const category of retiredCategories) {
const categoryPath = join(packagesRoot, category);
if (pruneEmptyDir(categoryPath)) {
log(` rmdir packages/${category}/`);
}
}
}
function summarise(
moves: PlannedMove[],
orphans: string[],
retiredOnDisk: string[],
assignment: Assignment,
onDisk: Map<string, string>,
mode: Mode
): void {
const categoryCounts = new Map<string, number>();
for (const m of moves)
categoryCounts.set(
m.category,
(categoryCounts.get(m.category) ?? 0) + 1
);
const categoryKeys = Object.keys(assignment).filter(
(k) => k !== "__retired__"
);
const totalAssigned = categoryKeys.reduce(
(n, k) => n + (assignment[k]?.length ?? 0),
0
);
log("");
log(`Mode: ${mode}`);
log(`Categories defined: ${categoryKeys.length}`);
log(`Packages assigned: ${totalAssigned}`);
log(`Packages retired: ${assignment.__retired__?.length ?? 0}`);
log(
`Categories retired: ${assignment.__retired_categories__?.length ?? 0}`
);
log(`Packages on disk: ${onDisk.size}`);
log(`Packages to migrate: ${moves.length}`);
log(
`Packages already correct: ${onDisk.size - moves.length - orphans.length - retiredOnDisk.length}`
);
log(`Packages on disk (retired, skipped): ${retiredOnDisk.length}`);
log(`Packages future-only: ${totalAssigned - onDisk.size}`);
log("");
log("Per-category migration counts:");
for (const [category, count] of [...categoryCounts.entries()].sort()) {
log(` ${category.padEnd(24)} ${count}`);
}
if (retiredOnDisk.length > 0) {
log("");
log("Retired packages on disk (NOT migrated; remove via tasks.md §8):");
for (const p of retiredOnDisk)
log(` ${p} (${relative(repoRoot, onDisk.get(p) ?? "")})`);
}
if (orphans.length > 0) {
log("");
warn("On-disk packages not assigned to any category:");
for (const p of orphans) warn(` ${p}`);
fail(
"Refusing to proceed — every on-disk package must appear in assignment.yml (or in __retired__)."
);
}
}
function main(): void {
const mode = parseMode(process.argv.slice(2));
log(`folder-reorg/realign-to-categories.ts — mode: ${mode}`);
const assignment = loadAssignment();
const parsed = validateUniqueness(assignment);
if (mode === "check") {
const categoryCount = Object.keys(assignment).filter(
(k) => k !== "__retired__" && k !== "__retired_categories__"
).length;
log(
`✓ Manifest valid: ${parsed.pkgToCategory.size} unique packages across ${categoryCount} categories, ${parsed.retiredPackages.size} retired.`
);
return;
}
const onDisk = discoverOnDiskPackages();
const { moves, orphans, retiredOnDisk } = planMoves(parsed, onDisk);
summarise(moves, orphans, retiredOnDisk, assignment, onDisk, mode);
if (mode === "dry-run") {
log("");
log("Planned moves (first 10):");
for (const m of moves.slice(0, 10)) {
log(
` ${relative(repoRoot, m.from)} → ${relative(repoRoot, m.to)}`
);
}
if (moves.length > 10) log(` ...and ${moves.length - 10} more`);
log("");
log("✓ Dry run complete. Re-run without --dry-run to execute.");
return;
}
checkGitClean();
ensureDestinationsClear(moves);
log("");
log("Executing moves:");
executeMoves(moves);
pruneRetiredCategories(parsed.retiredCategories);
log("");
log(`✓ Migrated ${moves.length} packages.`);
log("");
log("Next steps:");
log(
' 1. Run: rm -rf node_modules bun.lockb && find . -type d -name "node_modules" -exec rm -rf {} + && bun install --force'
);
log(" 2. Run: bunx turbo lint typecheck test");
log(" 3. Run: bun scripts/folder-reorg/generate-manifest.ts");
log(
" 4. Commit: chore(structure): realign packages to FOLDERS.md categories"
);
}
main();