fix(frontend): the chunk guard's logic lives in the guard, so no file sits in two TypeScript projects
The guard's logic was a src/lib file the config project also listed. The app project, which references the config project, then refuses that source on a clean build (TS6305) — while every local run reused the referenced project's cached output and stayed green; only the Docker image build failed. The logic is back inside chunkGuard.ts, its test beside it at the frontend root (vitest now also collects `*.test.ts` there), and a gate contract fails when the config project's include reaches into src/ again. The guard was re-proved on a build with a leaking re-export.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { bootLeaks, type ChunkLike } from "./chunkLeaks";
|
import { bootLeaks, type ChunkLike } from "./chunkGuard";
|
||||||
|
|
||||||
const APP = "/r/src/shell/App.tsx";
|
const APP = "/r/src/shell/App.tsx";
|
||||||
const chunk = (fileName: string, moduleIds: string[], imports: string[] = [], isEntry = false) =>
|
const chunk = (fileName: string, moduleIds: string[], imports: string[] = [], isEntry = false) =>
|
||||||
+38
-5
@@ -1,12 +1,45 @@
|
|||||||
import type { Plugin } from "vite";
|
import type { Plugin } from "vite";
|
||||||
import { bootLeaks, type ChunkLike } from "./src/lib/chunkLeaks";
|
|
||||||
|
|
||||||
// A lazily imported page, rail, dialog or player must stay out of the chunks loaded at boot (the
|
// A lazily imported page, rail, dialog or player must stay out of the chunks loaded at boot. A page
|
||||||
// entry, the shell and what they import): one static import of it on the boot path — a module's
|
// is imported with import()/lazy() so it is fetched only when it is opened, and Rollup honours that
|
||||||
// index.ts re-exporting it is the easy way — makes its import() a silent no-op. That rule used to be
|
// only while nothing on the boot path imports the same module statically: one static import — a
|
||||||
// a hope; this makes it a build failure, naming each module and the boot chunk it landed in.
|
// module's index.ts re-exporting it is the easy way — and the module joins a boot chunk, the import()
|
||||||
|
// becomes a no-op, and nothing says so. That rule used to be a hope; this makes it a build failure,
|
||||||
|
// naming each module and the boot chunk it landed in. 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.
|
||||||
const SHELL = "src/shell/App.tsx";
|
const SHELL = "src/shell/App.tsx";
|
||||||
|
|
||||||
|
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) 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 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)));
|
||||||
|
};
|
||||||
|
chunks.filter((c) => c.isEntry || c.moduleIds.some(isShell)).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();
|
||||||
|
}
|
||||||
|
|
||||||
export function chunkGuard(): Plugin {
|
export function chunkGuard(): Plugin {
|
||||||
return {
|
return {
|
||||||
name: "siftlode-chunk-guard",
|
name: "siftlode-chunk-guard",
|
||||||
|
|||||||
@@ -845,7 +845,14 @@ export default tseslint.config(
|
|||||||
// Outside src: the e2e suite and the TypeScript build-tool configs went unlinted — the
|
// Outside src: the e2e suite and the TypeScript build-tool configs went unlinted — the
|
||||||
// flat config named only src/**, so `eslint .` skipped them without a word. The same base and
|
// flat config named only src/**, so `eslint .` skipped them without a word. The same base and
|
||||||
// typed rules as the app; Node globals, since none of it runs in the browser.
|
// typed rules as the app; Node globals, since none of it runs in the browser.
|
||||||
files: ["e2e/**/*.ts", "*.config.ts", "reactPlugin.ts", "devOnlyGuard.ts", "chunkGuard.ts"],
|
files: [
|
||||||
|
"e2e/**/*.ts",
|
||||||
|
"*.config.ts",
|
||||||
|
"reactPlugin.ts",
|
||||||
|
"devOnlyGuard.ts",
|
||||||
|
"chunkGuard.ts",
|
||||||
|
"chunkGuard.test.ts",
|
||||||
|
],
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2022,
|
ecmaVersion: 2022,
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
// 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();
|
|
||||||
}
|
|
||||||
@@ -472,7 +472,7 @@ describe("the gate register (docs/gates.md)", () => {
|
|||||||
"reactPlugin.ts",
|
"reactPlugin.ts",
|
||||||
"devOnlyGuard.ts",
|
"devOnlyGuard.ts",
|
||||||
"chunkGuard.ts",
|
"chunkGuard.ts",
|
||||||
"src/lib/chunkLeaks.ts",
|
"chunkGuard.test.ts",
|
||||||
"playwright.config.ts",
|
"playwright.config.ts",
|
||||||
"postcss.config.js",
|
"postcss.config.js",
|
||||||
],
|
],
|
||||||
@@ -490,6 +490,14 @@ describe("the gate register (docs/gates.md)", () => {
|
|||||||
}
|
}
|
||||||
expect(missingFrom(found)).toEqual([]);
|
expect(missingFrom(found)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
it("puts no file in two projects", () => {
|
||||||
|
// A src file the config project also lists builds into that project's output, and the app
|
||||||
|
// project (which references it) then refuses the source (TS6305) — but only on a clean
|
||||||
|
// build: a local run reuses the referenced project's cached output and stays green, so only
|
||||||
|
// the Docker image build said so.
|
||||||
|
const node = jsonc("frontend/tsconfig.node.json").include as string[];
|
||||||
|
expect(node.filter((f) => f === "src" || f.startsWith("src/"))).toEqual([]);
|
||||||
|
});
|
||||||
it("type-checks every config file at the frontend root, or lists it", () => {
|
it("type-checks every config file at the frontend root, or lists it", () => {
|
||||||
const checked = new Set(INCLUDE["frontend/tsconfig.node.json"]);
|
const checked = new Set(INCLUDE["frontend/tsconfig.node.json"]);
|
||||||
const roots = readdirSync(FRONTEND).filter((f) => /[.](ts|js|mjs|cjs)$/.test(f));
|
const roots = readdirSync(FRONTEND).filter((f) => /[.](ts|js|mjs|cjs)$/.test(f));
|
||||||
|
|||||||
@@ -1287,7 +1287,7 @@
|
|||||||
"export default defineConfig({",
|
"export default defineConfig({",
|
||||||
" plugins: [reactWithCompiler()],",
|
" plugins: [reactWithCompiler()],",
|
||||||
" test: {",
|
" test: {",
|
||||||
" include: [\"src/**/*.test.{ts,tsx}\"],",
|
" include: [\"src/**/*.test.{ts,tsx}\", \"*.test.ts\"],",
|
||||||
" environment: \"node\",",
|
" environment: \"node\",",
|
||||||
" coverage: {",
|
" coverage: {",
|
||||||
" provider: \"v8\",",
|
" provider: \"v8\",",
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
"reactPlugin.ts",
|
"reactPlugin.ts",
|
||||||
"devOnlyGuard.ts",
|
"devOnlyGuard.ts",
|
||||||
"chunkGuard.ts",
|
"chunkGuard.ts",
|
||||||
"src/lib/chunkLeaks.ts",
|
"chunkGuard.test.ts",
|
||||||
"playwright.config.ts",
|
"playwright.config.ts",
|
||||||
"postcss.config.js"
|
"postcss.config.js"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
// .tsx included on purpose: a test file the glob misses does not fail, it is simply never
|
// .tsx included on purpose: a test file the glob misses does not fail, it is simply never
|
||||||
// collected, and the gate still prints green.
|
// collected, and the gate still prints green.
|
||||||
include: ["src/**/*.test.{ts,tsx}"],
|
// …and the build plugins' own tests beside them at the frontend root (chunkGuard).
|
||||||
|
include: ["src/**/*.test.{ts,tsx}", "*.test.ts"],
|
||||||
environment: "node",
|
environment: "node",
|
||||||
// A coverage REPORT on demand (`npm run test -- --coverage`), never enforced and not in the gate:
|
// A coverage REPORT on demand (`npm run test -- --coverage`), never enforced and not in the gate:
|
||||||
// "every regression test seen red" is the bar, not a percentage.
|
// "every regression test seen red" is the bar, not a percentage.
|
||||||
|
|||||||
Reference in New Issue
Block a user