244 lines
8.2 KiB
TypeScript
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());
|
|
}
|