feat(frontend): the folders are layers — an import only goes down, a module is reached through its index.ts
`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.
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { bootLeaks, type ChunkLike } from "./chunkLeaks";
|
||||
|
||||
const APP = "/r/src/shell/App.tsx";
|
||||
const chunk = (fileName: string, moduleIds: string[], imports: string[] = [], isEntry = false) =>
|
||||
({ fileName, moduleIds, imports, isEntry }) satisfies ChunkLike;
|
||||
|
||||
describe("a lazily imported module", () => {
|
||||
const lazy = new Set([APP, "/r/src/modules/plex/PlexBrowse.tsx"]);
|
||||
|
||||
it("is a leak in the entry chunk, in a chunk the entry imports, or in the App chunk", () => {
|
||||
const P = "/r/src/modules/plex/PlexBrowse.tsx";
|
||||
for (const chunks of [
|
||||
[chunk("index.js", ["/r/src/main.tsx", P], [], true)],
|
||||
[chunk("index.js", ["/r/src/main.tsx"], ["shared.js"], true), chunk("shared.js", [P])],
|
||||
[chunk("index.js", ["/r/src/main.tsx"], [], true), chunk("App.js", [APP, P])],
|
||||
[
|
||||
chunk("index.js", ["/r/src/main.tsx"], [], true),
|
||||
chunk("App.js", [APP], ["s.js"]),
|
||||
chunk("s.js", [P]),
|
||||
],
|
||||
])
|
||||
expect(bootLeaks(chunks, lazy, "src/shell/App.tsx")).toEqual([
|
||||
`${P} (${chunks[chunks.length - 1]!.fileName})`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("is not a leak in its own chunk, or in one only another lazy chunk loads — and App itself is lazy by design", () => {
|
||||
const chunks = [
|
||||
chunk("index.js", ["/r/src/main.tsx"], ["react.js"], true),
|
||||
chunk("react.js", ["/r/node_modules/react/index.js"]),
|
||||
chunk("App.js", [APP], ["react.js"]),
|
||||
chunk("PlexBrowse.js", ["/r/src/modules/plex/PlexBrowse.tsx"], ["react.js"]),
|
||||
];
|
||||
expect(bootLeaks(chunks, lazy, "src/shell/App.tsx")).toEqual([]);
|
||||
});
|
||||
|
||||
it("is checked on Windows paths too", () => {
|
||||
const win = "D:\\r\\src\\modules\\plex\\PlexBrowse.tsx";
|
||||
const chunks = [chunk("index.js", ["D:\\r\\src\\main.tsx", win], [], true)];
|
||||
expect(bootLeaks(chunks, new Set([win]), "src/shell/App.tsx")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
// Which lazily imported modules a production build loads at boot anyway — the logic behind the
|
||||
// chunkGuard build plugin (frontend/chunkGuard.ts), kept free of Vite so a unit test can drive it.
|
||||
//
|
||||
// A page, a rail, a dialog or a player is imported with import()/lazy() so it is fetched only when it
|
||||
// is opened. Rollup honours that only while nothing on the boot path imports the same module
|
||||
// statically: one static import — a re-export from a module's index.ts is the easy way to add one —
|
||||
// and the module joins a boot chunk, the import() becomes a no-op, and nothing says so. The boot path
|
||||
// is the entry chunks and what they import, plus the shell (App, itself loaded lazily but fetched
|
||||
// at boot alongside the identity probe) and what it imports.
|
||||
|
||||
export interface ChunkLike {
|
||||
fileName: string;
|
||||
isEntry: boolean;
|
||||
/** The chunks this one imports statically. */
|
||||
imports: string[];
|
||||
moduleIds: string[];
|
||||
}
|
||||
|
||||
const posix = (id: string) => id.split("\\").join("/");
|
||||
|
||||
/** Every lazily imported module (`lazy`: module ids) that sits in a boot chunk, as "id (chunk)". The
|
||||
* shell's own module (`shell`, a path under the project, e.g. "src/shell/App.tsx") is exempt: it is
|
||||
* lazy so that the public pages never load it, and its chunk is the shell. */
|
||||
export function bootLeaks(chunks: ChunkLike[], lazy: Set<string>, shell: string): string[] {
|
||||
const byName = new Map(chunks.map((c) => [c.fileName, c]));
|
||||
const isShell = (id: string) => posix(id).endsWith(`/${shell}`);
|
||||
const roots = chunks.filter((c) => c.isEntry || c.moduleIds.some(isShell));
|
||||
const boot = new Set<ChunkLike>();
|
||||
const visit = (c: ChunkLike | undefined) => {
|
||||
if (!c || boot.has(c)) return;
|
||||
boot.add(c);
|
||||
c.imports.forEach((name) => visit(byName.get(name)));
|
||||
};
|
||||
roots.forEach(visit);
|
||||
const leaks: string[] = [];
|
||||
for (const c of boot)
|
||||
for (const id of c.moduleIds)
|
||||
if (lazy.has(id) && !isShell(id)) leaks.push(`${posix(id)} (${c.fileName})`);
|
||||
return leaks.sort();
|
||||
}
|
||||
@@ -471,6 +471,8 @@ describe("the gate register (docs/gates.md)", () => {
|
||||
"vitest.config.ts",
|
||||
"reactPlugin.ts",
|
||||
"devOnlyGuard.ts",
|
||||
"chunkGuard.ts",
|
||||
"src/lib/chunkLeaks.ts",
|
||||
"playwright.config.ts",
|
||||
"postcss.config.js",
|
||||
],
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"local/glyph-link",
|
||||
"local/input-primitive",
|
||||
"local/jsx-comment-text",
|
||||
"local/layers",
|
||||
"local/literal-a11y-text",
|
||||
"local/locale-format",
|
||||
"local/sortable-a11y",
|
||||
@@ -117,6 +118,7 @@
|
||||
"local/glyph-link",
|
||||
"local/input-primitive",
|
||||
"local/jsx-comment-text",
|
||||
"local/layers",
|
||||
"local/literal-a11y-text",
|
||||
"local/locale-format",
|
||||
"local/sortable-a11y",
|
||||
@@ -642,6 +644,7 @@
|
||||
"local/glyph-link": [2],
|
||||
"local/input-primitive": [2],
|
||||
"local/jsx-comment-text": [2],
|
||||
"local/layers": [2],
|
||||
"local/literal-a11y-text": [2],
|
||||
"local/locale-format": [2],
|
||||
"local/sortable-a11y": [2],
|
||||
@@ -1055,6 +1058,7 @@
|
||||
"local/glyph-link",
|
||||
"local/input-primitive",
|
||||
"local/jsx-comment-text",
|
||||
"local/layers",
|
||||
"local/literal-a11y-text",
|
||||
"local/locale-format",
|
||||
"local/sortable-a11y",
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -15,8 +15,11 @@ const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
|
||||
// file no tsconfig includes, so those names are let into TypeScript's default project here — the
|
||||
// probe still runs the real config, typed rules included, rather than a copy with them switched off.
|
||||
const PROBE_FILES = [
|
||||
"src/__*probe.tsx",
|
||||
"src/*/__*probe.ts",
|
||||
"src/*/__*probe.tsx",
|
||||
// a file inside one module or feature (src/modules/feed/…), for the layer rule
|
||||
"src/*/*/__*probe.tsx",
|
||||
"src/components/ui/__*probe.tsx",
|
||||
"src/lib/api/__*probe.ts",
|
||||
"src/components/Button.tsx",
|
||||
|
||||
Reference in New Issue
Block a user