#!/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/.json` entry SHALL list at least one * `files[]` path; every listed path SHALL resolve to an * existing file under `src/components//` (or the * configured `componentsDir`). * 2. Every public component directory under `src/components/` * that ships a `.tsx` or `index.{ts,tsx}` SHALL have a * corresponding `registry/r/.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; readonly registryDependencies?: ReadonlyArray; readonly dependencies?: ReadonlyArray; } 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 { 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 { 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 { try { const raw = await readFile(entryPath, "utf8"); const parsed = JSON.parse(raw) as Partial; 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; readonly componentDirs: ReadonlyArray; readonly componentsDir: string; } export async function audit(opts: AuditOptions): Promise { const diagnostics: Diagnostic[] = []; const entryNames = new Set(); 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 { 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()); }