Initial Commit
This commit is contained in:
243
packages/ui/react/react-ui/scripts/check-registry-eject.ts
Normal file
243
packages/ui/react/react-ui/scripts/check-registry-eject.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
#!/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());
|
||||
}
|
||||
Reference in New Issue
Block a user