Files
suaveplan/scripts/lint/migrate-feature-layout.ts
2026-05-27 23:47:53 +01:00

331 lines
11 KiB
TypeScript

#!/usr/bin/env bun
/**
* Migrates flat-at-src-root <feature>.{ts,test.ts,md} into
* src/<feature>/<feature>.{ts,test.ts,md} + src/<feature>/index.ts barrel.
*
* For each violation flagged by `scripts/lint/check-feature-layout.ts`:
* 1. mkdir src/<feature>/
* 2. git mv each flat file into the folder
* 3. Parse the impl file for top-level named exports + type exports
* 4. Write src/<feature>/index.ts with explicit named re-exports
* (rule 10 — no export *)
* 5. Rewrite every sibling `./<feature>.js` / `../<feature>.js` import
* in the same package's src/ tree to point at `./<feature>/<feature>.js`
* / `../<feature>/<feature>.js`. If the import target is the package
* root (src/index.ts), use the new sub-barrel `./<feature>/index.js`.
*
* Skips already-fixed violations. Idempotent.
*
* Usage:
* bun scripts/lint/migrate-feature-layout.ts [--dry-run] [--package <pkg-dir>]
*/
import { execFileSync } from "node:child_process";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { dirname, join, relative } from "node:path";
const ROOT_FLAT_EXEMPT = new Set(["index.ts"]);
const PACKAGES_DIR = "packages";
interface Violation {
readonly pkgDir: string;
readonly srcDir: string;
readonly feature: string;
readonly impl: string;
readonly test: string;
readonly doc: string | null;
}
interface NamedExports {
readonly values: readonly string[];
readonly types: readonly string[];
}
function listPackagesWithSrc(): readonly string[] {
const out: string[] = [];
for (const cat of readdirSync(PACKAGES_DIR)) {
const catDir = join(PACKAGES_DIR, cat);
if (!statSync(catDir).isDirectory()) continue;
for (const plugin of readdirSync(catDir)) {
const pluginDir = join(catDir, plugin);
if (!statSync(pluginDir).isDirectory()) continue;
for (const pkg of readdirSync(pluginDir)) {
const pkgDir = join(pluginDir, pkg);
if (!statSync(pkgDir).isDirectory()) continue;
const srcDir = join(pkgDir, "src");
try {
if (statSync(srcDir).isDirectory()) out.push(pkgDir);
} catch {
/* skip */
}
}
}
}
return out;
}
function findViolations(pkgDir: string): Violation[] {
const srcDir = join(pkgDir, "src");
const entries = readdirSync(srcDir);
const featuresFound = new Map<string, string[]>();
for (const name of entries) {
const full = join(srcDir, name);
if (statSync(full).isDirectory()) continue;
if (ROOT_FLAT_EXEMPT.has(name)) continue;
const match = name.match(/^(.+?)(\.test|\.types|\.md|)\.(ts|tsx|md)$/);
if (!match) continue;
const feature = match[1] as string;
const list = featuresFound.get(feature) ?? [];
list.push(name);
featuresFound.set(feature, list);
}
const violations: Violation[] = [];
for (const [feature, files] of featuresFound) {
const hasImpl = files.includes(`${feature}.ts`);
const hasTest = files.includes(`${feature}.test.ts`);
if (!hasImpl || !hasTest) continue;
violations.push({
pkgDir,
srcDir,
feature,
impl: `${feature}.ts`,
test: `${feature}.test.ts`,
doc: files.includes(`${feature}.md`) ? `${feature}.md` : null,
});
}
return violations;
}
function parseExports(implPath: string): NamedExports {
const src = readFileSync(implPath, "utf8");
const values = new Set<string>();
const types = new Set<string>();
const cleaned = src
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\/\/.*$/gm, "");
const re =
/\bexport\s+(?:default\s+)?(?:async\s+)?(const|let|var|function|class|enum|interface|type)\s+([A-Za-z_$][\w$]*)/g;
let match: RegExpExecArray | null;
while ((match = re.exec(cleaned)) !== null) {
const kind = match[1] as string;
const name = match[2] as string;
if (kind === "type" || kind === "interface") types.add(name);
else values.add(name);
}
const listRe = /export\s+(type\s+)?\{([^}]+)\}/g;
while ((match = listRe.exec(cleaned)) !== null) {
const blockIsType = Boolean(match[1]);
const body = match[2] as string;
for (const piece of body.split(",")) {
const trimmed = piece.trim();
if (!trimmed) continue;
const typeM = trimmed.match(/^type\s+(.+)$/);
const inner = typeM ? (typeM[1] as string) : trimmed;
const asM = inner.match(
/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/
);
const name = asM ? (asM[2] as string) : inner;
if (!/^[A-Za-z_$][\w$]*$/.test(name)) continue;
if (blockIsType || typeM) types.add(name);
else values.add(name);
}
}
return {
values: [...values].sort(),
types: [...types].sort(),
};
}
function buildBarrel(feature: string, exports: NamedExports): string {
const lines: string[] = [];
if (exports.types.length > 0) {
lines.push("export type {");
for (const t of exports.types) lines.push(` ${t},`);
lines.push(`} from "./${feature}.js";`);
}
if (exports.values.length > 0) {
lines.push("export {");
for (const v of exports.values) lines.push(` ${v},`);
lines.push(`} from "./${feature}.js";`);
}
if (lines.length === 0) {
lines.push(`export {} from "./${feature}.js";`);
}
lines.push("");
return lines.join("\n");
}
function git(args: readonly string[], cwd?: string): void {
execFileSync("git", args as string[], { cwd, stdio: "pipe" });
}
function walkTs(dir: string): string[] {
const out: string[] = [];
for (const name of readdirSync(dir)) {
const full = join(dir, name);
const s = statSync(full);
if (s.isDirectory()) out.push(...walkTs(full));
else if (
(name.endsWith(".ts") || name.endsWith(".tsx")) &&
!name.endsWith(".d.ts")
)
out.push(full);
}
return out;
}
function rewriteImports(
pkgDir: string,
feature: string,
dryRun: boolean
): number {
const srcDir = join(pkgDir, "src");
const tsFiles = walkTs(srcDir);
let changed = 0;
for (const file of tsFiles) {
const src = readFileSync(file, "utf8");
let updated = src;
const fileDir = dirname(file);
const featureFromHere = `${feature}.js`;
// Root-level imports: `./<feature>.js` from src/<file>.ts
if (fileDir === srcDir) {
const localFrom = `"./${feature}.js"`;
if (updated.includes(localFrom)) {
if (file === join(srcDir, "index.ts")) {
updated = updated
.split(localFrom)
.join(`"./${feature}/index.js"`);
} else {
updated = updated
.split(localFrom)
.join(`"./${feature}/${featureFromHere}"`);
}
}
}
// One-level-deep: `../<feature>.js` from src/<subdir>/<file>.ts
const relFromSrc = relative(srcDir, fileDir);
if (
relFromSrc !== "" &&
!relFromSrc.startsWith("..") &&
!relFromSrc.includes("/")
) {
const parentFrom = `"../${feature}.js"`;
if (updated.includes(parentFrom)) {
updated = updated
.split(parentFrom)
.join(`"../${feature}/${featureFromHere}"`);
}
}
// Two-or-more-level-deep: `../../<feature>.js` etc.
if (relFromSrc !== "" && relFromSrc.includes("/")) {
const depth = relFromSrc.split("/").length;
const prefix = "../".repeat(depth);
const deepFrom = `"${prefix}${feature}.js"`;
if (updated.includes(deepFrom)) {
updated = updated
.split(deepFrom)
.join(`"${prefix}${feature}/${featureFromHere}"`);
}
}
if (updated !== src) {
changed++;
if (!dryRun) writeFileSync(file, updated);
}
}
return changed;
}
function migrate(v: Violation, dryRun: boolean): void {
const newDir = join(v.srcDir, v.feature);
if (existsSync(newDir)) {
console.log(`SKIP ${v.pkgDir} :: ${v.feature} (folder already exists)`);
return;
}
console.log(`MIGRATE ${v.pkgDir} :: ${v.feature}`);
if (!dryRun) mkdirSync(newDir);
const moves: Array<[string, string]> = [
[v.impl, `${v.feature}/${v.impl}`],
[v.test, `${v.feature}/${v.test}`],
];
if (v.doc) moves.push([v.doc, `${v.feature}/${v.doc}`]);
for (const [from, to] of moves) {
if (dryRun) {
console.log(` would git mv ${from} -> ${to}`);
continue;
}
git(["mv", from, to], v.srcDir);
}
const implPath = join(newDir, v.impl);
const exports = dryRun ? { values: [], types: [] } : parseExports(implPath);
const barrel = buildBarrel(v.feature, exports);
if (dryRun) {
console.log(
` would write index.ts (${exports.values.length} values, ${exports.types.length} types)`
);
} else {
writeFileSync(join(newDir, "index.ts"), barrel);
git(["add", "index.ts"], newDir);
}
const rewriteCount = rewriteImports(v.pkgDir, v.feature, dryRun);
console.log(
` rewrote ${rewriteCount} import path${rewriteCount === 1 ? "" : "s"}`
);
}
function main(): void {
const args = process.argv.slice(2);
const dryRun = args.includes("--dry-run");
const pkgFilterIdx = args.indexOf("--package");
const pkgFilter =
pkgFilterIdx >= 0 ? (args[pkgFilterIdx + 1] as string) : null;
const pkgs = listPackagesWithSrc();
const allViolations: Violation[] = [];
for (const pkg of pkgs) {
if (pkgFilter && pkg !== pkgFilter) continue;
allViolations.push(...findViolations(pkg));
}
if (allViolations.length === 0) {
console.log("OK no violations to migrate.");
return;
}
console.log(
`Found ${allViolations.length} violations${dryRun ? " (dry-run)" : ""}.\n`
);
for (const v of allViolations) {
migrate(v, dryRun);
}
console.log(
`\n${dryRun ? "Would migrate" : "Migrated"} ${allViolations.length} violations across ${new Set(allViolations.map((v) => v.pkgDir)).size} packages.`
);
if (!dryRun) {
console.log(
"Next: run `bunx turbo lint typecheck test` per affected package to verify."
);
}
}
main();