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

155 lines
5.2 KiB
TypeScript

#!/usr/bin/env bun
/**
* Regenerates src/<feature>/index.ts barrels.
*/
import {
existsSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
const PACKAGES_DIR = "packages";
interface NamedExports {
readonly values: readonly string[];
readonly types: readonly string[];
readonly hasDefault: boolean;
}
function parseExports(implPath: string): NamedExports {
const src = readFileSync(implPath, "utf8");
const values = new Set<string>();
const types = new Set<string>();
let hasDefault = false;
const cleaned = src
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\/\/.*$/gm, "");
// Captures: 1 = "default" or "", 2 = kind, 3 = name.
const re =
/\bexport\s+(default\s+)?(?:abstract\s+)?(?:declare\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 isDefault = Boolean(match[1]);
const kind = match[2] as string;
const name = match[3] as string;
if (isDefault) {
hasDefault = true;
// `export default class X {}` also creates a named binding X locally,
// but the named export only happens through the default slot.
continue;
}
if (kind === "type" || kind === "interface") types.add(name);
else values.add(name);
}
// Detect bare `export default Identifier;`.
const bareDefaultRe = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;/g;
while ((match = bareDefaultRe.exec(cleaned)) !== null) {
hasDefault = true;
}
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);
}
}
values.delete("default");
return {
values: [...values].sort(),
types: [...types].sort(),
hasDefault,
};
}
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 || exports.hasDefault) {
const names = [...exports.values];
if (exports.hasDefault) names.push("default");
lines.push("export {");
for (const v of names) 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 listPackages(): 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;
if (existsSync(join(pkgDir, "src"))) out.push(pkgDir);
}
}
}
return out;
}
function main(): void {
let touched = 0;
for (const pkgDir of listPackages()) {
const srcDir = join(pkgDir, "src");
for (const entry of readdirSync(srcDir)) {
const featureDir = join(srcDir, entry);
try {
if (!statSync(featureDir).isDirectory()) continue;
} catch {
continue;
}
const impl = join(featureDir, `${entry}.ts`);
const indexFile = join(featureDir, "index.ts");
if (!existsSync(impl) || !existsSync(indexFile)) continue;
const current = readFileSync(indexFile, "utf8");
const onlyTouchesImpl = current
.split("\n")
.filter((l) => l.includes("from "))
.every((l) => l.includes(`from "./${entry}.js"`));
if (!onlyTouchesImpl) continue;
const exports = parseExports(impl);
const barrel = buildBarrel(entry, exports);
if (barrel !== current) {
writeFileSync(indexFile, barrel);
console.log(`REGEN ${indexFile}`);
touched++;
}
}
}
console.log(`\nRegenerated ${touched} barrels.`);
}
main();