#!/usr/bin/env bun /** * check-no-backwards-deps.ts * * CI gate enforcing the React-stack dependency-direction invariant * declared in: * - openspec/changes/enterprise-react-i18n/specs/react-i18n/spec.md * ("Branded TranslationKey typing contract") * - openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md §11.2 * * The React stack has a strict layered hierarchy: * * react-i18n (foundation; has no upstream React-stack deps) * ▲ * react-ui (depends on react-i18n only) * ▲ * react-forms (depends on react-i18n + react-ui) * ▲ * react-ui-theme-* (depends on react-i18n + react-ui) * react-ui-pack-* (depends on react-i18n + react-ui [+ react-forms]) * * Each tier MUST NOT declare a downstream-tier package in its * `dependencies`, `peerDependencies`, or `devDependencies` — otherwise * the layering inverts, the TranslationKey augmentation contract breaks, * and tree-shaking guarantees collapse. * * This script reads a package.json, identifies the package by its `name`, * computes the appropriate banned-pattern subset for that tier, and * checks the three dep buckets. Exits non-zero with a structured * diagnostic listing every offending (bucket, package) pair. * * Packages outside the React stack (`@suaveplan/error`, `@suaveplan/i18n`, * `@some/other`) get a no-op pass — this gate is React-stack-specific. * * Usage: * bun scripts/lint/check-no-backwards-deps.ts [--root ] * [--package-json ] * * Exit codes: * 0 — package.json declares no banned deps (or is outside React stack) * 1 — at least one banned dep found (details on stderr) * 2 — internal error (cannot read or parse package.json) */ import { readFile } from "node:fs/promises"; import { join, resolve } from "node:path"; export interface BannedPattern { readonly label: string; readonly match: (depName: string) => boolean; } const PATTERN_REACT_UI_THEME: BannedPattern = { label: "@suaveplan/react-ui-theme-*", match: (n) => n.startsWith("@suaveplan/react-ui-theme-"), }; const PATTERN_REACT_UI_PACK: BannedPattern = { label: "@suaveplan/react-ui-pack-*", match: (n) => n.startsWith("@suaveplan/react-ui-pack-"), }; const PATTERN_REACT_UI: BannedPattern = { label: "@suaveplan/react-ui*", match: (n) => n === "@suaveplan/react-ui" || (n.startsWith("@suaveplan/react-ui-") && !n.startsWith("@suaveplan/react-ui-theme-") && !n.startsWith("@suaveplan/react-ui-pack-")), }; const PATTERN_REACT_FORMS: BannedPattern = { label: "@suaveplan/react-forms*", match: (n) => n === "@suaveplan/react-forms" || n.startsWith("@suaveplan/react-forms-"), }; /** * Full registry of every banned-pattern in the React stack. Per-tier * banned subsets are derived from this set. Stable for downstream * consumers (test fixtures, doc generators). */ export const BANNED_PATTERNS: ReadonlyArray = [ PATTERN_REACT_UI_THEME, PATTERN_REACT_UI_PACK, PATTERN_REACT_UI, PATTERN_REACT_FORMS, ]; /** * Per-tier banned-pattern map. Each tier's entry lists the patterns * that MUST NOT appear in any of its dep buckets. Computed from the * stack hierarchy: * * react-i18n → bans every leaf (react-ui*, react-forms*, theme-*, pack-*) * react-ui → bans every leaf below it (react-forms*, theme-*, pack-*) * react-forms → bans theme-* and pack-* only (CAN dep react-ui) * react-ui-theme-* → bans react-forms* and pack-* (themes are siblings of forms; * packs sit downstream of themes) * react-ui-pack-* → bans nothing in the React stack (packs are bottom-most) */ const TIER_BANNED: Record> = { "@suaveplan/react-i18n": BANNED_PATTERNS, "@suaveplan/react-ui": [ PATTERN_REACT_UI_THEME, PATTERN_REACT_UI_PACK, PATTERN_REACT_FORMS, ], "@suaveplan/react-forms": [PATTERN_REACT_UI_THEME, PATTERN_REACT_UI_PACK], }; /** * Compute the banned-pattern subset for a given package name. Returns * an empty array for packages outside the React stack so the gate * no-ops on them. */ export function bannedPatternsFor( packageName: string | undefined ): ReadonlyArray { if (typeof packageName !== "string") return []; if (Object.hasOwn(TIER_BANNED, packageName)) { return TIER_BANNED[packageName] as ReadonlyArray; } if (packageName.startsWith("@suaveplan/react-ui-theme-")) { return [PATTERN_REACT_FORMS, PATTERN_REACT_UI_PACK]; } if (packageName.startsWith("@suaveplan/react-ui-pack-")) { return []; } return []; } /** * The package.json buckets we inspect. Listed explicitly (rather than * walking every key) so the rule is auditable and so unrelated buckets * (e.g. `optionalDependencies`, `bundleDependencies`) cannot smuggle a * banned dep through. */ export const INSPECTED_BUCKETS = [ "dependencies", "peerDependencies", "devDependencies", ] as const; export type InspectedBucket = (typeof INSPECTED_BUCKETS)[number]; export interface Violation { readonly bucket: InspectedBucket; readonly depName: string; readonly version: string; readonly matchedPattern: string; } export interface CheckOptions { readonly packageJsonPath: string; readonly readFileFn?: typeof readFile; } /** * Read the supplied `package.json` and return every banned dep * declared in `dependencies`, `peerDependencies`, or * `devDependencies`. Buckets that are absent or malformed contribute * zero violations (a malformed bucket is the consumer's bug — out of * scope for this gate). */ export async function findViolations( options: CheckOptions ): Promise { const readImpl = options.readFileFn ?? readFile; let raw: string; try { raw = await readImpl(options.packageJsonPath, "utf8"); } catch (cause) { throw new Error( `check-no-backwards-deps: cannot read ${options.packageJsonPath}: ${(cause as Error).message}` ); } let manifest: Record; try { manifest = JSON.parse(raw) as Record; } catch (cause) { throw new Error( `check-no-backwards-deps: cannot parse ${options.packageJsonPath}: ${(cause as Error).message}` ); } const packageName = typeof manifest.name === "string" ? manifest.name : undefined; const banned = bannedPatternsFor(packageName); if (banned.length === 0) return []; const violations: Violation[] = []; for (const bucket of INSPECTED_BUCKETS) { const value = manifest[bucket]; if (value === undefined || value === null) continue; if (typeof value !== "object" || Array.isArray(value)) continue; const deps = value as Record; for (const [depName, depVersion] of Object.entries(deps)) { for (const pattern of banned) { if (!pattern.match(depName)) continue; violations.push({ bucket, depName, version: typeof depVersion === "string" ? depVersion : JSON.stringify(depVersion), matchedPattern: pattern.label, }); } } } return violations; } /** * Render a human-readable diagnostic block. The empty-violations form * is the single line printed on stdout for the success case. */ export function formatViolations(violations: readonly Violation[]): string { if (violations.length === 0) { return "Package declares no React-stack backward dependencies."; } const lines: string[] = [ "❌ React-stack backward dependencies detected.", "", "Rule: openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md §11.2.", "Fix: remove the dep — each tier in the stack must only depend on tiers", " strictly above it (react-i18n → react-ui → react-forms / theme / pack).", "", ]; const grouped = new Map(); for (const v of violations) { const list = grouped.get(v.bucket) ?? []; list.push(v); grouped.set(v.bucket, list); } for (const bucket of INSPECTED_BUCKETS) { const items = grouped.get(bucket); if (!items || items.length === 0) continue; lines.push( ` ${bucket} (${items.length} violation${items.length === 1 ? "" : "s"})` ); for (const v of items) { lines.push( ` ${v.depName}@${v.version} (matched: ${v.matchedPattern})` ); } lines.push(""); } lines.push( `Total: ${violations.length} violation${violations.length === 1 ? "" : "s"}.` ); return lines.join("\n"); } /** * Default canonical path: react-i18n's `package.json` inside the repo. * The CLI accepts overrides for both the repo root and the package.json * path so test fixtures can drive the script without mutating the real * file. */ export function defaultPackageJsonPath(repoRoot: string): string { return join( resolve(repoRoot), "packages", "react", "react-i18n", "package.json" ); } async function main(): Promise { const argv = process.argv.slice(2); let repoRoot = process.cwd(); let packageJsonPath: string | undefined; for (let i = 0; i < argv.length; i += 1) { if (argv[i] === "--root" && i + 1 < argv.length) { repoRoot = resolve(argv[i + 1] ?? "."); i += 1; continue; } if (argv[i] === "--package-json" && i + 1 < argv.length) { packageJsonPath = resolve(argv[i + 1] ?? "."); i += 1; } } const target = packageJsonPath ?? defaultPackageJsonPath(repoRoot); let violations: readonly Violation[]; try { violations = await findViolations({ packageJsonPath: target }); } catch (cause) { process.stderr.write(`${(cause as Error).message}\n`); process.exit(2); } const message = formatViolations(violations); if (violations.length === 0) { process.stdout.write(`${message}\n`); process.exit(0); } process.stderr.write(`${message}\n`); process.exit(1); } if (import.meta.main) { await main(); }