307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
#!/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 <repo-root>]
|
|
* [--package-json <path>]
|
|
*
|
|
* 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<BannedPattern> = [
|
|
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<string, ReadonlyArray<BannedPattern>> = {
|
|
"@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<BannedPattern> {
|
|
if (typeof packageName !== "string") return [];
|
|
if (Object.hasOwn(TIER_BANNED, packageName)) {
|
|
return TIER_BANNED[packageName] as ReadonlyArray<BannedPattern>;
|
|
}
|
|
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<Violation[]> {
|
|
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<string, unknown>;
|
|
try {
|
|
manifest = JSON.parse(raw) as Record<string, unknown>;
|
|
} 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<string, unknown>;
|
|
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<InspectedBucket, Violation[]>();
|
|
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<void> {
|
|
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();
|
|
}
|