From 0b24f2d8fa502339cc74f61d13741d43f832f31a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 22 Oct 2026 23:48:16 +0200 Subject: [PATCH] fix(frontend): the chunk guard's logic lives in the guard, so no file sits in two TypeScript projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../chunkLeaks.test.ts => chunkGuard.test.ts} | 2 +- frontend/chunkGuard.ts | 43 ++++++++++++++++--- frontend/eslint.config.js | 9 +++- frontend/src/lib/chunkLeaks.ts | 40 ----------------- frontend/src/lib/gates.contract.test.ts | 10 ++++- frontend/src/lib/gates.snapshot.json | 2 +- frontend/tsconfig.node.json | 2 +- frontend/vitest.config.ts | 3 +- 8 files changed, 60 insertions(+), 51 deletions(-) rename frontend/{src/lib/chunkLeaks.test.ts => chunkGuard.test.ts} (96%) delete mode 100644 frontend/src/lib/chunkLeaks.ts diff --git a/frontend/src/lib/chunkLeaks.test.ts b/frontend/chunkGuard.test.ts similarity index 96% rename from frontend/src/lib/chunkLeaks.test.ts rename to frontend/chunkGuard.test.ts index ca7e75b2..675363e1 100644 --- a/frontend/src/lib/chunkLeaks.test.ts +++ b/frontend/chunkGuard.test.ts @@ -1,5 +1,5 @@ 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 chunk = (fileName: string, moduleIds: string[], imports: string[] = [], isEntry = false) => diff --git a/frontend/chunkGuard.ts b/frontend/chunkGuard.ts index 92fd575f..102b4b93 100644 --- a/frontend/chunkGuard.ts +++ b/frontend/chunkGuard.ts @@ -1,12 +1,45 @@ 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 -// entry, the shell and what they import): one static import of it on the boot path — a module's -// index.ts re-exporting it is the easy way — makes its import() a silent no-op. That rule used to be -// a hope; this makes it a build failure, naming each module and the boot chunk it landed in. +// A lazily imported page, rail, dialog or player must stay out of the chunks loaded at boot. A page +// is imported with import()/lazy() so it is fetched only when it is opened, and Rollup honours that +// only while nothing on the boot path imports the same module statically: one static import — a +// 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"; +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, 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(); + 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 { return { name: "siftlode-chunk-guard", diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 58f9320c..8ac6b818 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -845,7 +845,14 @@ export default tseslint.config( // 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 // 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], languageOptions: { ecmaVersion: 2022, diff --git a/frontend/src/lib/chunkLeaks.ts b/frontend/src/lib/chunkLeaks.ts deleted file mode 100644 index bcaa1b0a..00000000 --- a/frontend/src/lib/chunkLeaks.ts +++ /dev/null @@ -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, 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(); - 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(); -} diff --git a/frontend/src/lib/gates.contract.test.ts b/frontend/src/lib/gates.contract.test.ts index 371b4c8a..f0f2eab0 100644 --- a/frontend/src/lib/gates.contract.test.ts +++ b/frontend/src/lib/gates.contract.test.ts @@ -472,7 +472,7 @@ describe("the gate register (docs/gates.md)", () => { "reactPlugin.ts", "devOnlyGuard.ts", "chunkGuard.ts", - "src/lib/chunkLeaks.ts", + "chunkGuard.test.ts", "playwright.config.ts", "postcss.config.js", ], @@ -490,6 +490,14 @@ describe("the gate register (docs/gates.md)", () => { } 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", () => { const checked = new Set(INCLUDE["frontend/tsconfig.node.json"]); const roots = readdirSync(FRONTEND).filter((f) => /[.](ts|js|mjs|cjs)$/.test(f)); diff --git a/frontend/src/lib/gates.snapshot.json b/frontend/src/lib/gates.snapshot.json index 8b02e7bd..bb3cf52f 100644 --- a/frontend/src/lib/gates.snapshot.json +++ b/frontend/src/lib/gates.snapshot.json @@ -1287,7 +1287,7 @@ "export default defineConfig({", " plugins: [reactWithCompiler()],", " test: {", - " include: [\"src/**/*.test.{ts,tsx}\"],", + " include: [\"src/**/*.test.{ts,tsx}\", \"*.test.ts\"],", " environment: \"node\",", " coverage: {", " provider: \"v8\",", diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index 1a75e62c..918fabb1 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -44,7 +44,7 @@ "reactPlugin.ts", "devOnlyGuard.ts", "chunkGuard.ts", - "src/lib/chunkLeaks.ts", + "chunkGuard.test.ts", "playwright.config.ts", "postcss.config.js" ] diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 3af2749f..fa1e7347 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -17,7 +17,8 @@ export default defineConfig({ test: { // .tsx included on purpose: a test file the glob misses does not fail, it is simply never // 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", // 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.