#!/usr/bin/env bun /** * Convert `export * from "./X"` lines in feature barrels to explicit * `export { a, b, type C, ... } from "./X"`. * * Usage: bun scripts/convert-barrels.ts [ ...] * * Reads each barrel, resolves its `export *` targets relative to its dir, * extracts top-level exports from each target via the TypeScript compiler * API, and rewrites the barrel with explicit named re-exports. * * Skipped entirely: barrels that contain anything other than `export *` or * empty lines. Skipped per-target: a target file that fails to parse or * exposes non-top-level export forms we can't safely round-trip. * * Run biome --write afterwards to canonicalise import order. */ import * as fs from "node:fs"; import * as path from "node:path"; import * as ts from "typescript"; interface NamedRe { readonly name: string; // exported identifier name readonly isType: boolean; // true if the export is type-only } function readFile(p: string): string { return fs.readFileSync(p, "utf8"); } function topLevelExports( filePath: string, visiting: ReadonlySet = new Set() ): NamedRe[] | undefined { if (visiting.has(filePath)) { // Cycle in `export *` chain — bail out so we don't loop forever. return undefined; } let source: string; try { source = readFile(filePath); } catch { return undefined; } const sf = ts.createSourceFile( filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS ); const out: NamedRe[] = []; const seen = new Set(); const push = (name: string, isType: boolean): void => { if (seen.has(name)) return; seen.add(name); out.push({ name, isType }); }; const fileDir = path.dirname(filePath); const nextVisiting = new Set(visiting); nextVisiting.add(filePath); for (const stmt of sf.statements) { const mods = ts.canHaveModifiers(stmt) ? ts.getModifiers(stmt) : undefined; const hasExport = mods?.some( (m) => m.kind === ts.SyntaxKind.ExportKeyword ); // Declarations with `export` modifier: const/let/var, function, class, // interface, type alias, enum, namespace. if (hasExport === true) { if (ts.isFunctionDeclaration(stmt) && stmt.name !== undefined) { push(stmt.name.text, false); continue; } if (ts.isClassDeclaration(stmt) && stmt.name !== undefined) { push(stmt.name.text, false); continue; } if (ts.isInterfaceDeclaration(stmt)) { push(stmt.name.text, true); continue; } if (ts.isTypeAliasDeclaration(stmt)) { push(stmt.name.text, true); continue; } if (ts.isEnumDeclaration(stmt)) { push(stmt.name.text, false); continue; } if (ts.isModuleDeclaration(stmt) && stmt.name !== undefined) { push( ts.isIdentifier(stmt.name) ? stmt.name.text : stmt.name.text, false ); continue; } if (ts.isVariableStatement(stmt)) { for (const d of stmt.declarationList.declarations) { if (ts.isIdentifier(d.name)) push(d.name.text, false); // Destructuring patterns intentionally skipped. } continue; } } // export { a, type B, c as d } from "..." or `export { ... };` if (ts.isExportDeclaration(stmt)) { const isTypeOnlyDecl = stmt.isTypeOnly === true; if (stmt.exportClause === undefined) { // `export * from "..."` — recurse into the target so we can // see what symbols it ultimately exports. This handles // generated selector chains: `feature/index.ts` → // `feature/feature.select/feature.select.ts` → `impl/...` if ( stmt.moduleSpecifier === undefined || !ts.isStringLiteral(stmt.moduleSpecifier) ) { return undefined; } const spec = stmt.moduleSpecifier.text; const resolved = resolveTarget(fileDir, spec); if (resolved === undefined) return undefined; const inner = topLevelExports(resolved.absPath, nextVisiting); if (inner === undefined) return undefined; for (const n of inner) push(n.name, n.isType || isTypeOnlyDecl); continue; } if (ts.isNamespaceExport(stmt.exportClause)) { // `export * as ns from "..."` — single named slot. push(stmt.exportClause.name.text, false); continue; } for (const spec of stmt.exportClause.elements) { const name = spec.name.text; push(name, isTypeOnlyDecl || spec.isTypeOnly === true); } } } return out; } interface BarrelLine { readonly raw: string; readonly target?: string; readonly typeOnly?: boolean; } function parseBarrel(barrelPath: string): BarrelLine[] | undefined { // Use the TypeScript parser to walk top-level statements rather than // line-regex matching — this handles multi-line `export { a, b, c } // from "..."` blocks, JSDoc comments, and the mixed-content barrels // (`export * from X` interleaved with `export { Y } from Z`) that // appear naturally in larger packages. const text = readFile(barrelPath); const sf = ts.createSourceFile( barrelPath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS ); const out: BarrelLine[] = []; for (const stmt of sf.statements) { if ( ts.isExportDeclaration(stmt) && stmt.exportClause === undefined && stmt.moduleSpecifier !== undefined && ts.isStringLiteral(stmt.moduleSpecifier) ) { // `export * from "X"` or `export type * from "X"` — convertible. out.push({ raw: text.slice(stmt.pos, stmt.end).trimStart(), target: stmt.moduleSpecifier.text, typeOnly: stmt.isTypeOnly === true, }); continue; } // Anything else: pass through verbatim. We never rewrite explicit // export lines, named imports, declarations, etc. out.push({ raw: text.slice(stmt.pos, stmt.end).trimStart() }); } return out; } function resolveTarget( barrelDir: string, spec: string ): { absPath: string; importSpec: string } | undefined { // Strip `.js` if present — TS sources live alongside as `.ts`. const relSpec = spec.endsWith(".js") ? spec.slice(0, -3) : spec; const candidates = [ path.resolve(barrelDir, `${relSpec}.ts`), path.resolve(barrelDir, `${relSpec}.tsx`), path.resolve(barrelDir, relSpec, "index.ts"), ]; for (const c of candidates) { if (fs.existsSync(c)) { return { absPath: c, importSpec: spec }; } } return undefined; } function renderExportLine( importSpec: string, names: NamedRe[], typeOnly = false ): string { if (typeOnly) { const parts = names.map((n) => n.name); return `export type { ${parts.join(", ")} } from "${importSpec}";`; } const parts = names.map((n) => (n.isType ? `type ${n.name}` : n.name)); return `export { ${parts.join(", ")} } from "${importSpec}";`; } interface ConvertOutcome { readonly path: string; readonly status: "converted" | "skipped" | "no-violation" | "failed"; readonly reason?: string; } function convertBarrel(barrelPath: string): ConvertOutcome { const lines = parseBarrel(barrelPath); if (lines === undefined) { return { path: barrelPath, status: "skipped", reason: "barrel has non-`export *` content", }; } const violations = lines.filter((l) => l.target !== undefined); if (violations.length === 0) { return { path: barrelPath, status: "no-violation" }; } const barrelDir = path.dirname(barrelPath); // Dedupe across the barrel: first occurrence wins. This matches the // semantics of `export *` collapsing identical re-exports across two // targets (e.g. when `feature.ts` re-exports types from `feature.types.ts` // and the barrel forwards both files). const claimed = new Set(); const newLines: string[] = []; for (const line of lines) { if (line.target === undefined) { newLines.push(line.raw); continue; } const resolved = resolveTarget(barrelDir, line.target); if (resolved === undefined) { return { path: barrelPath, status: "failed", reason: `cannot resolve "${line.target}"`, }; } const exports = topLevelExports(resolved.absPath); if (exports === undefined) { return { path: barrelPath, status: "failed", reason: `cannot enumerate exports of "${line.target}" (target itself contains re-export *)`, }; } const fresh = exports.filter((e) => { if (claimed.has(e.name)) return false; claimed.add(e.name); return true; }); if (fresh.length === 0) { // Every name from this target was already claimed by an earlier // target — drop the line entirely; it would be a no-op duplicate. continue; } newLines.push( renderExportLine(resolved.importSpec, fresh, line.typeOnly === true) ); } if (claimed.size === 0) { return { path: barrelPath, status: "failed", reason: "no top-level exports found across any target", }; } fs.writeFileSync( barrelPath, `${newLines.join("\n").replace(/\n+$/, "")}\n` ); return { path: barrelPath, status: "converted" }; } function main(): void { const args = process.argv.slice(2); if (args.length === 0) { console.error( "usage: bun scripts/convert-barrels.ts [ ...]" ); process.exit(2); } let converted = 0; let skipped = 0; let failed = 0; let none = 0; for (const a of args) { const out = convertBarrel(path.resolve(a)); switch (out.status) { case "converted": converted++; console.log(`✓ ${out.path}`); break; case "skipped": skipped++; console.log(`- ${out.path} (${out.reason})`); break; case "no-violation": none++; break; case "failed": failed++; console.error(`✗ ${out.path} (${out.reason})`); break; } } console.log( `\nDone: ${converted} converted, ${skipped} skipped, ${none} no-violation, ${failed} failed` ); if (failed > 0) process.exit(1); } main();