Files
suaveplan/packages/ui/react/react-ui/scripts/check-registry-eject.ts
2026-05-27 23:47:53 +01:00

244 lines
8.2 KiB
TypeScript

#!/usr/bin/env bun
/**
* `check-registry-eject.ts` — shadcn-eject smoke gate for
* `@suaveplan/react-ui`.
*
* Verifies the shadcn `registry/r/*.json` tree against the package
* source so a downstream consumer running `shadcn add` always finds
* the files the registry promised. Specifically:
*
* 1. Every `registry/r/<name>.json` entry SHALL list at least one
* `files[]` path; every listed path SHALL resolve to an
* existing file under `src/components/<name>/` (or the
* configured `componentsDir`).
* 2. Every public component directory under `src/components/`
* that ships a `<name>.tsx` or `index.{ts,tsx}` SHALL have a
* corresponding `registry/r/<name>.json`. Drift in either
* direction (registry entry without source, or source without
* registry entry) fails the gate.
* 3. Every entry SHALL declare a stable `name` and `type` per the
* shadcn schema and SHALL parse as JSON.
*
* NOT in scope today (deferred until the registry transform itself
* lands as a callable helper — see `build-registry.ts` § "Future
* Wave 3 enhancements"):
*
* - Applying the transform that rewrites `@suaveplan/react-ui`
* imports to consumer-local relative paths.
* - Running `tsc --noEmit` against the transformed tree under a
* synthesized consumer `tsconfig.json` to verify the
* `@suaveplan/react-i18n` import contract survives ejection.
*
* Those gates will land alongside the transform itself; until then,
* `bun run typecheck` over the in-source files is the strongest
* signal we have that an ejected component would compile (the
* registry ships source verbatim today; the transform is a no-op).
*
* Wired into `bunx turbo lint`. Exits non-zero on any diagnostic.
*/
import { existsSync } from "node:fs";
import { readdir, readFile } from "node:fs/promises";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const PACKAGE_ROOT = resolve(
dirname(fileURLToPath(import.meta.url)),
".."
);
const REGISTRY_DIR = join(PACKAGE_ROOT, "registry", "r");
const COMPONENTS_DIR = join(PACKAGE_ROOT, "src", "components");
export interface RegistryFile {
readonly path: string;
readonly type: string;
}
export interface RegistryEntry {
readonly name: string;
readonly type: string;
readonly files: ReadonlyArray<RegistryFile>;
readonly registryDependencies?: ReadonlyArray<string>;
readonly dependencies?: ReadonlyArray<string>;
}
export type DiagnosticKind =
| "MISSING_REGISTRY_ENTRY"
| "MISSING_SOURCE_FILE"
| "MALFORMED_ENTRY"
| "EMPTY_FILES_LIST"
| "ORPHAN_REGISTRY_ENTRY";
export interface Diagnostic {
readonly kind: DiagnosticKind;
readonly message: string;
}
async function listRegistryEntryFiles(): Promise<string[]> {
if (!existsSync(REGISTRY_DIR)) return [];
const entries = await readdir(REGISTRY_DIR, { withFileTypes: true });
return entries
.filter(
(e) =>
e.isFile() &&
e.name.endsWith(".json") &&
e.name !== "index.json"
)
.map((e) => join(REGISTRY_DIR, e.name))
.sort();
}
async function listComponentDirectories(): Promise<string[]> {
if (!existsSync(COMPONENTS_DIR)) return [];
const entries = await readdir(COMPONENTS_DIR, { withFileTypes: true });
const out: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const sub = join(COMPONENTS_DIR, entry.name);
const hasIndex =
existsSync(join(sub, "index.ts")) ||
existsSync(join(sub, "index.tsx")) ||
existsSync(join(sub, `${entry.name}.tsx`));
if (hasIndex) out.push(entry.name);
}
return out.sort();
}
export async function loadEntry(
entryPath: string
): Promise<RegistryEntry | { readonly error: string }> {
try {
const raw = await readFile(entryPath, "utf8");
const parsed = JSON.parse(raw) as Partial<RegistryEntry>;
if (typeof parsed.name !== "string" || parsed.name.length === 0) {
return { error: "missing or empty 'name'" };
}
if (typeof parsed.type !== "string") {
return { error: "missing 'type'" };
}
if (!Array.isArray(parsed.files)) {
return { error: "missing 'files' array" };
}
for (const f of parsed.files) {
if (
typeof f !== "object" ||
f === null ||
typeof (f as RegistryFile).path !== "string"
) {
return { error: "files[] entry missing 'path'" };
}
}
return parsed as RegistryEntry;
} catch (err) {
return { error: (err as Error).message };
}
}
export interface AuditOptions {
readonly registryEntries: ReadonlyArray<string>;
readonly componentDirs: ReadonlyArray<string>;
readonly componentsDir: string;
}
export async function audit(opts: AuditOptions): Promise<Diagnostic[]> {
const diagnostics: Diagnostic[] = [];
const entryNames = new Set<string>();
for (const entryPath of opts.registryEntries) {
const entryRel = relative(PACKAGE_ROOT, entryPath);
const loaded = await loadEntry(entryPath);
if ("error" in loaded) {
diagnostics.push({
kind: "MALFORMED_ENTRY",
message: `${entryRel}: ${loaded.error}`,
});
continue;
}
const entryBase = entryPath.replace(/\.json$/, "").split("/").pop()!;
entryNames.add(entryBase);
if (loaded.files.length === 0) {
diagnostics.push({
kind: "EMPTY_FILES_LIST",
message: `${entryRel}: files[] is empty`,
});
continue;
}
for (const file of loaded.files) {
const sourcePath = join(opts.componentsDir, entryBase, file.path);
if (!existsSync(sourcePath)) {
diagnostics.push({
kind: "MISSING_SOURCE_FILE",
message: `${entryRel}: file '${file.path}' does not exist at ${relative(PACKAGE_ROOT, sourcePath)}`,
});
}
}
}
for (const dir of opts.componentDirs) {
if (!entryNames.has(dir)) {
diagnostics.push({
kind: "MISSING_REGISTRY_ENTRY",
message: `src/components/${dir}/ has no registry entry at registry/r/${dir}.json`,
});
}
}
for (const entryName of entryNames) {
if (!opts.componentDirs.includes(entryName)) {
diagnostics.push({
kind: "ORPHAN_REGISTRY_ENTRY",
message: `registry/r/${entryName}.json has no matching src/components/${entryName}/`,
});
}
}
diagnostics.sort((a, b) => {
if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1;
return a.message < b.message ? -1 : a.message > b.message ? 1 : 0;
});
return diagnostics;
}
async function main(): Promise<number> {
const registryEntries = await listRegistryEntryFiles();
const componentDirs = await listComponentDirectories();
if (registryEntries.length === 0) {
console.error(
`check-registry-eject: no registry entries found under ${relative(PACKAGE_ROOT, REGISTRY_DIR)} — run 'bun scripts/build-registry.ts' first.`
);
return 2;
}
if (componentDirs.length === 0) {
console.error(
`check-registry-eject: no component directories found under ${relative(PACKAGE_ROOT, COMPONENTS_DIR)}`
);
return 2;
}
const diagnostics = await audit({
registryEntries,
componentDirs,
componentsDir: COMPONENTS_DIR,
});
if (diagnostics.length === 0) {
console.log(
`check-registry-eject: clean — ${registryEntries.length} registry entries match ${componentDirs.length} component directories.`
);
return 0;
}
for (const d of diagnostics) {
console.error(`${d.kind} ${d.message}`);
}
console.error(
`check-registry-eject: ${diagnostics.length} diagnostic(s) — see above.`
);
return 1;
}
if (import.meta.main) {
process.exit(await main());
}