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

483 lines
18 KiB
TypeScript

/**
* 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/<plugin>/<pkg>/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<void>;
}
async function makeFixture(): Promise<Fixture> {
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<string, unknown>
): Promise<string> {
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 <root>/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([]);
});
});