Files
suaveplan/scripts/lint/check-no-backwards-deps.ts
2026-05-27 23:47:53 +01:00

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