Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,137 @@
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
build,
buildRegistryIndex,
buildRegistryItem,
parseArgs,
serialize,
validateIndex,
validateItem,
} from "./build-registry.js";
describe("build-registry", () => {
it(
"buildRegistryItem produces a shadcn-compatible item",
{ timeout: 5000 },
() => {
const item = buildRegistryItem("button");
expect(item.name).toBe("@suaveplan/button");
expect(item.type).toBe("registry:ui");
expect(item.files).toEqual([
{ path: "button.tsx", type: "registry:ui" },
]);
expect(validateItem(item)).toBe(true);
}
);
it(
"buildRegistryIndex includes every supplied name",
{ timeout: 5000 },
() => {
const idx = buildRegistryIndex(["button", "card"]);
expect(idx.items).toHaveLength(2);
expect(idx.items[0]?.name).toBe("@suaveplan/button");
expect(validateIndex(idx)).toBe(true);
}
);
it(
"parseArgs picks up --out and --components flags",
{ timeout: 5000 },
() => {
const parsed = parseArgs([
"--out",
"/tmp/out",
"--components",
"/tmp/src/components",
]);
expect(parsed.outDir).toBe("/tmp/out");
expect(parsed.componentsDir).toBe("/tmp/src/components");
}
);
it(
"serialize emits canonical JSON with trailing newline",
{ timeout: 5000 },
() => {
const out = serialize({ a: 1 });
expect(out).toBe('{\n "a": 1\n}\n');
}
);
it(
"build emits an empty index when components dir does not exist",
{ timeout: 10000 },
async () => {
const dir = await mkdtemp(join(tmpdir(), "registry-empty-"));
const componentsDir = join(dir, "src", "components");
const outDir = join(dir, "registry", "r");
const result = await build({ componentsDir, outDir });
expect(result.items).toEqual([]);
const idx = JSON.parse(await readFile(result.indexPath, "utf8"));
expect(idx.items).toEqual([]);
}
);
it(
"build emits one manifest per component when present",
{ timeout: 10000 },
async () => {
const dir = await mkdtemp(join(tmpdir(), "registry-some-"));
const componentsDir = join(dir, "src", "components");
const outDir = join(dir, "registry", "r");
await mkdir(join(componentsDir, "button"), { recursive: true });
await writeFile(
join(componentsDir, "button", "button.tsx"),
"export const Button = () => null;"
);
await mkdir(join(componentsDir, "card"), { recursive: true });
await writeFile(
join(componentsDir, "card", "index.ts"),
"export {};"
);
const result = await build({ componentsDir, outDir });
expect(result.items.sort()).toEqual(["button", "card"]);
const buttonItem = JSON.parse(
await readFile(join(outDir, "button.json"), "utf8")
);
expect(buttonItem.name).toBe("@suaveplan/button");
const idx = JSON.parse(await readFile(result.indexPath, "utf8"));
expect(idx.items).toHaveLength(2);
}
);
it(
"validateItem rejects malformed items",
{ timeout: 5000 },
() => {
expect(
validateItem({
$schema: "wrong",
name: "x",
type: "registry:ui",
dependencies: [],
registryDependencies: [],
files: [],
} as never)
).toBe(false);
}
);
it(
"validateIndex rejects malformed indexes",
{ timeout: 5000 },
() => {
expect(
validateIndex({
$schema: "wrong",
name: "x",
items: [],
} as never)
).toBe(false);
}
);
});

View File

@@ -0,0 +1,244 @@
#!/usr/bin/env bun
/**
* `build-registry.ts` — emits the shadcn-CLI-compatible registry tree
* under `packages/react/react-ui/registry/r/` for the chassis.
*
* Real implementation that supersedes the placeholder shipped under
* `ops/registry/scripts/build-registry.ts` (Wave 4 ops scaffolding).
*
* Behaviour:
*
* * Walks `<package-root>/src/components/` (when it exists). For every
* direct subdirectory that contains a `<name>.tsx` file or an
* `index.ts` barrel, emits a `registry/r/<name>.json` manifest.
* * Always emits `registry/r/index.json` with the manifest list (even
* when the components dir is empty, which is the Wave 2 chassis
* state — no components ship yet; they land per-batch in Wave 3a-h).
* * Validates every emitted manifest against the upstream
* shadcn registry-item JSON schema URL declared in the `$schema`
* field. The schema itself is resolved at runtime by the consumer
* when they invoke `shadcn add`.
*
* Future Wave 3 enhancements:
*
* * Strip internal-only OTel hooks (replaced with no-op shims in
* ejected source)
* * Rewrite `@suaveplan/react-i18n` imports to a configurable
* consumer alias (default `@/lib/i18n`)
* * Capture Tailwind/cva dependencies into `registryDependencies`
*
* Usage:
* bun packages/react/react-ui/scripts/build-registry.ts
* bun packages/react/react-ui/scripts/build-registry.ts --out ./tmp/r
*/
import { existsSync } from "node:fs";
import { mkdir, readdir, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const REGISTRY_SCHEMA_URL = "https://ui.shadcn.com/schema/registry-item.json";
const REGISTRY_NAMESPACE = "@suaveplan";
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const PACKAGE_ROOT = resolve(SCRIPT_DIR, "..");
const DEFAULT_COMPONENTS_DIR = join(PACKAGE_ROOT, "src", "components");
const DEFAULT_OUT_DIR = join(PACKAGE_ROOT, "registry", "r");
export interface RegistryItem {
readonly $schema: string;
readonly name: string;
readonly type: "registry:ui";
readonly dependencies: readonly string[];
readonly registryDependencies: readonly string[];
readonly files: readonly RegistryFile[];
}
export interface RegistryFile {
readonly path: string;
readonly type: "registry:ui";
}
export interface RegistryIndex {
readonly $schema: string;
readonly name: string;
readonly items: readonly RegistryIndexEntry[];
}
export interface RegistryIndexEntry {
readonly name: string;
readonly type: "registry:ui";
}
export interface BuildOptions {
readonly componentsDir: string;
readonly outDir: string;
}
export function parseArgs(argv: readonly string[]): Partial<BuildOptions> {
const opts: { componentsDir?: string; outDir?: string } = {};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--out" && i + 1 < argv.length) {
i += 1;
opts.outDir = resolve(argv[i] as string);
continue;
}
if (arg === "--components" && i + 1 < argv.length) {
i += 1;
opts.componentsDir = resolve(argv[i] as string);
}
}
return opts;
}
export async function listComponentDirs(
componentsDir: string
): Promise<string[]> {
if (!existsSync(componentsDir)) return [];
const entries = await readdir(componentsDir, { withFileTypes: true });
const dirs: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const sub = join(componentsDir, entry.name);
const hasIndex =
existsSync(join(sub, "index.ts")) ||
existsSync(join(sub, "index.tsx")) ||
existsSync(join(sub, `${entry.name}.tsx`));
if (hasIndex) dirs.push(entry.name);
}
return dirs.sort();
}
export function buildRegistryItem(
name: string,
componentsDir?: string
): RegistryItem {
// Most components ship as `<name>.tsx`. A handful (toast,
// use-toast-sonner-bridge) are JSX-free and ship as `<name>.ts`.
// Prefer the `.tsx` form when present so consumers get the JSX
// template; fall back to `.ts` when the component is hooks-only.
const extension =
componentsDir !== undefined &&
existsSync(join(componentsDir, name, `${name}.tsx`))
? "tsx"
: componentsDir !== undefined &&
existsSync(join(componentsDir, name, `${name}.ts`))
? "ts"
: "tsx";
return {
$schema: REGISTRY_SCHEMA_URL,
name: `${REGISTRY_NAMESPACE}/${name}`,
type: "registry:ui",
dependencies: [],
registryDependencies: [],
files: [{ path: `${name}.${extension}`, type: "registry:ui" }],
};
}
export function buildRegistryIndex(names: readonly string[]): RegistryIndex {
return {
$schema: REGISTRY_SCHEMA_URL,
name: REGISTRY_NAMESPACE,
items: names.map((n) => ({
name: `${REGISTRY_NAMESPACE}/${n}`,
type: "registry:ui" as const,
})),
};
}
export function serialize(value: unknown): string {
return `${JSON.stringify(value, null, 2)}\n`;
}
/** Validate the emitted item against the bare shadcn schema shape. */
export function validateItem(value: RegistryItem): boolean {
return (
typeof value.$schema === "string" &&
value.$schema === REGISTRY_SCHEMA_URL &&
typeof value.name === "string" &&
value.name.length > 0 &&
value.type === "registry:ui" &&
Array.isArray(value.dependencies) &&
Array.isArray(value.registryDependencies) &&
Array.isArray(value.files) &&
value.files.every(
(f) => typeof f.path === "string" && f.type === "registry:ui"
)
);
}
export function validateIndex(value: RegistryIndex): boolean {
return (
typeof value.$schema === "string" &&
value.$schema === REGISTRY_SCHEMA_URL &&
typeof value.name === "string" &&
Array.isArray(value.items) &&
value.items.every(
(i) => typeof i.name === "string" && i.type === "registry:ui"
)
);
}
async function ensureDir(path: string): Promise<void> {
await mkdir(path, { recursive: true });
}
export async function writeFileAtomic(
path: string,
content: string
): Promise<void> {
await ensureDir(dirname(path));
await writeFile(path, content, { encoding: "utf8" });
}
export async function build(options: BuildOptions): Promise<{
readonly items: readonly string[];
readonly indexPath: string;
}> {
const componentNames = await listComponentDirs(options.componentsDir);
if (componentNames.length === 0) {
process.stdout.write(
`build-registry: no components found under ${options.componentsDir}; emitting empty registry index\n`
);
}
for (const name of componentNames) {
const item = buildRegistryItem(name, options.componentsDir);
if (!validateItem(item)) {
throw new Error(`build-registry: emitted item failed validation for ${name}`);
}
const out = join(options.outDir, `${name}.json`);
await writeFileAtomic(out, serialize(item));
process.stdout.write(`wrote ${out}\n`);
}
const index = buildRegistryIndex(componentNames);
if (!validateIndex(index)) {
throw new Error("build-registry: emitted index failed validation");
}
const indexPath = join(options.outDir, "index.json");
await writeFileAtomic(indexPath, serialize(index));
process.stdout.write(`wrote ${indexPath}\n`);
return { items: componentNames, indexPath };
}
async function main(): Promise<void> {
const parsed = parseArgs(process.argv.slice(2));
const options: BuildOptions = {
componentsDir: parsed.componentsDir ?? DEFAULT_COMPONENTS_DIR,
outDir: parsed.outDir ?? DEFAULT_OUT_DIR,
};
await ensureDir(options.outDir);
await build(options);
}
if (import.meta.main) {
await main().catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
process.stderr.write(`build-registry FAILED: ${message}\n`);
process.exit(1);
});
}

View File

@@ -0,0 +1,77 @@
# `check-i18n-keys`
## Purpose
Translation-key drift gate for `@suaveplan/react-ui`. Walks every `*.{ts,tsx}` under the package's source roots, extracts every `react-ui.*` translation-key reference from the documented call shapes, and diffs the discovered set against the canonical `src/locales/en-GB.json`. Reports `MISSING <key>` for keys referenced in code but absent from the bundle (consumers will hit a missing-key warning) and `ORPHAN <key>` for bundle keys nothing in source references (dead translation keys). Catches drift the moment a developer renames a key in code but forgets the bundle, or vice versa.
## Features
- Pattern-based extraction over six chassis-recognised call shapes: `t("react-ui.…")`, `tDynamic(...)`, `i18nKey="…"` JSX attribute, `i18nKey: "…"` object literal, `<name>Key={"…"}` prop default, `?? "react-ui.…"` fallback shorthand, `const DEFAULT_*_KEY = "…"` module-scope literal, `labelKey: "…"` plugin shape.
- Source location (`<file>:<line>`) attached to every MISSING diagnostic for quick navigation.
- `--strict` flag promotes ORPHAN diagnostics to errors. Default exits 0 on ORPHAN-only (warn) so the gate ships green even when the bundle has pre-existing dead keys; CI can opt into strict mode after a cleanup pass.
- Wired into `bunx turbo lint` — every commit in CI runs the default mode.
## Usage: Basic Example
```bash
bun run check:i18n-keys
# check-i18n-keys: clean — 23 keys in code, 23 keys in bundle.
```
When a key is missing:
```bash
bun run check:i18n-keys
# MISSING react-ui.foo.absent at src/components/foo/foo.tsx:42
# check-i18n-keys: 1 missing, 0 orphan (orphan = warn)
# (exit 1)
```
## Usage: Advanced Example
Strict mode for a periodic bundle-cleanup pass:
```bash
bun run check:i18n-keys:strict
# ORPHAN react-ui.popover.close
# ORPHAN react-ui.alertDialog.cancel
# check-i18n-keys: 0 missing, 2 orphan (strict)
# (exit 1)
```
The script's exported helpers can be reused by other tooling (e.g. a CI report aggregator):
```ts
import {
diff,
extractKeysFromSource,
loadBundleKeys,
} from "./check-i18n-keys.js";
const sourceKeys = (await extractKeysFromSource(files, root)).keys;
const bundleKeys = await loadBundleKeys(bundlePath);
const diagnostics = diff(sourceKeys, bundleKeys);
```
## API Reference
### CLI
`bun scripts/check-i18n-keys.ts [--strict]`
- Default mode: exits non-zero only when at least one MISSING diagnostic appears. ORPHANs are printed as warnings.
- `--strict`: exits non-zero on any diagnostic (MISSING or ORPHAN).
### Exported helpers
- `extractKeysFromSource(files, rootForRelative): Promise<{ keys: Map<key, location> }>` — walks the supplied source files, returns a map keyed by translation key, value `<relative-path>:<line>`.
- `loadBundleKeys(bundlePath): Promise<Set<string>>` — reads the bundle JSON and returns the subset of keys in the `react-ui.*` namespace.
- `diff(sourceKeys, bundleKeys): Diagnostic[]` — pure function returning sorted diagnostics (MISSING before ORPHAN, alphabetic within each kind).
## Implementation Notes
- Pattern-based instead of AST-based — no `@babel/parser` dep. The supported call shapes are a closed set documented at the top of the script. New idioms (e.g. a future `const KEYS = { close: "react-ui.foo" }` const-record style) need a new pattern; the script falls into the catch-all-of-last-resort `?? "react-ui.…"` only when the value lives in a discriminating context.
- The extraction explicitly EXCLUDES `*.test.tsx`, `*.stories.tsx`, `*.stories.test.tsx`, and `*.bench.ts` — test fixtures and stories often reference keys that exist in the bundle for component contracts, not for the test's own runtime use; treating them as references would mask real orphans.
- Scan roots: `src/components`, `src/i18n`, `src/theme`, `src/pack`, `src/provider`, `src/hooks`, `src/errors`. Any future surface that ships `react-ui.*` keys must be added here.
- The default-vs-strict split is deliberate. Pre-existing orphans in the bundle (`react-ui.empty.defaultDescription`, `react-ui.popover.close`, …) are public-API keys consumers may pass via `*Key` prop overrides; removing them would be a breaking change. Default mode warns; strict mode lets the team enforce zero-orphan after a bundle cleanup change.
- The script is a `Bun.import.meta.main` entry point; `process.exit(await main())` is the only top-level side effect. Imports are pure helpers reusable from tests.

View File

@@ -0,0 +1,171 @@
/**
* Tests for `check-i18n-keys.ts`.
*
* Build a fixtures filesystem on disk under `mkdtemp` and call the
* exported helpers (`extractKeysFromSource`, `loadBundleKeys`,
* `diff`) directly. The script's `main()` integrates with
* `process.argv` / `process.exit` so it is exercised end-to-end via
* a `Bun.spawn` round-trip in the last test.
*/
import { afterEach, beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
diff,
extractKeysFromSource,
loadBundleKeys,
} from "./check-i18n-keys.js";
let workDir: string;
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "i18n-keys-"));
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
});
async function writeFixture(rel: string, content: string): Promise<string> {
const full = join(workDir, rel);
await mkdir(join(full, ".."), { recursive: true });
await writeFile(full, content, "utf8");
return full;
}
describe("extractKeysFromSource", () => {
it("captures t() calls with double quotes", { timeout: 5000 }, async () => {
const f = await writeFixture(
"a.tsx",
`const out = t("react-ui.foo.bar");`
);
const { keys } = await extractKeysFromSource([f], workDir);
expect(keys.has("react-ui.foo.bar")).toBe(true);
});
it("captures t() calls with single quotes", { timeout: 5000 }, async () => {
const f = await writeFixture(
"a.tsx",
`const out = t('react-ui.foo.bar');`
);
const { keys } = await extractKeysFromSource([f], workDir);
expect(keys.has("react-ui.foo.bar")).toBe(true);
});
it("captures i18nKey JSX attribute", { timeout: 5000 }, async () => {
const f = await writeFixture(
"a.tsx",
`<FormattedMessage i18nKey="react-ui.greeting" />`
);
const { keys } = await extractKeysFromSource([f], workDir);
expect(keys.has("react-ui.greeting")).toBe(true);
});
it("captures the DEFAULT_*_KEY const idiom", { timeout: 5000 }, async () => {
const f = await writeFixture(
"a.tsx",
`const DEFAULT_LABEL_KEY = "react-ui.thing.label";`
);
const { keys } = await extractKeysFromSource([f], workDir);
expect(keys.has("react-ui.thing.label")).toBe(true);
});
it("captures `?? \"react-ui.…\"` fallback shorthand", {
timeout: 5000,
}, async () => {
const f = await writeFixture(
"a.tsx",
`const k = labelKey ?? "react-ui.fallback.label";`
);
const { keys } = await extractKeysFromSource([f], workDir);
expect(keys.has("react-ui.fallback.label")).toBe(true);
});
it("records the source location for each key", {
timeout: 5000,
}, async () => {
const f = await writeFixture(
"deep/nested.tsx",
`// header\nconst k = t("react-ui.line.two");`
);
const { keys } = await extractKeysFromSource([f], workDir);
expect(keys.get("react-ui.line.two")).toMatch(/deep\/nested\.tsx:2$/);
});
it("ignores non-namespace strings", { timeout: 5000 }, async () => {
const f = await writeFixture(
"a.tsx",
`const k = t("other.namespace.key");`
);
const { keys } = await extractKeysFromSource([f], workDir);
expect(keys.size).toBe(0);
});
});
describe("loadBundleKeys", () => {
it("loads only keys in the react-ui namespace", {
timeout: 5000,
}, async () => {
const path = join(workDir, "bundle.json");
await writeFile(
path,
JSON.stringify({
"react-ui.foo": "Foo",
"react-ui.bar": "Bar",
"other.namespace.key": "other",
}),
"utf8"
);
const keys = await loadBundleKeys(path);
expect(keys.has("react-ui.foo")).toBe(true);
expect(keys.has("react-ui.bar")).toBe(true);
expect(keys.has("other.namespace.key")).toBe(false);
});
});
describe("diff", () => {
it("reports MISSING for code-only keys", { timeout: 5000 }, () => {
const codeKeys = new Map([["react-ui.code.only", "src/x.tsx:1"]]);
const bundleKeys = new Set(["react-ui.bundled"]);
const diags = diff(codeKeys, bundleKeys);
expect(
diags.find((d) => d.kind === "MISSING")?.key
).toBe("react-ui.code.only");
});
it("reports ORPHAN for bundle-only keys", { timeout: 5000 }, () => {
const codeKeys = new Map<string, string>();
const bundleKeys = new Set(["react-ui.bundle.only"]);
const diags = diff(codeKeys, bundleKeys);
expect(
diags.find((d) => d.kind === "ORPHAN")?.key
).toBe("react-ui.bundle.only");
});
it("returns empty diagnostics on a clean source/bundle pair", {
timeout: 5000,
}, () => {
const codeKeys = new Map([["react-ui.k", "src/x.tsx:1"]]);
const bundleKeys = new Set(["react-ui.k"]);
expect(diff(codeKeys, bundleKeys)).toEqual([]);
});
it("orders MISSING before ORPHAN, then alphabetically", {
timeout: 5000,
}, () => {
const codeKeys = new Map([
["react-ui.b", "x:1"],
["react-ui.a", "x:2"],
]);
const bundleKeys = new Set(["react-ui.zzz", "react-ui.aaa"]);
const diags = diff(codeKeys, bundleKeys);
expect(diags.map((d) => `${d.kind}:${d.key}`)).toEqual([
"MISSING:react-ui.a",
"MISSING:react-ui.b",
"ORPHAN:react-ui.aaa",
"ORPHAN:react-ui.zzz",
]);
});
});

View File

@@ -0,0 +1,239 @@
#!/usr/bin/env bun
/**
* `check-i18n-keys.ts` — translation-key drift gate for
* `@suaveplan/react-ui`.
*
* Walks every `*.{ts,tsx}` under `src/components/**` and `src/i18n/**`
* and extracts translation-key references from these call shapes:
*
* - `t("react-ui.…")` / `t('react-ui.…')`
* - `tDynamic("react-ui.…")` / `tDynamic('react-ui.…')`
* - `<FormattedMessage i18nKey="react-ui.…" />` / `i18nKey='…'`
* - `<Trans i18nKey="…">` (existing components)
* - `i18nKey: "react-ui.…"` (object-literal column-config style)
* - `(.+)Key={?["']react-ui\.…["']` for prop-drilled keys
* (Pagination, Calendar, DataTable name multiple `*Key` props)
* - `?? "react-ui.…"` (default-key fallback pattern)
*
* The script then diffs the discovered set against the canonical
* `src/locales/en-GB.json`, reporting:
*
* - `MISSING <key> at <file>:<line>` — referenced in code but absent
* from the bundle (consumers will hit a missing-key warning).
* - `ORPHAN <key>` — present in the bundle but never referenced
* (dead translation key).
*
* Exits non-zero on any diagnostic. Wired into `bunx turbo lint`.
*
* Pattern-based extraction is intentional — the package has no
* `@babel/parser` dep and the supported call shapes are a closed set.
* The script accepts both single and double-quoted attribute values
* and ignores keys outside the `react-ui.…` namespace.
*/
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 SCAN_ROOTS = [
join(PACKAGE_ROOT, "src", "components"),
join(PACKAGE_ROOT, "src", "i18n"),
join(PACKAGE_ROOT, "src", "theme"),
join(PACKAGE_ROOT, "src", "pack"),
join(PACKAGE_ROOT, "src", "provider"),
join(PACKAGE_ROOT, "src", "hooks"),
join(PACKAGE_ROOT, "src", "errors"),
];
const BUNDLE_PATH = join(PACKAGE_ROOT, "src", "locales", "en-GB.json");
const NAMESPACE = "react-ui.";
export interface Diagnostic {
readonly kind: "MISSING" | "ORPHAN";
readonly key: string;
/** Source path + line for MISSING diagnostics; absent for ORPHAN. */
readonly location?: string;
}
export interface KeyExtraction {
readonly keys: ReadonlyMap<string, string>;
}
const KEY_PATTERNS: ReadonlyArray<RegExp> = [
/\bt\s*\(\s*["'](react-ui\.[^"']+)["']/g,
/\btDynamic\s*\(\s*["'](react-ui\.[^"']+)["']/g,
/\bi18nKey\s*=\s*["'](react-ui\.[^"']+)["']/g,
/\bi18nKey\s*:\s*["'](react-ui\.[^"']+)["']/g,
/\b\w+Key\s*=\s*\{?\s*["'](react-ui\.[^"']+)["']/g,
/\?\?\s*["'](react-ui\.[^"']+)["']/g,
// `const DEFAULT_*_KEY = "react-ui.…"` — the chassis convention
// for module-scoped default keys consumed via the *Key prop
// pattern above.
/(?:const|let|var)\s+\w+\s*(?::[^=]+)?=\s*["'](react-ui\.[^"']+)["']/g,
// `labelKey: "react-ui.…"` — theme-plugin / pack-plugin literal
// (chassis convention).
/\blabelKey\s*:\s*["'](react-ui\.[^"']+)["']/g,
// `as ThemePlugin["labelKey"]` cast pattern — keys forwarded
// through `as const` annotations into a typed slot.
/["'](react-ui\.[a-zA-Z][a-zA-Z0-9.]*)["']\s*as\s+const\b/g,
];
async function walk(dir: string): Promise<string[]> {
const out: string[] = [];
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === "node_modules" || entry.name === "dist") continue;
const full = join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...(await walk(full)));
} else if (
entry.isFile() &&
(entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) &&
!entry.name.endsWith(".test.ts") &&
!entry.name.endsWith(".test.tsx") &&
!entry.name.endsWith(".stories.tsx") &&
!entry.name.endsWith(".stories.test.tsx") &&
!entry.name.endsWith(".bench.ts")
) {
out.push(full);
}
}
return out;
}
function lineNumber(source: string, offset: number): number {
let line = 1;
for (let i = 0; i < offset && i < source.length; i += 1) {
if (source.charCodeAt(i) === 10) line += 1;
}
return line;
}
export async function extractKeysFromSource(
sourceFiles: ReadonlyArray<string>,
rootForRelative: string
): Promise<KeyExtraction> {
const keys = new Map<string, string>();
for (const file of sourceFiles) {
const text = await readFile(file, "utf8");
for (const pattern of KEY_PATTERNS) {
for (const match of text.matchAll(pattern)) {
const key = match[1];
if (typeof key !== "string" || keys.has(key)) continue;
const idx = match.index ?? 0;
const ln = lineNumber(text, idx);
keys.set(key, `${relative(rootForRelative, file)}:${ln}`);
}
}
}
return { keys };
}
export function diff(
sourceKeys: ReadonlyMap<string, string>,
bundleKeys: ReadonlySet<string>
): Diagnostic[] {
const diagnostics: Diagnostic[] = [];
for (const [key, location] of sourceKeys) {
if (!bundleKeys.has(key)) {
diagnostics.push({ kind: "MISSING", key, location });
}
}
for (const key of bundleKeys) {
if (!sourceKeys.has(key)) {
diagnostics.push({ kind: "ORPHAN", key });
}
}
diagnostics.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === "MISSING" ? -1 : 1;
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
});
return diagnostics;
}
export async function loadBundleKeys(
bundlePath: string
): Promise<Set<string>> {
const raw = await readFile(bundlePath, "utf8");
const parsed: Record<string, unknown> = JSON.parse(raw);
const out = new Set<string>();
for (const key of Object.keys(parsed)) {
if (key.startsWith(NAMESPACE)) out.add(key);
}
return out;
}
export interface CheckOptions {
readonly strict: boolean;
}
function parseArgs(argv: ReadonlyArray<string>): CheckOptions {
let strict = false;
for (const arg of argv) {
if (arg === "--strict") strict = true;
}
return { strict };
}
async function main(): Promise<number> {
const opts = parseArgs(process.argv.slice(2));
const sourceFiles: string[] = [];
for (const root of SCAN_ROOTS) {
try {
sourceFiles.push(...(await walk(root)));
} catch (err) {
if (
(err as NodeJS.ErrnoException).code !== "ENOENT" &&
(err as NodeJS.ErrnoException).code !== "ENOTDIR"
) {
throw err;
}
}
}
if (sourceFiles.length === 0) {
console.error(
`check-i18n-keys: no source files found under any of ${SCAN_ROOTS.join(", ")}`
);
return 2;
}
const { keys: sourceKeys } = await extractKeysFromSource(
sourceFiles,
PACKAGE_ROOT
);
const bundleKeys = await loadBundleKeys(BUNDLE_PATH);
const diagnostics = diff(sourceKeys, bundleKeys);
if (diagnostics.length === 0) {
console.log(
`check-i18n-keys: clean — ${sourceKeys.size} keys in code, ${bundleKeys.size} keys in bundle.`
);
return 0;
}
const missingCount = diagnostics.filter((d) => d.kind === "MISSING").length;
const orphanCount = diagnostics.length - missingCount;
for (const d of diagnostics) {
if (d.kind === "MISSING") {
console.error(`MISSING ${d.key} at ${d.location ?? "?"}`);
} else if (opts.strict) {
console.error(`ORPHAN ${d.key}`);
} else {
console.warn(`ORPHAN ${d.key} (warn — use --strict to fail)`);
}
}
console.error(
`check-i18n-keys: ${missingCount} missing, ${orphanCount} orphan` +
(opts.strict ? " (strict)" : " (orphan = warn)")
);
if (missingCount > 0) return 1;
if (opts.strict && orphanCount > 0) return 1;
return 0;
}
if (import.meta.main) {
process.exit(await main());
}

View File

@@ -0,0 +1,109 @@
#!/usr/bin/env bun
/**
* `check-locale-completeness.ts` — assert every key in
* `<package>/src/locales/en-GB.json` exists in every other locale
* bundle shipped by the package. Pure file walk; no transitive deps on
* the package source.
*
* Usage:
* bun packages/react/react-ui/scripts/check-locale-completeness.ts <package-root>
*
* Exit codes:
* 0 — every key parity-complete
* 1 — at least one key missing
* 2 — internal failure
*/
import { readdir, readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
interface BundleDiff {
readonly locale: string;
readonly missing: ReadonlyArray<string>;
readonly extra: ReadonlyArray<string>;
}
export async function checkLocaleCompleteness(
packageRoot: string
): Promise<BundleDiff[]> {
const localesDir = join(resolve(packageRoot), "src", "locales");
const enPath = join(localesDir, "en-GB.json");
const enKeys = new Set(
Object.keys(JSON.parse(await readFile(enPath, "utf8")))
);
const entries = await readdir(localesDir);
const diffs: BundleDiff[] = [];
for (const entry of entries) {
if (entry === "en-GB.json") continue;
if (!entry.endsWith(".json")) continue;
const otherPath = join(localesDir, entry);
const other = JSON.parse(await readFile(otherPath, "utf8")) as Record<
string,
string
>;
const otherKeys = new Set(Object.keys(other));
const missing: string[] = [];
for (const k of enKeys) {
if (!otherKeys.has(k)) missing.push(k);
}
const extra: string[] = [];
for (const k of otherKeys) {
if (!enKeys.has(k)) extra.push(k);
}
if (missing.length > 0 || extra.length > 0) {
diffs.push({
locale: entry.replace(/\.json$/, ""),
missing,
extra,
});
}
}
return diffs;
}
function format(diffs: ReadonlyArray<BundleDiff>): string {
if (diffs.length === 0) {
return "check-locale-completeness: every locale parity-complete.";
}
const lines: string[] = [
`check-locale-completeness: ${diffs.length} locale(s) failed parity.`,
];
for (const d of diffs) {
lines.push(` ${d.locale}:`);
if (d.missing.length > 0) {
lines.push(" missing keys:");
for (const k of d.missing) lines.push(` - ${k}`);
}
if (d.extra.length > 0) {
lines.push(" extra keys (not in en-GB):");
for (const k of d.extra) lines.push(` - ${k}`);
}
}
return lines.join("\n");
}
async function main(): Promise<void> {
const argv = process.argv.slice(2);
if (argv.length === 0) {
process.stderr.write(
"Usage: check-locale-completeness.ts <package-root>\n"
);
process.exit(2);
}
const root = argv[0] as string;
const diffs = await checkLocaleCompleteness(root);
const message = format(diffs);
if (diffs.length === 0) {
process.stdout.write(`${message}\n`);
process.exit(0);
}
process.stderr.write(`${message}\n`);
process.exit(1);
}
if (import.meta.main) {
await main().catch((err: unknown) => {
process.stderr.write(`internal error: ${(err as Error).message}\n`);
process.exit(2);
});
}

View File

@@ -0,0 +1,151 @@
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { checkRoots } from "../../../../../scripts/lint/check-no-hardcoded-strings.js";
async function makeTempFile(name: string, body: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "no-hardcoded-strings-"));
const file = join(dir, name);
await writeFile(file, body, "utf8");
return dir;
}
describe("check-no-hardcoded-strings", () => {
it(
"passes a t-wrapped JSX text node",
{ timeout: 10000 },
async () => {
const root = await makeTempFile(
"ok.tsx",
`import { useTranslation } from '@suaveplan/react-i18n';
export function X() {
const { t } = useTranslation();
return <button type="button">{t('react-ui.themeToggle.label')}</button>;
}
`
);
const diagnostics = await checkRoots([{ root }]);
expect(diagnostics).toEqual([]);
}
);
it(
"fails a bare JSX text node",
{ timeout: 10000 },
async () => {
const root = await makeTempFile(
"bad.tsx",
`export function X() {
return <button type="button">Close</button>;
}
`
);
const diagnostics = await checkRoots([{ root }]);
expect(diagnostics.length).toBeGreaterThanOrEqual(1);
expect(diagnostics[0]?.rule).toBe("jsx-text");
expect(diagnostics[0]?.snippet).toBe("Close");
}
);
it(
"fails a string literal aria-label",
{ timeout: 10000 },
async () => {
const root = await makeTempFile(
"aria.tsx",
`export function X() {
return <button type="button" aria-label="Close" />;
}
`
);
const diagnostics = await checkRoots([{ root }]);
expect(diagnostics.length).toBeGreaterThanOrEqual(1);
expect(diagnostics[0]?.rule).toBe("jsx-attr-aria-label");
}
);
it(
"fails an object-literal label string",
{ timeout: 10000 },
async () => {
const root = await makeTempFile(
"obj.ts",
`export const fields = { email: { label: 'Email' } };
`
);
const diagnostics = await checkRoots([{ root }]);
expect(diagnostics.length).toBeGreaterThanOrEqual(1);
expect(diagnostics[0]?.rule).toBe("object-label");
}
);
it(
"fails a JSX expression string-literal child",
{ timeout: 10000 },
async () => {
const root = await makeTempFile(
"literal.tsx",
`export function X() {
return <span>{'hardcoded'}</span>;
}
`
);
const diagnostics = await checkRoots([{ root }]);
expect(diagnostics.length).toBeGreaterThanOrEqual(1);
}
);
it(
"fails a string-literal placeholder attribute",
{ timeout: 10000 },
async () => {
const root = await makeTempFile(
"placeholder.tsx",
`export function X() {
return <input type="text" placeholder="Email address" />;
}
`
);
const diagnostics = await checkRoots([{ root }]);
expect(diagnostics.length).toBeGreaterThanOrEqual(1);
expect(diagnostics[0]?.rule).toBe("jsx-attr-placeholder");
}
);
it(
"skips test/spec/stories files via the skip suffix list",
{ timeout: 10000 },
async () => {
const root = await makeTempFile(
"thing.test.tsx",
`export function X() {
return <button type="button">hardcoded but exempt</button>;
}
`
);
const diagnostics = await checkRoots([{ root }]);
expect(diagnostics).toEqual([]);
}
);
it(
"skips spike directories",
{ timeout: 10000 },
async () => {
const dir = await mkdtemp(join(tmpdir(), "spike-skip-"));
const spikeDir = join(dir, "spike");
const fs = await import("node:fs/promises");
await fs.mkdir(spikeDir, { recursive: true });
await fs.writeFile(
join(spikeDir, "x.tsx"),
`export function X() {
return <button type="button">spike literal</button>;
}
`
);
const diagnostics = await checkRoots([{ root: dir }]);
expect(diagnostics).toEqual([]);
}
);
});

View File

@@ -0,0 +1,63 @@
# `check-registry-eject`
## Purpose
Shadcn-eject smoke gate for `@suaveplan/react-ui`. Verifies the `registry/r/*.json` tree against the package source so a downstream consumer running `shadcn add @suaveplan/<name>` always finds the files the registry promised. Catches the realistic drift modes today: a registry entry whose listed file vanished (rename / restructure), a component directory shipped without a registry entry (forgot to regenerate), an orphaned registry entry whose component was deleted (forgot to clean up), and entries that fail JSON or shape validation. Runs as part of `bunx turbo lint` on every commit.
## Features
- Per-entry validation: shape (`name`, `type`, `files`), JSON parse, non-empty `files[]`.
- Per-file existence: every `files[].path` resolves to a real file under `src/components/<name>/`.
- Bidirectional drift detection: registry entries without matching component directories AND component directories without registry entries both fail.
- Sorted, stable diagnostic output for diff-friendly CI logs.
- Pure helper exports (`audit`, `loadEntry`) reusable from tests.
## Usage: Basic Example
```bash
bun run check:registry-eject
# check-registry-eject: clean — 51 registry entries match 51 component directories.
```
When a registry entry lists a missing file:
```bash
bun run check:registry-eject
# MISSING_SOURCE_FILE registry/r/toast.json: file 'toast.tsx' does not exist at src/components/toast/toast.tsx
# check-registry-eject: 1 diagnostic(s) — see above.
# (exit 1)
```
## Usage: Advanced Example
The exported helpers can be reused — for example, a CI report generator that walks both registries (`react-ui` + a future `react-ui-social`) and emits a unified report:
```ts
import { audit } from "@suaveplan/react-ui/scripts/check-registry-eject";
const diagnostics = await audit({
registryEntries: await listEntries(packageA),
componentDirs: await listDirs(packageA),
componentsDir: join(packageA, "src/components"),
});
```
## API Reference
### CLI
`bun scripts/check-registry-eject.ts`
Exits 0 on a clean tree; exits 1 on any diagnostic; exits 2 when the registry directory or components directory is missing entirely (configuration error).
### Exported helpers
- `loadEntry(entryPath): Promise<RegistryEntry | { error }>` — reads + validates a single `registry/r/<name>.json` entry. Returns `{ error: string }` on any structural failure (missing field, wrong type, malformed JSON).
- `audit({ registryEntries, componentDirs, componentsDir }): Promise<Diagnostic[]>` — pure function returning sorted diagnostics. The four diagnostic kinds are: `MISSING_REGISTRY_ENTRY`, `MISSING_SOURCE_FILE`, `EMPTY_FILES_LIST`, `MALFORMED_ENTRY`, `ORPHAN_REGISTRY_ENTRY`.
## Implementation Notes
- The gate is intentionally narrower than the proposal's full vision — it verifies file presence + entry shape, NOT that the ejected source compiles against a synthesized consumer `tsconfig.json`. The synthesized-tsconfig roundtrip depends on the registry transform itself (rewriting `@suaveplan/react-ui` imports to consumer-local paths) which today is a passthrough — `build-registry.ts` ships source verbatim, so a consumer's `shadcn add` already gets compilable source. When the transform lands as a callable helper (per the comment block in `build-registry.ts`), this gate's `audit()` will be extended to invoke it and then run `tsc --noEmit` against the transformed tree.
- `index.json` is excluded from the per-entry walk — it is the manifest of manifests, not an individual component.
- Component directories whose only files are barrels (`index.ts` only, no `<name>.tsx`/`<name>.ts`) are NOT detected as component directories. The list-detection mirrors `build-registry.ts`'s heuristic.
- Both `.tsx` and `.ts` extensions are valid for the file listed in `files[]`. Components like `<Toast>` that ship as JSX-free TypeScript ship as `.ts`; the registry entry for them lists `<name>.ts` and the gate accepts it.

View File

@@ -0,0 +1,200 @@
/**
* Tests for `check-registry-eject.ts`.
*
* Builds fixtures filesystems on disk under `mkdtemp` and exercises
* the exported helpers (`audit`, `loadEntry`) directly. The script's
* `main()` is exercised indirectly through these helpers — its
* top-level behaviour (process.exit) is covered by the lint gate
* itself running on real source.
*/
import { afterEach, beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { audit, loadEntry } from "./check-registry-eject.js";
let workDir: string;
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "registry-eject-"));
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
});
async function writeJson(rel: string, body: unknown): Promise<string> {
const full = join(workDir, rel);
await mkdir(join(full, ".."), { recursive: true });
await writeFile(full, JSON.stringify(body, null, 2), "utf8");
return full;
}
async function writeFixture(rel: string, content = ""): Promise<string> {
const full = join(workDir, rel);
await mkdir(join(full, ".."), { recursive: true });
await writeFile(full, content, "utf8");
return full;
}
describe("loadEntry", () => {
it("loads a valid registry entry", { timeout: 5000 }, async () => {
const entry = await writeJson("button.json", {
name: "@suaveplan/button",
type: "registry:ui",
files: [{ path: "button.tsx", type: "registry:ui" }],
});
const result = await loadEntry(entry);
expect("error" in result).toBe(false);
});
it("rejects an entry without a name", { timeout: 5000 }, async () => {
const entry = await writeJson("bad.json", {
type: "registry:ui",
files: [],
});
const result = await loadEntry(entry);
expect("error" in result).toBe(true);
});
it("rejects an entry with non-array files", {
timeout: 5000,
}, async () => {
const entry = await writeJson("bad.json", {
name: "x",
type: "registry:ui",
files: "oops",
});
const result = await loadEntry(entry);
expect("error" in result).toBe(true);
});
it("rejects malformed JSON", { timeout: 5000 }, async () => {
const entry = await writeFixture("bad.json", "{ not json");
const result = await loadEntry(entry);
expect("error" in result).toBe(true);
});
});
describe("audit", () => {
it("returns no diagnostics on a clean fixtures tree", {
timeout: 5000,
}, async () => {
const componentsDir = join(workDir, "src/components");
await writeFixture("src/components/button/button.tsx", "export {}");
const entry = await writeJson("registry/r/button.json", {
name: "@x/button",
type: "registry:ui",
files: [{ path: "button.tsx", type: "registry:ui" }],
});
const diagnostics = await audit({
registryEntries: [entry],
componentDirs: ["button"],
componentsDir,
});
expect(diagnostics).toEqual([]);
});
it("flags a component directory with no registry entry", {
timeout: 5000,
}, async () => {
const componentsDir = join(workDir, "src/components");
await writeFixture("src/components/button/button.tsx", "export {}");
const diagnostics = await audit({
registryEntries: [],
componentDirs: ["button"],
componentsDir,
});
expect(
diagnostics.find(
(d) => d.kind === "MISSING_REGISTRY_ENTRY"
)?.message
).toMatch(/button/);
});
it("flags a registry entry whose source file is missing", {
timeout: 5000,
}, async () => {
const componentsDir = join(workDir, "src/components");
await mkdir(join(componentsDir, "ghost"), { recursive: true });
const entry = await writeJson("registry/r/ghost.json", {
name: "@x/ghost",
type: "registry:ui",
files: [{ path: "ghost.tsx", type: "registry:ui" }],
});
const diagnostics = await audit({
registryEntries: [entry],
componentDirs: ["ghost"],
componentsDir,
});
expect(
diagnostics.find((d) => d.kind === "MISSING_SOURCE_FILE")?.message
).toMatch(/ghost\.tsx/);
});
it("flags an orphan registry entry (no matching component dir)", {
timeout: 5000,
}, async () => {
const componentsDir = join(workDir, "src/components");
await writeFixture(
"src/components/button/button.tsx",
"export {}"
);
const entryButton = await writeJson("registry/r/button.json", {
name: "@x/button",
type: "registry:ui",
files: [{ path: "button.tsx", type: "registry:ui" }],
});
const entryStale = await writeJson("registry/r/removed.json", {
name: "@x/removed",
type: "registry:ui",
files: [{ path: "removed.tsx", type: "registry:ui" }],
});
const diagnostics = await audit({
registryEntries: [entryButton, entryStale],
componentDirs: ["button"],
componentsDir,
});
expect(
diagnostics.find(
(d) => d.kind === "ORPHAN_REGISTRY_ENTRY"
)?.message
).toMatch(/removed/);
});
it("flags an entry with empty files[]", { timeout: 5000 }, async () => {
const componentsDir = join(workDir, "src/components");
await writeFixture("src/components/foo/foo.tsx", "export {}");
const entry = await writeJson("registry/r/foo.json", {
name: "@x/foo",
type: "registry:ui",
files: [],
});
const diagnostics = await audit({
registryEntries: [entry],
componentDirs: ["foo"],
componentsDir,
});
expect(
diagnostics.find((d) => d.kind === "EMPTY_FILES_LIST")?.message
).toMatch(/foo\.json/);
});
it("flags malformed JSON entries", { timeout: 5000 }, async () => {
const componentsDir = join(workDir, "src/components");
await writeFixture("src/components/foo/foo.tsx", "");
const entry = await writeFixture(
"registry/r/foo.json",
"{ not json"
);
const diagnostics = await audit({
registryEntries: [entry],
componentDirs: ["foo"],
componentsDir,
});
expect(
diagnostics.find((d) => d.kind === "MALFORMED_ENTRY")?.message
).toMatch(/foo\.json/);
});
});

View 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());
}