#!/usr/bin/env bun /** * Migrates flat-at-src-root .{ts,test.ts,md} into * src//.{ts,test.ts,md} + src//index.ts barrel. * * For each violation flagged by `scripts/lint/check-feature-layout.ts`: * 1. mkdir src// * 2. git mv each flat file into the folder * 3. Parse the impl file for top-level named exports + type exports * 4. Write src//index.ts with explicit named re-exports * (rule 10 — no export *) * 5. Rewrite every sibling `./.js` / `../.js` import * in the same package's src/ tree to point at `.//.js` * / `..//.js`. If the import target is the package * root (src/index.ts), use the new sub-barrel `.//index.js`. * * Skips already-fixed violations. Idempotent. * * Usage: * bun scripts/lint/migrate-feature-layout.ts [--dry-run] [--package ] */ 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(); 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(); const types = new Set(); 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: `./.js` from src/.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: `../.js` from src//.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: `../../.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();