/** * Tests for check-no-backwards-deps.ts. * * Bun-test runner (precedent: scripts/lint/check-test-runner-imports.test.ts): * the canonical-runner-wrapper rule polices `packages///src/` * test files, not repo-tooling tests under `scripts/`. * * Real fixtures: every test writes a real package.json to a real temp * directory and runs the script's `findViolations` against the file. * No fs mocks — the temp-dir lifecycle is owned via beforeEach/afterEach. * * Includes the "passing case for the live react-i18n package.json" so a * future regression (someone adds @suaveplan/react-ui to dependencies) * fails this test loudly and the CI gate, not just the gate. */ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { BANNED_PATTERNS, bannedPatternsFor, defaultPackageJsonPath, findViolations, formatViolations, INSPECTED_BUCKETS, } from "./check-no-backwards-deps.ts"; const REPO_ROOT = resolve(__dirname, "../.."); interface Fixture { readonly root: string; cleanup(): Promise; } async function makeFixture(): Promise { const root = await mkdtemp(join(tmpdir(), "check-no-backwards-deps-")); return { root, async cleanup() { await rm(root, { recursive: true, force: true }); }, }; } async function writeManifest( root: string, manifest: Record ): Promise { const path = join(root, "package.json"); await writeFile(path, JSON.stringify(manifest, null, 2)); return path; } describe("check-no-backwards-deps — fixture cases", () => { let fixture: Fixture; beforeEach(async () => { fixture = await makeFixture(); }); afterEach(async () => { await fixture.cleanup(); }); it("passes for a clean manifest with only foundation deps", { timeout: 5000, }, async () => { const path = await writeManifest(fixture.root, { name: "@suaveplan/react-i18n", dependencies: { "@suaveplan/error": "workspace:^", "@suaveplan/i18n": "workspace:^", react: "^19.0.0", }, peerDependencies: { react: "^19.0.0" }, devDependencies: { "@suaveplan/testing": "workspace:^" }, }); const violations = await findViolations({ packageJsonPath: path }); expect(violations).toEqual([]); expect(formatViolations(violations)).toContain( "no React-stack backward dependencies" ); }); it("flags @suaveplan/react-ui in dependencies", { timeout: 5000, }, async () => { const path = await writeManifest(fixture.root, { name: "@suaveplan/react-i18n", dependencies: { "@suaveplan/react-ui": "*" }, }); const violations = await findViolations({ packageJsonPath: path }); expect(violations).toHaveLength(1); expect(violations[0]?.bucket).toBe("dependencies"); expect(violations[0]?.depName).toBe("@suaveplan/react-ui"); expect(violations[0]?.matchedPattern).toBe("@suaveplan/react-ui*"); expect(formatViolations(violations)).toContain( "React-stack backward dependencies detected" ); }); it("flags @suaveplan/react-forms in peerDependencies", { timeout: 5000, }, async () => { const path = await writeManifest(fixture.root, { name: "@suaveplan/react-i18n", peerDependencies: { "@suaveplan/react-forms": "*" }, }); const violations = await findViolations({ packageJsonPath: path }); expect(violations).toHaveLength(1); expect(violations[0]?.bucket).toBe("peerDependencies"); expect(violations[0]?.depName).toBe("@suaveplan/react-forms"); expect(violations[0]?.matchedPattern).toBe("@suaveplan/react-forms*"); }); it("flags @suaveplan/react-ui-theme-dracula in devDependencies", { timeout: 5000, }, async () => { const path = await writeManifest(fixture.root, { name: "@suaveplan/react-i18n", devDependencies: { "@suaveplan/react-ui-theme-dracula": "*", }, }); const violations = await findViolations({ packageJsonPath: path }); expect(violations).toHaveLength(1); expect(violations[0]?.bucket).toBe("devDependencies"); expect(violations[0]?.depName).toBe( "@suaveplan/react-ui-theme-dracula" ); expect(violations[0]?.matchedPattern).toBe( "@suaveplan/react-ui-theme-*" ); }); it("flags @suaveplan/react-ui-pack-auth across all three buckets", { timeout: 5000, }, async () => { const path = await writeManifest(fixture.root, { name: "@suaveplan/react-i18n", dependencies: { "@suaveplan/react-ui-pack-auth": "1.0.0" }, peerDependencies: { "@suaveplan/react-ui-pack-auth": "*" }, devDependencies: { "@suaveplan/react-ui-pack-auth": "*" }, }); const violations = await findViolations({ packageJsonPath: path }); expect(violations).toHaveLength(3); for (const v of violations) { expect(v.matchedPattern).toBe("@suaveplan/react-ui-pack-*"); } }); it("flags multiple banned deps and groups them by bucket in the diagnostic", { timeout: 5000, }, async () => { const path = await writeManifest(fixture.root, { name: "@suaveplan/react-i18n", dependencies: { "@suaveplan/react-ui": "*", "@suaveplan/react-forms": "*", }, devDependencies: { "@suaveplan/react-ui-theme-x": "*", }, }); const violations = await findViolations({ packageJsonPath: path }); expect(violations).toHaveLength(3); const message = formatViolations(violations); expect(message).toContain("dependencies (2 violations)"); expect(message).toContain("devDependencies (1 violation)"); expect(message).toContain("Total: 3 violations"); }); it("ignores absent dep buckets", { timeout: 5000 }, async () => { const path = await writeManifest(fixture.root, { name: "@suaveplan/react-i18n", }); const violations = await findViolations({ packageJsonPath: path }); expect(violations).toEqual([]); }); it("ignores buckets with non-object values", { timeout: 5000, }, async () => { // A malformed bucket (array instead of object) is the consumer's // bug — outside the scope of this rule. The script must not // crash on it. const path = await writeManifest(fixture.root, { name: "@suaveplan/react-i18n", dependencies: ["@suaveplan/react-ui"] as unknown, }); const violations = await findViolations({ packageJsonPath: path }); expect(violations).toEqual([]); }); it("ignores buckets with null values", { timeout: 5000 }, async () => { const path = await writeManifest(fixture.root, { name: "@suaveplan/react-i18n", dependencies: null, }); const violations = await findViolations({ packageJsonPath: path }); expect(violations).toEqual([]); }); it("does NOT flag arbitrary @suaveplan/* packages outside the banned set", { timeout: 5000, }, async () => { const path = await writeManifest(fixture.root, { name: "@suaveplan/react-i18n", dependencies: { "@suaveplan/i18n": "workspace:^", "@suaveplan/error": "workspace:^", "@suaveplan/types": "workspace:^", "@suaveplan/react-i18n": "workspace:^", react: "^19.0.0", }, }); const violations = await findViolations({ packageJsonPath: path }); expect(violations).toEqual([]); }); it("renders non-string version values via JSON.stringify in the diagnostic", { timeout: 5000, }, async () => { const path = await writeManifest(fixture.root, { name: "@suaveplan/react-i18n", dependencies: { "@suaveplan/react-ui": { version: "1.0.0" } as unknown, }, }); const violations = await findViolations({ packageJsonPath: path }); expect(violations).toHaveLength(1); expect(violations[0]?.version).toContain('"version":"1.0.0"'); }); it("throws on a missing package.json", { timeout: 5000 }, async () => { await expect( findViolations({ packageJsonPath: join(fixture.root, "missing.json"), }) ).rejects.toThrow(/cannot read/); }); it("throws on an unparsable package.json", { timeout: 5000 }, async () => { const path = join(fixture.root, "package.json"); await writeFile(path, "{not json"); await expect(findViolations({ packageJsonPath: path })).rejects.toThrow( /cannot parse/ ); }); it("respects an injected readFile dependency", { timeout: 5000, }, async () => { const calls: string[] = []; const fakeRead = async (path: import("node:fs").PathLike) => { calls.push(String(path)); return JSON.stringify({ name: "@suaveplan/react-i18n", dependencies: { "@suaveplan/react-forms-validation": "*", }, }); }; const violations = await findViolations({ packageJsonPath: "/virtual/package.json", readFileFn: fakeRead as Parameters< typeof findViolations >[0]["readFileFn"], }); expect(calls).toEqual(["/virtual/package.json"]); expect(violations).toHaveLength(1); expect(violations[0]?.matchedPattern).toBe("@suaveplan/react-forms*"); }); }); describe("check-no-backwards-deps — live react-i18n package.json", () => { it("the real package.json declares no banned deps", { timeout: 5000, }, async () => { const target = defaultPackageJsonPath(REPO_ROOT); const violations = await findViolations({ packageJsonPath: target, }); expect(violations).toEqual([]); }); }); describe("check-no-backwards-deps — registry sanity", () => { it("INSPECTED_BUCKETS lists exactly the three audited dep buckets", { timeout: 5000, }, () => { expect(INSPECTED_BUCKETS).toEqual([ "dependencies", "peerDependencies", "devDependencies", ]); }); it("BANNED_PATTERNS labels are stable", { timeout: 5000 }, () => { const labels = BANNED_PATTERNS.map((p) => p.label).sort(); expect(labels).toEqual([ "@suaveplan/react-forms*", "@suaveplan/react-ui*", "@suaveplan/react-ui-pack-*", "@suaveplan/react-ui-theme-*", ]); }); it("defaultPackageJsonPath resolves under /packages/react/react-i18n", { timeout: 5000, }, () => { const path = defaultPackageJsonPath("/virtual/repo"); expect(path).toBe( "/virtual/repo/packages/react/react-i18n/package.json" ); }); }); describe("check-no-backwards-deps — per-tier banned-pattern hierarchy", () => { it("react-i18n bans every leaf in the React stack", () => { const labels = bannedPatternsFor("@suaveplan/react-i18n") .map((p) => p.label) .sort(); expect(labels).toEqual([ "@suaveplan/react-forms*", "@suaveplan/react-ui*", "@suaveplan/react-ui-pack-*", "@suaveplan/react-ui-theme-*", ]); }); it("react-ui bans react-forms, react-ui-theme-*, react-ui-pack-* (CAN dep react-i18n)", () => { const labels = bannedPatternsFor("@suaveplan/react-ui") .map((p) => p.label) .sort(); expect(labels).toEqual([ "@suaveplan/react-forms*", "@suaveplan/react-ui-pack-*", "@suaveplan/react-ui-theme-*", ]); }); it("react-forms bans theme-* and pack-* (CAN dep react-i18n + react-ui)", () => { const labels = bannedPatternsFor("@suaveplan/react-forms") .map((p) => p.label) .sort(); expect(labels).toEqual([ "@suaveplan/react-ui-pack-*", "@suaveplan/react-ui-theme-*", ]); }); it("react-ui-theme-* bans react-forms* and pack-* (CAN dep react-i18n + react-ui)", () => { const labels = bannedPatternsFor("@suaveplan/react-ui-theme-dracula") .map((p) => p.label) .sort(); expect(labels).toEqual([ "@suaveplan/react-forms*", "@suaveplan/react-ui-pack-*", ]); }); it("react-ui-pack-* bans NOTHING in the React stack (bottom of hierarchy)", () => { expect(bannedPatternsFor("@suaveplan/react-ui-pack-auth")).toEqual([]); }); it("packages outside the React stack get a no-op (empty banned list)", () => { expect(bannedPatternsFor("@suaveplan/error")).toEqual([]); expect(bannedPatternsFor("@suaveplan/i18n")).toEqual([]); expect(bannedPatternsFor("@some-vendor/lib")).toEqual([]); }); it("undefined / non-string package names yield empty banned list", () => { expect(bannedPatternsFor(undefined)).toEqual([]); }); }); describe("check-no-backwards-deps — per-tier enforcement", () => { let fixture: Fixture; beforeEach(async () => { fixture = await makeFixture(); }); afterEach(async () => { await fixture.cleanup(); }); it("react-ui declaring @suaveplan/react-i18n is allowed (upstream dep)", async () => { const path = await writeManifest(fixture.root, { name: "@suaveplan/react-ui", dependencies: { "@suaveplan/react-i18n": "workspace:^" }, }); const violations = await findViolations({ packageJsonPath: path }); expect(violations).toEqual([]); }); it("react-ui declaring @suaveplan/react-forms IS a violation", async () => { const path = await writeManifest(fixture.root, { name: "@suaveplan/react-ui", dependencies: { "@suaveplan/react-forms": "workspace:^" }, }); const violations = await findViolations({ packageJsonPath: path }); expect(violations).toHaveLength(1); expect(violations[0]?.matchedPattern).toBe("@suaveplan/react-forms*"); }); it("react-forms declaring @suaveplan/react-ui is allowed (upstream dep)", async () => { const path = await writeManifest(fixture.root, { name: "@suaveplan/react-forms", dependencies: { "@suaveplan/react-ui": "workspace:^", "@suaveplan/react-i18n": "workspace:^", }, }); const violations = await findViolations({ packageJsonPath: path }); expect(violations).toEqual([]); }); it("react-forms declaring @suaveplan/react-ui-theme-dracula IS a violation", async () => { const path = await writeManifest(fixture.root, { name: "@suaveplan/react-forms", dependencies: { "@suaveplan/react-ui-theme-dracula": "workspace:^", }, }); const violations = await findViolations({ packageJsonPath: path }); expect(violations).toHaveLength(1); expect(violations[0]?.matchedPattern).toBe( "@suaveplan/react-ui-theme-*" ); }); it("react-ui-theme-dracula declaring react-i18n + react-ui is allowed", async () => { const path = await writeManifest(fixture.root, { name: "@suaveplan/react-ui-theme-dracula", dependencies: { "@suaveplan/react-i18n": "workspace:^", "@suaveplan/react-ui": "workspace:^", }, }); const violations = await findViolations({ packageJsonPath: path }); expect(violations).toEqual([]); }); it("react-ui-theme-dracula declaring react-forms IS a violation", async () => { const path = await writeManifest(fixture.root, { name: "@suaveplan/react-ui-theme-dracula", dependencies: { "@suaveplan/react-forms": "workspace:^" }, }); const violations = await findViolations({ packageJsonPath: path }); expect(violations).toHaveLength(1); expect(violations[0]?.matchedPattern).toBe("@suaveplan/react-forms*"); }); it("react-ui-pack-auth can declare react-i18n + react-ui + react-forms (bottom-most tier)", async () => { const path = await writeManifest(fixture.root, { name: "@suaveplan/react-ui-pack-auth", dependencies: { "@suaveplan/react-i18n": "workspace:^", "@suaveplan/react-ui": "workspace:^", "@suaveplan/react-forms": "workspace:^", }, }); const violations = await findViolations({ packageJsonPath: path }); expect(violations).toEqual([]); }); it("packages outside the React stack get a clean pass even with React-stack deps", async () => { const path = await writeManifest(fixture.root, { name: "@suaveplan/some-other-package", dependencies: { "@suaveplan/react-i18n": "workspace:^", "@suaveplan/react-ui": "workspace:^", "@suaveplan/react-forms": "workspace:^", "@suaveplan/react-ui-theme-dracula": "workspace:^", "@suaveplan/react-ui-pack-auth": "workspace:^", }, }); const violations = await findViolations({ packageJsonPath: path }); expect(violations).toEqual([]); }); });