`local/layers` (eslint.config.js) resolves every import to its file and refuses one that goes up (lib/ → components/ui/ → features/ → modules/ → shell/) or that reaches into another module's own files. A module's index.ts is its public face: the stores, mounted at the root and read app-wide, which features/ may read too; a page is still opened lazily by import(). Its contract test probes every edge, and three mutants of the rule (off, no face check, a door for everyone) each turn it red. Six modules got that face; the shared channel UI (about, tags, tag manager) moved to features/channel, the filter chips and the saved-views context to features/filters, and the prefs store's controller type moved next to the store instead of importing it from its own page. The header's per-module controls and the eagerly loaded rails keep eight inline suppressions, each naming why: they go when the header gets slots and every rail loads lazily. `chunkGuard` fails the production build when a lazily imported module lands in a boot chunk (the entry, the shell, what they import) — e.g. a page re-exported from an index.ts, which makes its import() a silent no-op. Proved on a build with that mutant; today's build has none.
92 lines
3.8 KiB
TypeScript
92 lines
3.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { eslint } from "./lintProbe.testkit";
|
|
|
|
// The layer rule (local/layers in eslint.config.js) is what makes the folders a structure rather
|
|
// than a filing habit: lib/ → components/ui/ → features/ → modules/ → shell/, an import only goes
|
|
// down, and a module's own files are reached from outside only through its index.ts or lazily. A
|
|
// rule that matched nothing would look exactly like a clean codebase, so each edge is probed here.
|
|
async function refused(source: string, filePath: string): Promise<string[]> {
|
|
const [result] = await eslint.lintText(source, { filePath });
|
|
return (result?.messages ?? []).filter((m) => m.ruleId === "local/layers").map((m) => m.message);
|
|
}
|
|
const imp = (...specs: string[]) =>
|
|
specs.map((s, i) => `import x${i} from "${s}";\n`).join("") +
|
|
`export default [${specs.map((_, i) => `x${i}`).join(", ")}];\n`;
|
|
|
|
const IN_FEED = "src/modules/feed/__probe.tsx";
|
|
const IN_FEATURE = "src/features/video/__probe.tsx";
|
|
|
|
describe("a module", () => {
|
|
it("reaches another module only through its index.ts", async () => {
|
|
expect(await refused(imp("../plex/PlexBrowse"), IN_FEED)).toHaveLength(1);
|
|
expect(await refused(imp("../plex", "../plex/index"), IN_FEED)).toEqual([]);
|
|
});
|
|
|
|
it("by every spelling: a type, a re-export, a type-only import", async () => {
|
|
const src =
|
|
'import type { P } from "../plex/plexContext";\n' +
|
|
'export { usePlex } from "../plex/plexContext";\n' +
|
|
'export * from "../channels/channelsContext";\n' +
|
|
"export type Q = P;\n";
|
|
expect(await refused(src, IN_FEED)).toHaveLength(3);
|
|
});
|
|
|
|
it("may open another module's page lazily, and use its own files and anything below", async () => {
|
|
const src =
|
|
'const P = () => import("../plex/PlexBrowse");\n' +
|
|
'import F from "./Feed";\nimport V from "../../features/video/VideoCard";\n' +
|
|
'import B from "../../components/ui/Button";\nimport { qk } from "../../lib/queryKeys";\n' +
|
|
"export default [P, F, V, B, qk];\n";
|
|
expect(await refused(src, IN_FEED)).toEqual([]);
|
|
});
|
|
|
|
it("never imports the shell", async () => {
|
|
expect(await refused(imp("../../shell/Header"), IN_FEED)).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("a feature", () => {
|
|
it("reads a module only through its face, and never the shell", async () => {
|
|
expect(await refused(imp("../../modules/plex"), IN_FEATURE)).toEqual([]);
|
|
const up = await refused(imp("../../modules/plex/plexContext", "../../shell/App"), IN_FEATURE);
|
|
expect(up).toHaveLength(2);
|
|
expect(up.every((m) => m.includes("sits below"))).toBe(true);
|
|
});
|
|
|
|
it("uses other features freely", async () => {
|
|
expect(await refused(imp("../player/ClipPanel", "../filters/SearchBar"), IN_FEATURE)).toEqual(
|
|
[],
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("the platform and lib layers", () => {
|
|
it("import nothing above them — not even a module's face", async () => {
|
|
const ui = await refused(
|
|
imp("../../features/video/VideoCard", "../../modules/feed", "../../shell/App"),
|
|
"src/components/ui/__layers_probe.tsx",
|
|
);
|
|
expect(ui).toHaveLength(3);
|
|
const lib = await refused(
|
|
imp("../features/navigation/navigationContext", "../modules/feed", "../shell/App"),
|
|
"src/lib/__layers_probe.ts",
|
|
);
|
|
expect(lib).toHaveLength(3);
|
|
});
|
|
});
|
|
|
|
describe("the shell and the entry", () => {
|
|
it("reach a module through its face, or lazily", async () => {
|
|
const src =
|
|
imp("../modules/feed/Feed", "../modules/feed") +
|
|
'export const L = () => import("../modules/feed/Feed");\n';
|
|
expect(await refused(src, "src/shell/__layers_probe.tsx")).toHaveLength(1);
|
|
});
|
|
|
|
it("only the root entry mounts the shell", async () => {
|
|
expect(await refused(imp("./shell/App"), "src/__layers_probe.tsx")).toEqual([]);
|
|
expect(await refused(imp("./modules/feed/Feed"), "src/__layers_probe.tsx")).toHaveLength(1);
|
|
});
|
|
});
|