483 lines
18 KiB
TypeScript
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([]);
|
|
});
|
|
});
|