Files
suaveplan/scripts/convert-barrels.ts
2026-05-27 23:47:53 +01:00

341 lines
11 KiB
TypeScript

#!/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 <barrel-path> [<barrel-path> ...]
*
* 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<string> = 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<string>();
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<string>();
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 <barrel> [<barrel> ...]"
);
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();