Initial Commit
This commit is contained in:
340
scripts/convert-barrels.ts
Normal file
340
scripts/convert-barrels.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
#!/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();
|
||||
Reference in New Issue
Block a user