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