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