From c3ebad64ff20a3d6ff157566f75af7b0830b1b85 Mon Sep 17 00:00:00 2001 From: Matt Boon Date: Thu, 13 Aug 2026 15:57:01 +0200 Subject: [PATCH 1/5] fix(client): source getEnv from its leaf so the client barrel drops the adapter graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `src/index.client.ts` is the browser/SSR-safe mirror of the `veryfront` root barrel. It re-exported `getEnv` from the broad `#veryfront/platform` barrel, which statically re-exports the eager runtime-adapter singletons (`detect.ts` → `denoAdapter`/`nodeAdapter`/`bunAdapter`). That dragged `DenoAdapter → DenoFileSystemAdapter → NodeCompatibleFileSystemAdapter` into the client bundle, where constructing it dereferences a browser-absent `fs.constants.O_NOFOLLOW` and aborts hydration (#3661) — the leak class #3025 sealed, resurfaced through a different transit barrel. `getEnv` is defined in the adapter-free leaf `platform/compat/process/env.ts` (already the canonical import for 12 other modules). Sourcing it there is behaviour-identical and removes the only static edge from the client barrel into the platform barrel, so the runtime adapters are no longer reachable. Adds a static-import boundary test that walks the value-import graph from `index.client.ts` and fails if it reaches any runtime adapter, guarding the whole regression class. Closes #3661. --- src/index.client.boundary.test.ts | 155 ++++++++++++++++++++++++++++++ src/index.client.ts | 7 +- 2 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 src/index.client.boundary.test.ts diff --git a/src/index.client.boundary.test.ts b/src/index.client.boundary.test.ts new file mode 100644 index 0000000000..36112cfc69 --- /dev/null +++ b/src/index.client.boundary.test.ts @@ -0,0 +1,155 @@ +import { assert, assertEquals } from "#veryfront/testing/assert.ts"; + +/** + * `src/index.client.ts` is the browser/SSR-safe mirror of the `veryfront` root + * barrel — the module the import rewriter redirects `veryfront` to for a browser + * target. It must never statically pull the server runtime adapters into the + * client graph: constructing `DenoAdapter → DenoFileSystemAdapter → + * NodeCompatibleFileSystemAdapter` in a browser dereferences a Node-absent + * `fs.constants.O_NOFOLLOW` and kills hydration (#3661). + * + * This walks the *static* value-import graph from `index.client.ts` (the graph + * that actually ships — dynamic `import()` is lazy and legitimately used by the + * adapter registry, and `import type` is erased) and fails if it reaches any + * server-only runtime module. Paths are resolved through `import.meta.url`, not + * the cwd, so the check is location-independent. + */ + +const REPO_ROOT = new URL("../", import.meta.url); + +/** + * The runtime adapter graph behind the #3661 crash: constructing any of these + * in a browser reaches `NodeCompatibleFileSystemAdapter`'s `O_NOFOLLOW` read. + * (The broader server→client leak surface — `adapters/fs/veryfront/*`, + * `compat/process/command`, `production-server` — is the subject of the + * fail-loud CI gate in #3670, not this focused regression.) + */ +const SERVER_ONLY_PATTERNS: readonly RegExp[] = [ + /\/platform\/adapters\/runtime\/(deno|node|bun|cloudflare)\/adapter\.ts$/, + /\/platform\/adapters\/runtime\/(deno|node|bun|cloudflare)\/filesystem-adapter\.ts$/, + /\/platform\/adapters\/runtime\/shared\/node-filesystem-adapter\.ts$/, +]; + +// Only follow value imports/exports with a `from` clause. Skipping `import type` +// / `export type` keeps erased type edges out, and requiring `from` skips bare +// side-effect and dynamic `import(...)` forms. +const STATIC_FROM_RE = + /(?:^|\n)\s*(?:import|export)\s+(?!type\b)[^;'"]*?\sfrom\s+["']([^"']+)["']/g; + +let cachedImportMap: Record | null = null; + +async function loadImportMap(): Promise> { + if (cachedImportMap) return cachedImportMap; + const denoJson = JSON.parse(await Deno.readTextFile(new URL("deno.json", REPO_ROOT))); + cachedImportMap = (denoJson.imports ?? {}) as Record; + return cachedImportMap; +} + +/** + * Resolve a specifier to a repo-relative `src/...` path, or `null` when it is + * external (npm/jsr/node/`react`) or a runtime-provided `veryfront/*` bare + * specifier the browser loads from the import map rather than the source tree. + */ +function resolveToRepoPath( + spec: string, + fromPath: string, + map: Record, +): string | null { + if (spec.startsWith("./") || spec.startsWith("../")) { + const fromDir = fromPath.slice(0, fromPath.lastIndexOf("/") + 1); + return normalize(fromDir + spec); + } + if (spec.startsWith("#")) { + let bestKey = ""; + for (const key of Object.keys(map)) { + const matches = spec === key || spec.startsWith(key.endsWith("/") ? key : key + "/"); + if (matches && key.length > bestKey.length) bestKey = key; + } + const mapped = map[bestKey]; + if (!mapped) return null; + const target = mapped.replace(/^\.\//, ""); + return normalize(target + spec.slice(bestKey.length)); + } + // Bare specifier: react, node:*, npm:*, jsr:*, veryfront/* → external. + return null; +} + +function normalize(path: string): string { + const parts: string[] = []; + for (const segment of path.split("/")) { + if (segment === "" || segment === ".") continue; + if (segment === "..") parts.pop(); + else parts.push(segment); + } + return parts.join("/"); +} + +async function readModule(repoPath: string): Promise<{ path: string; source: string } | null> { + const candidates = /\.[cm]?[jt]sx?$/.test(repoPath) + ? [repoPath] + : [repoPath + ".ts", repoPath + ".tsx", repoPath + "/index.ts", repoPath + "/index.tsx"]; + for (const candidate of candidates) { + try { + const source = await Deno.readTextFile(new URL(candidate, REPO_ROOT)); + return { path: candidate, source }; + } catch { + // try the next extension form + } + } + return null; +} + +/** Walk the static value-import graph, returning every reached source path and the edge into it. */ +async function collectStaticGraph(entry: string): Promise> { + const map = await loadImportMap(); + const reached = new Map(); + const queue: Array<{ repoPath: string; via: string | null }> = [{ repoPath: entry, via: null }]; + + while (queue.length > 0) { + const { repoPath, via } = queue.shift()!; + const mod = await readModule(repoPath); + if (!mod) continue; + if (reached.has(mod.path)) continue; + reached.set(mod.path, via); + + STATIC_FROM_RE.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = STATIC_FROM_RE.exec(mod.source)) !== null) { + const specifier = match[1]; + if (!specifier) continue; + const next = resolveToRepoPath(specifier, mod.path, map); + if (next) queue.push({ repoPath: next, via: mod.path }); + } + } + return reached; +} + +Deno.test("index.client barrel never statically reaches a server runtime adapter (#3661)", async () => { + const graph = await collectStaticGraph("src/index.client.ts"); + + // Sanity: the walk actually resolved the barrel and its neighbourhood. + assert(graph.has("src/index.client.ts"), "entry module was not read"); + assert(graph.size > 10, `expected a non-trivial graph, got ${graph.size} modules`); + + const leaks = [...graph.keys()].filter((path) => + SERVER_ONLY_PATTERNS.some((pattern) => pattern.test("/" + path)) + ); + + if (leaks.length > 0) { + const trace = (leak: string): string => { + const chain = [leak]; + let cursor: string | null | undefined = graph.get(leak); + while (cursor) { + chain.push(cursor); + cursor = graph.get(cursor); + } + return chain.reverse().join("\n → "); + }; + throw new Error( + `index.client.ts statically reaches ${leaks.length} server-only module(s):\n` + + leaks.map(trace).join("\n\n"), + ); + } + + assertEquals(leaks, []); +}); diff --git a/src/index.client.ts b/src/index.client.ts index b9d9359c93..4bf1513206 100644 --- a/src/index.client.ts +++ b/src/index.client.ts @@ -22,7 +22,12 @@ export { defineConfig, defineConfigWithEnv, mergeConfigs } from "#veryfront/config"; export type { VeryfrontConfig } from "#veryfront/config"; -export { getEnv } from "#veryfront/platform"; +// Source `getEnv` from its browser-safe leaf, not the `#veryfront/platform` +// barrel: that barrel statically re-exports the eager runtime-adapter singletons +// (`detect.ts` → Deno/Node/Bun adapters), which drags the server filesystem +// adapter graph into the client bundle and crashes hydration on a browser-absent +// `fs.constants.O_NOFOLLOW` (#3661). +export { getEnv } from "#veryfront/platform/compat/process/env.ts"; // NOTE: the server bootstrap value export (`createHandler`, `startServer`, // `toNodeHandler` from the public server entrypoint) is intentionally omitted From 63e2824f7363a5cf7ec09b4bfc2b80f3edbd5a9b Mon Sep 17 00:00:00 2001 From: Matt Boon Date: Thu, 13 Aug 2026 16:06:46 +0200 Subject: [PATCH 2/5] test(server): fail-loud client-bundle leak gate + PR-comment size report (#3670) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A client-tree entrypoint that value-imports a server helper drags the server platform into the browser — the class of regression behind #3661, where the Deno filesystem adapter reached the browser and crashed hydration on a Node-absent O_NOFOLLOW. There was no CI gate for it, so such leaks shipped silently. Adds a deterministic, framework-native boundary lint (no live server) that walks the static value-import graph of each browser entrypoint — following named/side-effect imports and re-exports, skipping erased `import type` and lazy `import()` — and fails fast when a server module reaches the client: - CRITICAL (the #3661 crash class — runtime filesystem adapters): must be zero, always; never baselineable. - SERVER-ONLY (adapters/fs/veryfront/*, veryfront-api-client, compat/process/command, production-server, distributed/redis): ratcheted against scripts/lint/client-bundle-baseline.json so a *new* leak fails fast, while the 35 pre-existing leaks stay visible to burn down. Wired into `lint:ci` as `lint:client-bundle` (lints + audits + tests), so it runs in the `ci (lint)` job. A separate `client-bundle-report` workflow posts a sticky PR comment with the per-entrypoint module count and source size (machine-readable, for a human or agent to pick up) — the Next.js pull-request-stats pattern, minus a hard byte budget. Tests cover the mechanism (catches a leak with its import chain, clears a clean entry, ignores type-only/dynamic edges, follows side-effect imports, counts bytes) and the real contract (index.client.ts reaches zero runtime adapters). Closes #3670. Stacked on the #3661 fix so the crash-class contract is green. --- .github/workflows/client-bundle-report.yml | 64 ++++++ deno.json | 5 +- scripts/lint/audit-client-bundle.ts | 255 +++++++++++++++++++++ scripts/lint/client-bundle-baseline.json | 42 ++++ scripts/lint/client-bundle-graph.test.ts | 153 +++++++++++++ scripts/lint/client-bundle-graph.ts | 190 +++++++++++++++ 6 files changed, 708 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/client-bundle-report.yml create mode 100644 scripts/lint/audit-client-bundle.ts create mode 100644 scripts/lint/client-bundle-baseline.json create mode 100644 scripts/lint/client-bundle-graph.test.ts create mode 100644 scripts/lint/client-bundle-graph.ts diff --git a/.github/workflows/client-bundle-report.yml b/.github/workflows/client-bundle-report.yml new file mode 100644 index 0000000000..aaf9b4a03d --- /dev/null +++ b/.github/workflows/client-bundle-report.yml @@ -0,0 +1,64 @@ +name: Client bundle report + +# Posts (and keeps updated) a sticky PR comment with the client-bundle boundary +# report — module count, source size, and any server-module leaks per browser +# entrypoint. This is the informational half of the #3670 gate; the hard +# fail-fast check runs in the `ci (lint)` job via `deno task lint:client-bundle`. +on: + pull_request: + branches: [main] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: client-bundle-report-${{ github.ref }} + cancel-in-progress: true + +jobs: + report: + # Same-repo PRs only: forks get a read-only token that cannot comment, and + # the repo already scopes CI to same-repo PRs. + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup-deno + timeout-minutes: 5 + - name: Compute client bundle report + run: deno task client-bundle:report > client-bundle-report.md + - name: Post sticky PR comment + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const fs = require("fs"); + const marker = ""; + const body = fs.readFileSync("client-bundle-report.md", "utf8"); + + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + }); + const existing = comments.data.find( + (c) => c.user.type === "Bot" && c.body.includes(marker), + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body, + }); + } diff --git a/deno.json b/deno.json index d658458cf8..cb4975931c 100644 --- a/deno.json +++ b/deno.json @@ -495,7 +495,7 @@ "build:storybook": "npm --prefix storybook run build-storybook", "storybook:check": "deno test --no-lock --config=scripts/test.deno.json --no-check --allow-read scripts/storybook/storybook-workbench.test.ts", "lint": "DENO_NO_PACKAGE_JSON=1 deno lint && deno lint --config=scripts/test.deno.json scripts/test/ scripts/build/dnt-meta-property-safety.ts scripts/build/dnt-meta-property-safety.test.ts scripts/build/dnt-polyfill.ts scripts/build/dnt-polyfill.test.ts scripts/build/npm-package-metadata.test.ts scripts/build/prepare-framework-sources.test.ts && deno lint --config=scripts/codemods/deno.json scripts/codemods/", - "lint:ci": "deno task lint && deno task lint:core-deps && deno task lint:cross-runtime-jsr && deno task lint:dependency-boundaries && deno task lint:module-boundaries && deno task lint:extension-contracts && deno task lint:extension-capabilities && deno task lint:ban-test-only && deno task lint:sanitizer-baseline && deno task lint:skipped-tests && deno task lint:chat-ratchets && deno task lint:chat-composability && deno task lint:rfc-status && deno task lint:esm-sh-codemod && deno task lint:test-typecheck && deno task lint:cwd-relative-test-reads && deno task lint:dnt-meta-properties && deno task storybook:check && deno task docs:api-reference:check && deno task docs:errors:check && deno task docs:public:check && deno test --frozen --config=scripts/test.deno.json --no-check --allow-read --allow-write --allow-run=bash scripts/ci/setup-deno-workflow.test.ts scripts/ci/prepare-rc-build.test.ts scripts/build/generated-artifact-checks.test.ts", + "lint:ci": "deno task lint && deno task lint:core-deps && deno task lint:cross-runtime-jsr && deno task lint:dependency-boundaries && deno task lint:module-boundaries && deno task lint:client-bundle && deno task lint:extension-contracts && deno task lint:extension-capabilities && deno task lint:ban-test-only && deno task lint:sanitizer-baseline && deno task lint:skipped-tests && deno task lint:chat-ratchets && deno task lint:chat-composability && deno task lint:rfc-status && deno task lint:esm-sh-codemod && deno task lint:test-typecheck && deno task lint:cwd-relative-test-reads && deno task lint:dnt-meta-properties && deno task storybook:check && deno task docs:api-reference:check && deno task docs:errors:check && deno task docs:public:check && deno test --frozen --config=scripts/test.deno.json --no-check --allow-read --allow-write --allow-run=bash scripts/ci/setup-deno-workflow.test.ts scripts/ci/prepare-rc-build.test.ts scripts/build/generated-artifact-checks.test.ts", "fmt": "deno fmt src/ cli/ react/ templates/ && deno fmt --config=scripts/test.deno.json scripts/test/ scripts/build/dnt-meta-property-safety.ts scripts/build/dnt-meta-property-safety.test.ts scripts/build/dnt-polyfill.ts scripts/build/dnt-polyfill.test.ts scripts/build/prepare-framework-sources.test.ts && deno fmt --config=scripts/codemods/deno.json scripts/codemods/", "fmt:check": "deno fmt --check src/ cli/ react/ templates/ && deno fmt --check --config=scripts/test.deno.json scripts/test/ scripts/build/dnt-meta-property-safety.ts scripts/build/dnt-meta-property-safety.test.ts scripts/build/dnt-polyfill.ts scripts/build/dnt-polyfill.test.ts scripts/build/prepare-framework-sources.test.ts && deno fmt --check --config=scripts/codemods/deno.json scripts/codemods/", "typecheck": "deno task generate:manifests:check && deno check src/index.ts cli/main.ts src/server/index.ts src/routing/api/index.ts src/rendering/index.ts src/platform/index.ts src/platform/adapters/index.ts src/build/index.ts src/build/production-build/index.ts src/transforms/index.ts src/config/index.ts src/utils/index.ts src/data/index.ts src/security/index.ts src/middleware/index.ts src/server/handlers/dev/index.ts src/server/handlers/request/api/index.ts src/rendering/cache/index.ts src/rendering/cache/stores/index.ts src/rendering/rsc/actions/index.ts src/html/index.ts src/html/hydration-script-builder/runtime/main.ts src/modules/index.ts src/proxy/main.ts src/react/components/ui/index.ts src/chat/index.ts src/markdown/index.ts src/mdx/index.ts src/fs/index.ts src/oauth/index.ts src/agent/index.ts src/agent/service/route-export.check.ts src/eval/index.ts src/tool/index.ts src/workflow/index.ts src/prompt/index.ts src/resource/index.ts src/runs/index.ts src/mcp/index.ts src/provider/index.ts", @@ -507,6 +507,9 @@ "lint:chat-ratchets": "deno run --allow-read scripts/lint/ban-chat-antipatterns.ts && deno check --no-config --frozen --lock=deno.lock scripts/codemods/migrate-chat-composition.ts && deno check --config=deno.json --frozen --lock=deno.lock src/react/chat-barrels.check.ts && deno test --frozen --config=scripts/codemods/deno.json --no-check --allow-read --allow-write --allow-env=BABEL_TYPES_8_BREAKING scripts/codemods/migrate-chat-composition.test.ts", "lint:esm-sh-codemod": "deno check --no-config --frozen --lock=deno.lock scripts/codemods/migrate-esm-sh-imports.ts && deno test --frozen --config=scripts/codemods/deno.json --no-check --allow-read --allow-write --allow-env=BABEL_TYPES_8_BREAKING scripts/codemods/migrate-esm-sh-imports.test.ts", "lint:test-typecheck": "deno run --allow-read --allow-run scripts/lint/check-test-typecheck-baseline.ts", + "lint:client-bundle": "deno lint --config=scripts/test.deno.json scripts/lint/client-bundle-graph.ts scripts/lint/audit-client-bundle.ts scripts/lint/client-bundle-graph.test.ts && deno run --allow-read scripts/lint/audit-client-bundle.ts && deno test --frozen --config=scripts/test.deno.json --no-check --allow-read scripts/lint/client-bundle-graph.test.ts", + "lint:client-bundle:update": "deno run --allow-read --allow-write scripts/lint/audit-client-bundle.ts --update", + "client-bundle:report": "deno run --allow-read scripts/lint/audit-client-bundle.ts --markdown", "docs": "deno run --allow-read --allow-write --allow-run --allow-env scripts/docs/generate-api-reference.ts", "docs:api-reference:check": "deno run --allow-read --allow-write --allow-run --allow-env scripts/docs/generate-api-reference.ts --check", "docs:errors": "deno run --allow-read --allow-write --allow-run --allow-env scripts/docs/generate-error-reference.ts", diff --git a/scripts/lint/audit-client-bundle.ts b/scripts/lint/audit-client-bundle.ts new file mode 100644 index 0000000000..4c2883e9ec --- /dev/null +++ b/scripts/lint/audit-client-bundle.ts @@ -0,0 +1,255 @@ +/** + * Fail-loud client-bundle boundary lint (#3670). + * + * Walks the static import graph of each browser entrypoint and fails CI when a + * server module reaches the client bundle. Two tiers: + * + * - CRITICAL (the #3661 crash class — runtime filesystem adapters): must be + * zero, always. Never baselineable. + * - SERVER-ONLY (the broader leak surface — `adapters/fs/veryfront/*`, + * `veryfront-api-client`, `compat/process/command`, …): ratcheted against a + * baseline so a *new* leak fails fast, while the pre-existing debt stays + * visible until it is burned down. Regenerate with `--update`. + * + * It also reports the client-graph size (modules + source bytes) so a size + * regression — the tell-tale of a server barrel sneaking in — surfaces on the + * PR as an annotation and a sticky comment for a human or agent to pick up. + * + * Usage: + * deno run --allow-read scripts/lint/audit-client-bundle.ts # check (fail-fast) + * deno run --allow-read --allow-write scripts/lint/audit-client-bundle.ts --update + * deno run --allow-read scripts/lint/audit-client-bundle.ts --markdown # PR-comment body + */ + +import { + type ClientGraph, + collectClientGraph, + createRealReader, + findServerOnlyLeaks, + loadImportMap, + summarizeGraph, + traceLeak, +} from "./client-bundle-graph.ts"; + +const ROOT = new URL("../../", import.meta.url); +const BASELINE_URL = new URL("client-bundle-baseline.json", import.meta.url); + +/** Browser entrypoints whose client graph must stay server-free. */ +const ENTRYPOINTS: ReadonlyArray<{ label: string; entry: string }> = [ + { label: "veryfront (browser/SSR barrel)", entry: "src/index.client.ts" }, +]; + +/** The #3661 crash class: reaching any of these in a browser aborts hydration. Never baselineable. */ +const CRITICAL_PATTERNS: readonly RegExp[] = [ + /\/platform\/adapters\/runtime\/[^/]+\/(adapter|filesystem-adapter)\.ts$/, + /\/platform\/adapters\/runtime\/shared\/node-filesystem-adapter\.ts$/, +]; + +/** + * Warn (not fail) once the client graph exceeds this many modules. The graph is + * ~382 today; a broad server barrel leak jumps it into four figures. A soft + * ceiling flags that jump for review without inventing a hard byte budget. + */ +const SIZE_WARN_MODULES = 550; + +interface Baseline { + readonly note: string; + readonly entrypoints: Record; +} + +interface EntryReport { + label: string; + entry: string; + moduleCount: number; + byteCount: number; + critical: string[]; + newLeaks: string[]; + knownLeaks: string[]; + fixedLeaks: string[]; + graph: ClientGraph; +} + +function isCritical(path: string): boolean { + return CRITICAL_PATTERNS.some((pattern) => pattern.test("/" + path)); +} + +async function readBaseline(): Promise { + try { + return JSON.parse(await Deno.readTextFile(BASELINE_URL)) as Baseline; + } catch { + return { note: "", entrypoints: {} }; + } +} + +async function analyze(baseline: Baseline): Promise { + const importMap = await loadImportMap(ROOT); + const reader = createRealReader(ROOT); + const reports: EntryReport[] = []; + + for (const { label, entry } of ENTRYPOINTS) { + const graph = await collectClientGraph(entry, importMap, reader); + const { moduleCount, byteCount } = summarizeGraph(graph); + const leaks = findServerOnlyLeaks(graph); + const allowed = new Set(baseline.entrypoints[entry] ?? []); + + const critical = leaks.filter(isCritical); + const newLeaks = leaks.filter((leak) => + !isCritical(leak) && !allowed.has(leak) + ); + const knownLeaks = leaks.filter((leak) => + !isCritical(leak) && allowed.has(leak) + ); + const present = new Set(leaks); + const fixedLeaks = [...allowed].filter((leak) => !present.has(leak)); + + reports.push({ + label, + entry, + moduleCount, + byteCount, + critical, + newLeaks, + knownLeaks, + fixedLeaks, + graph, + }); + } + return reports; +} + +function kib(bytes: number): string { + return `${(bytes / 1024).toFixed(0)} KiB`; +} + +function renderMarkdown(reports: EntryReport[]): string { + const rows = reports.map((r) => { + const leakCell = r.critical.length > 0 || r.newLeaks.length > 0 + ? `❌ ${r.critical.length + r.newLeaks.length} new` + : r.knownLeaks.length > 0 + ? `⚠️ ${r.knownLeaks.length} known` + : "✅ 0"; + const sizeFlag = r.moduleCount > SIZE_WARN_MODULES ? " ⚠️" : ""; + return `| \`${r.entry}\` | ${r.moduleCount}${sizeFlag} | ${ + kib(r.byteCount) + } | ${leakCell} |`; + }); + return [ + "", + "### 📦 Client bundle boundary", + "", + "| Entrypoint | Modules | Source size | Server leaks |", + "| --- | ---: | ---: | ---: |", + ...rows, + "", + "_A server module in a client graph aborts hydration in the browser. New leaks fail CI;" + + " known leaks are tracked in `scripts/lint/client-bundle-baseline.json` to burn down._", + ].join("\n"); +} + +function reportToJson(reports: EntryReport[]) { + return reports.map((r) => ({ + entry: r.entry, + moduleCount: r.moduleCount, + byteCount: r.byteCount, + critical: r.critical, + newLeaks: r.newLeaks, + knownLeaks: r.knownLeaks.length, + })); +} + +async function main(): Promise { + const args = new Set(Deno.args); + + if (args.has("--update")) { + const reports = await analyze({ note: "", entrypoints: {} }); + const entrypoints: Record = {}; + for (const r of reports) { + entrypoints[r.entry] = [...r.knownLeaks, ...r.newLeaks].filter((l) => + !isCritical(l) + ).toSorted(); + } + const baseline: Baseline = { + note: + "Known server modules reachable from a browser entrypoint (#3670). Burn down, never grow. " + + "Regenerate with: deno run --allow-read --allow-write scripts/lint/audit-client-bundle.ts --update", + entrypoints, + }; + await Deno.writeTextFile( + BASELINE_URL, + JSON.stringify(baseline, null, 2) + "\n", + ); + console.log(`Wrote baseline for ${reports.length} entrypoint(s).`); + return; + } + + const baseline = await readBaseline(); + const reports = await analyze(baseline); + + if (args.has("--markdown")) { + console.log(renderMarkdown(reports)); + return; + } + + // Machine-readable summary line for agents / downstream tooling. + console.log("CLIENT_BUNDLE_REPORT " + JSON.stringify(reportToJson(reports))); + + let failed = false; + for (const r of reports) { + console.log( + `${r.entry}: ${r.moduleCount} modules, ${kib(r.byteCount)} source` + + (r.knownLeaks.length + ? `, ${r.knownLeaks.length} known server leak(s)` + : ""), + ); + + for (const leak of r.critical) { + failed = true; + console.error( + `::error file=${r.entry}::CRITICAL: the client graph reaches the server runtime adapter ` + + `${leak} (the #3661 hydration crash). Import chain: ${ + traceLeak(r.graph, leak) + }`, + ); + } + for (const leak of r.newLeaks) { + failed = true; + console.error( + `::error file=${r.entry}::New server module in the client bundle: ${leak}. ` + + `Import chain: ${ + traceLeak(r.graph, leak) + }. Break the import, or (only if intentional) ` + + `re-baseline with \`deno task lint:client-bundle:update\`.`, + ); + } + if (r.fixedLeaks.length > 0) { + console.log( + `::warning file=${r.entry}::${r.fixedLeaks.length} baselined leak(s) are gone — ` + + `run \`deno task lint:client-bundle:update\` to lock in the improvement: ${ + r.fixedLeaks.join(", ") + }`, + ); + } + if (r.moduleCount > SIZE_WARN_MODULES) { + console.log( + `::warning file=${r.entry}::Client graph grew to ${r.moduleCount} modules ` + + `(${ + kib(r.byteCount) + }) — a server barrel may have leaked in; investigate before it ships.`, + ); + } + } + + if (failed) { + console.error( + "\nServer module(s) reached a client bundle. See the annotations above.", + ); + Deno.exit(1); + } + console.log( + "\nClient bundle boundary verified: no critical or new server leaks.", + ); +} + +if (import.meta.main) { + await main(); +} diff --git a/scripts/lint/client-bundle-baseline.json b/scripts/lint/client-bundle-baseline.json new file mode 100644 index 0000000000..b7e24ec557 --- /dev/null +++ b/scripts/lint/client-bundle-baseline.json @@ -0,0 +1,42 @@ +{ + "note": "Known server modules reachable from a browser entrypoint (#3670). Burn down, never grow. Regenerate with: deno run --allow-read --allow-write scripts/lint/audit-client-bundle.ts --update", + "entrypoints": { + "src/index.client.ts": [ + "src/platform/adapters/fs/veryfront/adapter-content-context.ts", + "src/platform/adapters/fs/veryfront/adapter-helpers.ts", + "src/platform/adapters/fs/veryfront/adapter.ts", + "src/platform/adapters/fs/veryfront/api-search-circuit-breaker.ts", + "src/platform/adapters/fs/veryfront/base-operations.ts", + "src/platform/adapters/fs/veryfront/cache-keys.ts", + "src/platform/adapters/fs/veryfront/content-metrics.ts", + "src/platform/adapters/fs/veryfront/default-invalidation-callbacks.ts", + "src/platform/adapters/fs/veryfront/directory-operations.ts", + "src/platform/adapters/fs/veryfront/extension-priority.ts", + "src/platform/adapters/fs/veryfront/file-list-access.ts", + "src/platform/adapters/fs/veryfront/file-list-index.ts", + "src/platform/adapters/fs/veryfront/in-flight-dedupe.ts", + "src/platform/adapters/fs/veryfront/invalidation-state.ts", + "src/platform/adapters/fs/veryfront/multi-project-adapter.ts", + "src/platform/adapters/fs/veryfront/path-normalizer.ts", + "src/platform/adapters/fs/veryfront/proxy-manager.ts", + "src/platform/adapters/fs/veryfront/read-operations-helpers.ts", + "src/platform/adapters/fs/veryfront/read-operations.ts", + "src/platform/adapters/fs/veryfront/request-context.ts", + "src/platform/adapters/fs/veryfront/retry.ts", + "src/platform/adapters/fs/veryfront/schemas/index.ts", + "src/platform/adapters/fs/veryfront/schemas/proxy-manager.schema.ts", + "src/platform/adapters/fs/veryfront/stat-operations-helpers.ts", + "src/platform/adapters/fs/veryfront/stat-operations.ts", + "src/platform/adapters/fs/veryfront/websocket-manager-helpers.ts", + "src/platform/adapters/fs/veryfront/websocket-manager.ts", + "src/platform/adapters/veryfront-api-client/client.ts", + "src/platform/adapters/veryfront-api-client/index.ts", + "src/platform/adapters/veryfront-api-client/operations.ts", + "src/platform/adapters/veryfront-api-client/retry-handler.ts", + "src/platform/adapters/veryfront-api-client/schemas/api.schema.ts", + "src/platform/adapters/veryfront-api-client/schemas/index.ts", + "src/platform/adapters/veryfront-api-client/types.ts", + "src/platform/compat/process/command.ts" + ] + } +} diff --git a/scripts/lint/client-bundle-graph.test.ts b/scripts/lint/client-bundle-graph.test.ts new file mode 100644 index 0000000000..3f18156443 --- /dev/null +++ b/scripts/lint/client-bundle-graph.test.ts @@ -0,0 +1,153 @@ +import { assert, assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + type ClientGraph, + collectClientGraph, + createRealReader, + findServerOnlyLeaks, + loadImportMap, + type ReadModule, + SERVER_ONLY_MODULE_PATTERNS, + summarizeGraph, + traceLeak, +} from "./client-bundle-graph.ts"; + +const ROOT = new URL("../../", import.meta.url); +const SRC_IMPORT_MAP = { "#veryfront/": "./src/" }; + +// The #3661 crash class — must never reach a browser entrypoint. +const CRITICAL_PATTERNS: readonly RegExp[] = [ + /\/platform\/adapters\/runtime\/[^/]+\/(adapter|filesystem-adapter)\.ts$/, + /\/platform\/adapters\/runtime\/shared\/node-filesystem-adapter\.ts$/, +]; + +function memoryReader(modules: Record): ReadModule { + return (repoPath) => { + for ( + const candidate of [repoPath, repoPath + ".ts", repoPath + "/index.ts"] + ) { + if (candidate in modules) { + return Promise.resolve({ + path: candidate, + source: modules[candidate]!, + }); + } + } + return Promise.resolve(null); + }; +} + +function graphFrom(modules: Record): Promise { + return collectClientGraph( + "app/client-entry.ts", + SRC_IMPORT_MAP, + memoryReader(modules), + ); +} + +describe("scripts/lint/client-bundle-graph", () => { + describe("findServerOnlyLeaks", () => { + it("flags a server module reached from a client entry, with the import chain", async () => { + const graph = await graphFrom({ + "app/client-entry.ts": + 'import { open } from "./helper.ts";\nexport const x = open;', + "app/helper.ts": + 'export { denoAdapter as open } from "#veryfront/platform/adapters/runtime/deno/adapter.ts";', + "src/platform/adapters/runtime/deno/adapter.ts": + "export const denoAdapter = {};", + }); + + const leaks = findServerOnlyLeaks(graph); + assertEquals(leaks, ["src/platform/adapters/runtime/deno/adapter.ts"]); + assertEquals( + traceLeak(graph, leaks[0]!), + "app/client-entry.ts → app/helper.ts → src/platform/adapters/runtime/deno/adapter.ts", + ); + }); + + it("clears a client entry that only reaches client-safe modules", async () => { + const graph = await graphFrom({ + "app/client-entry.ts": + 'import { env } from "#veryfront/platform/compat/process/env.ts";\n' + + 'import { fmt } from "./util.ts";\nexport const x = [env, fmt];', + "src/platform/compat/process/env.ts": "export const env = {};", + "app/util.ts": "export const fmt = (s) => s;", + }); + + assertEquals(findServerOnlyLeaks(graph), []); + }); + + it("ignores type-only and dynamic edges into a server module", async () => { + const graph = await graphFrom({ + "app/client-entry.ts": [ + 'import type { DenoAdapter } from "#veryfront/platform/adapters/runtime/deno/adapter.ts";', + "export async function load() {", + ' return await import("#veryfront/platform/adapters/runtime/deno/adapter.ts");', + "}", + "export type A = DenoAdapter;", + ].join("\n"), + "src/platform/adapters/runtime/deno/adapter.ts": + "export class DenoAdapter {}", + }); + + assertEquals(findServerOnlyLeaks(graph), []); + }); + + it("follows a bare side-effect import, which still ships the module", async () => { + const graph = await graphFrom({ + "app/client-entry.ts": + 'import "#veryfront/server/production-server.ts";', + "src/server/production-server.ts": "console.log('server boot');", + }); + + assertEquals(findServerOnlyLeaks(graph), [ + "src/server/production-server.ts", + ]); + }); + }); + + describe("summarizeGraph", () => { + it("counts every reached module and its source bytes", async () => { + const entry = 'import "./a.ts";'; + const dep = "export const a = 1;"; + const graph = await graphFrom({ + "app/client-entry.ts": entry, + "app/a.ts": dep, + }); + const encoder = new TextEncoder(); + + const { moduleCount, byteCount } = summarizeGraph(graph); + assertEquals(moduleCount, 2); + assertEquals( + byteCount, + encoder.encode(entry).length + encoder.encode(dep).length, + ); + }); + }); + + describe("the framework's real browser barrel", () => { + it("index.client.ts reaches no server runtime adapter (#3661 crash class)", async () => { + const graph = await collectClientGraph( + "src/index.client.ts", + await loadImportMap(ROOT), + createRealReader(ROOT), + ); + + assert( + graph.size > 10, + `expected a non-trivial graph, got ${graph.size} modules`, + ); + const critical = findServerOnlyLeaks(graph, CRITICAL_PATTERNS); + assertEquals( + critical, + [], + critical.length + ? "index.client.ts leaks a runtime adapter:\n" + + critical.map((leak) => traceLeak(graph, leak)).join("\n") + : "", + ); + // Every server-only pattern is a valid RegExp against a normalised path. + assert(SERVER_ONLY_MODULE_PATTERNS.every((p) => p instanceof RegExp)); + }); + }); +}); diff --git a/scripts/lint/client-bundle-graph.ts b/scripts/lint/client-bundle-graph.ts new file mode 100644 index 0000000000..88ad364ad0 --- /dev/null +++ b/scripts/lint/client-bundle-graph.ts @@ -0,0 +1,190 @@ +/** + * Static client-bundle import-graph analysis, shared by the fail-loud lint + * (`audit-client-bundle.ts`) and its tests. + * + * A client-tree entrypoint that value-imports a server helper drags the server + * platform into the browser — the class of regression behind #3661, where the + * Deno filesystem adapter reached the browser and crashed hydration on a + * Node-absent `O_NOFOLLOW`. This walks the *static* value-import graph that + * actually ships — following named/side-effect imports and re-exports, skipping + * erased `import type` and lazy `import()` — so a server module reaching a + * browser entrypoint is a hard error at the boundary (#3670). + */ + +export interface ClientModuleNode { + /** The module that first imported this one (`null` for the entrypoint). */ + readonly via: string | null; + /** UTF-8 byte length of this module's source. */ + readonly bytes: number; +} + +export type ClientGraph = Map; + +export type ReadModule = ( + repoPath: string, +) => Promise<{ path: string; source: string } | null>; + +/** + * Internal modules that must never appear in a browser graph. Matched against a + * leading-slash-normalised repo path so a tail match cannot be spoofed by a + * same-named project directory. + */ +export const SERVER_ONLY_MODULE_PATTERNS: readonly RegExp[] = [ + /\/platform\/adapters\/runtime\/[^/]+\/(adapter|filesystem-adapter)\.ts$/, + /\/platform\/adapters\/runtime\/shared\/node-filesystem-adapter\.ts$/, + /\/platform\/adapters\/fs\/veryfront\//, + /\/platform\/adapters\/veryfront-api-client(\.ts$|\/)/, + /\/server\/production-server\.ts$/, + /\/extensions\/distributed\/(redis-runtime-provider|owned-redis-client)\.ts$/, + /\/platform\/compat\/process\/command\.ts$/, +]; + +// Edges that actually ship code into the browser graph: +// - `import … from "x"` / `export … from "x"` — value imports and re-exports +// (`import type` / `export type` are erased and excluded); +// - `import "x"` — a bare side-effect import still evaluates the module. +// A dynamic `import("x")` is lazy (parenthesised, no `from`, no trailing quote +// after whitespace), so neither pattern matches it. +const VALUE_FROM_RE = + /(?:^|\n)\s*(?:import|export)\s+(?!type\b)[^;'"]*?\sfrom\s+["']([^"']+)["']/g; +const SIDE_EFFECT_IMPORT_RE = /(?:^|\n)\s*import\s+["']([^"']+)["']/g; + +const textEncoder = new TextEncoder(); + +export function* staticSpecifiers(source: string): Generator { + for (const pattern of [VALUE_FROM_RE, SIDE_EFFECT_IMPORT_RE]) { + pattern.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = pattern.exec(source)) !== null) { + if (match[1]) yield match[1]; + } + } +} + +export function normalizePath(path: string): string { + const parts: string[] = []; + for (const segment of path.split("/")) { + if (segment === "" || segment === ".") continue; + if (segment === "..") parts.pop(); + else parts.push(segment); + } + return parts.join("/"); +} + +/** + * Resolve a specifier to a repo-relative `src/...` path, or `null` when it is + * external (npm/jsr/node/`react`) or a runtime-provided `veryfront/*` bare + * specifier the browser loads from the import map rather than the source tree. + */ +export function resolveSpecifier( + spec: string, + fromPath: string, + importMap: Record, +): string | null { + if (spec.startsWith("./") || spec.startsWith("../")) { + const fromDir = fromPath.slice(0, fromPath.lastIndexOf("/") + 1); + return normalizePath(fromDir + spec); + } + if (spec.startsWith("#")) { + let bestKey = ""; + for (const key of Object.keys(importMap)) { + const matches = spec === key || + spec.startsWith(key.endsWith("/") ? key : key + "/"); + if (matches && key.length > bestKey.length) bestKey = key; + } + const mapped = importMap[bestKey]; + if (!mapped) return null; + return normalizePath( + mapped.replace(/^\.\//, "") + spec.slice(bestKey.length), + ); + } + // Bare specifier: react / node:* / npm:* / jsr:* / veryfront/* → external. + return null; +} + +/** Walk the static value-import graph, mapping each reached module to its edge + byte size. */ +export async function collectClientGraph( + entry: string, + importMap: Record, + readModule: ReadModule, +): Promise { + const graph: ClientGraph = new Map(); + const queue: Array<{ repoPath: string; via: string | null }> = [{ + repoPath: entry, + via: null, + }]; + + while (queue.length > 0) { + const { repoPath, via } = queue.shift()!; + const mod = await readModule(repoPath); + if (!mod || graph.has(mod.path)) continue; + graph.set(mod.path, { via, bytes: textEncoder.encode(mod.source).length }); + + for (const specifier of staticSpecifiers(mod.source)) { + const next = resolveSpecifier(specifier, mod.path, importMap); + if (next) queue.push({ repoPath: next, via: mod.path }); + } + } + return graph; +} + +export function findServerOnlyLeaks( + graph: ClientGraph, + patterns: readonly RegExp[] = SERVER_ONLY_MODULE_PATTERNS, +): string[] { + return [...graph.keys()].filter((path) => + patterns.some((pattern) => pattern.test("/" + path)) + ); +} + +/** Render the import chain into `leak`, entrypoint first, for a legible failure. */ +export function traceLeak(graph: ClientGraph, leak: string): string { + const chain = [leak]; + let cursor: string | null | undefined = graph.get(leak)?.via; + while (cursor) { + chain.push(cursor); + cursor = graph.get(cursor)?.via ?? null; + } + return chain.reverse().join(" → "); +} + +export function summarizeGraph( + graph: ClientGraph, +): { moduleCount: number; byteCount: number } { + let byteCount = 0; + for (const node of graph.values()) byteCount += node.bytes; + return { moduleCount: graph.size, byteCount }; +} + +// --- Source-tree helpers (resolve through a repo-root URL, never the cwd) --- + +export async function loadImportMap( + rootUrl: URL, +): Promise> { + const denoJson = JSON.parse( + await Deno.readTextFile(new URL("deno.json", rootUrl)), + ); + return (denoJson.imports ?? {}) as Record; +} + +export function createRealReader(rootUrl: URL): ReadModule { + return async (repoPath) => { + const candidates = /\.[cm]?[jt]sx?$/.test(repoPath) ? [repoPath] : [ + repoPath + ".ts", + repoPath + ".tsx", + repoPath + "/index.ts", + repoPath + "/index.tsx", + ]; + for (const candidate of candidates) { + try { + return { + path: candidate, + source: await Deno.readTextFile(new URL(candidate, rootUrl)), + }; + } catch { + // try the next extension form + } + } + return null; + }; +} From a386c622a09af46fd0802e4ce8db8d33e8ea2631 Mon Sep 17 00:00:00 2001 From: Matt Boon Date: Thu, 13 Aug 2026 17:40:21 +0200 Subject: [PATCH 3/5] fix(lint): correct import-map resolution + track the sandbox worker leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes the router-testing reproducer surfaced: 1. Resolver: a bare import-map key (`#veryfront/security`) maps only the exact specifier — it points at a file. The walker matched sub-paths against it, mis-resolving `#veryfront/security/sandbox/*.ts` onto the barrel file and silently dropping the edge, so the gate under-reported (index.client graph 382 → 451 modules once fixed). Only trailing-slash keys map sub-paths. 2. Patterns: add the isolation worker pool (`security/sandbox/worker-pool`, `project-worker`, `worker-error-boundary`, `worker-script`) — server modules that read `node:util` types at module scope and crash browser hydration (the `pages-server-import-leak/vector-a` `isProxy` TypeError). Now baselined as known debt so a new such leak fails fast. --- scripts/lint/client-bundle-baseline.json | 6 +++++- scripts/lint/client-bundle-graph.ts | 12 ++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/scripts/lint/client-bundle-baseline.json b/scripts/lint/client-bundle-baseline.json index b7e24ec557..a607f976df 100644 --- a/scripts/lint/client-bundle-baseline.json +++ b/scripts/lint/client-bundle-baseline.json @@ -2,6 +2,7 @@ "note": "Known server modules reachable from a browser entrypoint (#3670). Burn down, never grow. Regenerate with: deno run --allow-read --allow-write scripts/lint/audit-client-bundle.ts --update", "entrypoints": { "src/index.client.ts": [ + "src/extensions/distributed/redis-runtime-provider.ts", "src/platform/adapters/fs/veryfront/adapter-content-context.ts", "src/platform/adapters/fs/veryfront/adapter-helpers.ts", "src/platform/adapters/fs/veryfront/adapter.ts", @@ -36,7 +37,10 @@ "src/platform/adapters/veryfront-api-client/schemas/api.schema.ts", "src/platform/adapters/veryfront-api-client/schemas/index.ts", "src/platform/adapters/veryfront-api-client/types.ts", - "src/platform/compat/process/command.ts" + "src/platform/compat/process/command.ts", + "src/security/sandbox/project-worker.ts", + "src/security/sandbox/worker-error-boundary.ts", + "src/security/sandbox/worker-pool.ts" ] } } diff --git a/scripts/lint/client-bundle-graph.ts b/scripts/lint/client-bundle-graph.ts index 88ad364ad0..3ffad2091f 100644 --- a/scripts/lint/client-bundle-graph.ts +++ b/scripts/lint/client-bundle-graph.ts @@ -37,6 +37,10 @@ export const SERVER_ONLY_MODULE_PATTERNS: readonly RegExp[] = [ /\/server\/production-server\.ts$/, /\/extensions\/distributed\/(redis-runtime-provider|owned-redis-client)\.ts$/, /\/platform\/compat\/process\/command\.ts$/, + // The isolation worker pool spawns runtime workers and reads `node:util` + // types at module scope — it crashes browser hydration (surfaced by the + // `pages-server-import-leak/vector-a` reproducer's `isProxy` TypeError). + /\/security\/sandbox\/(worker-pool|project-worker|worker-error-boundary|worker-script)\.ts$/, ]; // Edges that actually ship code into the browser graph: @@ -86,10 +90,14 @@ export function resolveSpecifier( return normalizePath(fromDir + spec); } if (spec.startsWith("#")) { + // Deno import-map semantics: a bare key (`#veryfront/security`) maps only the + // exact specifier — it points at a *file*. Only a trailing-slash key + // (`#veryfront/`) maps sub-paths. Matching sub-paths against a bare key would + // mis-resolve `#veryfront/security/sandbox/x.ts` onto the security barrel file + // and silently drop the edge — hiding real server leaks. let bestKey = ""; for (const key of Object.keys(importMap)) { - const matches = spec === key || - spec.startsWith(key.endsWith("/") ? key : key + "/"); + const matches = key.endsWith("/") ? spec.startsWith(key) : spec === key; if (matches && key.length > bestKey.length) bestKey = key; } const mapped = importMap[bestKey]; From f4d148b2dfc4805d4ce6d312cfb523025ced53d8 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 13 Aug 2026 22:21:30 +0200 Subject: [PATCH 4/5] fix(lint): drop erased type-only clauses from the client graph + scope the sticky comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on #3676: - `import { type A } from "x"` / `export { type A } from "x"` are erased whole by Deno (verified on 2.7.7: the target's top-level side effects never run), so they ship nothing. `VALUE_FROM_RE` counted them as runtime edges, which could pull a type-only module into the client graph and report a false server leak. A clause holding any value binding still ships and stays an edge. The real `src/index.client.ts` graph is unchanged by this (451 modules, 3096479 bytes, 39 known leaks) — it closes a latent false positive. - The sticky PR comment now only updates a comment authored by `github-actions[bot]`, so another bot echoing the marker cannot be clobbered. --- .github/workflows/client-bundle-report.yml | 8 ++++- scripts/lint/client-bundle-graph.test.ts | 29 +++++++++++++++++ scripts/lint/client-bundle-graph.ts | 36 +++++++++++++++++----- 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/.github/workflows/client-bundle-report.yml b/.github/workflows/client-bundle-report.yml index aaf9b4a03d..b553ec1f4e 100644 --- a/.github/workflows/client-bundle-report.yml +++ b/.github/workflows/client-bundle-report.yml @@ -43,8 +43,14 @@ jobs: repo: context.repo.repo, issue_number: context.payload.pull_request.number, }); + // Only ever update our own comment: another bot that quotes the + // marker (a review bot echoing this report, say) must not be + // overwritten. github-script runs on GITHUB_TOKEN, so the sticky + // comment is always authored by github-actions[bot]. const existing = comments.data.find( - (c) => c.user.type === "Bot" && c.body.includes(marker), + (c) => + c.user?.login === "github-actions[bot]" && + c.body.includes(marker), ); if (existing) { diff --git a/scripts/lint/client-bundle-graph.test.ts b/scripts/lint/client-bundle-graph.test.ts index 3f18156443..b11254f7cd 100644 --- a/scripts/lint/client-bundle-graph.test.ts +++ b/scripts/lint/client-bundle-graph.test.ts @@ -93,6 +93,35 @@ describe("scripts/lint/client-bundle-graph", () => { assertEquals(findServerOnlyLeaks(graph), []); }); + it("ignores an inline type-only clause, which Deno erases entirely", async () => { + const graph = await graphFrom({ + "app/client-entry.ts": [ + 'import { type DenoAdapter } from "#veryfront/platform/adapters/runtime/deno/adapter.ts";', + 'export { type Fs } from "#veryfront/platform/adapters/runtime/node/adapter.ts";', + "export type A = DenoAdapter;", + ].join("\n"), + "src/platform/adapters/runtime/deno/adapter.ts": + "export class DenoAdapter {}", + "src/platform/adapters/runtime/node/adapter.ts": "export class Fs {}", + }); + + assertEquals(findServerOnlyLeaks(graph), []); + }); + + it("still follows a clause that mixes a type binding with a value binding", async () => { + const graph = await graphFrom({ + "app/client-entry.ts": + 'import { type DenoAdapter, open } from "#veryfront/platform/adapters/runtime/deno/adapter.ts";\n' + + "export const x: DenoAdapter = open;", + "src/platform/adapters/runtime/deno/adapter.ts": + "export class DenoAdapter {}\nexport const open = {};", + }); + + assertEquals(findServerOnlyLeaks(graph), [ + "src/platform/adapters/runtime/deno/adapter.ts", + ]); + }); + it("follows a bare side-effect import, which still ships the module", async () => { const graph = await graphFrom({ "app/client-entry.ts": diff --git a/scripts/lint/client-bundle-graph.ts b/scripts/lint/client-bundle-graph.ts index 3ffad2091f..b32c80eb3b 100644 --- a/scripts/lint/client-bundle-graph.ts +++ b/scripts/lint/client-bundle-graph.ts @@ -49,19 +49,41 @@ export const SERVER_ONLY_MODULE_PATTERNS: readonly RegExp[] = [ // - `import "x"` — a bare side-effect import still evaluates the module. // A dynamic `import("x")` is lazy (parenthesised, no `from`, no trailing quote // after whitespace), so neither pattern matches it. +// Group 1 is the binding clause, group 2 the specifier. const VALUE_FROM_RE = - /(?:^|\n)\s*(?:import|export)\s+(?!type\b)[^;'"]*?\sfrom\s+["']([^"']+)["']/g; + /(?:^|\n)\s*(?:import|export)\s+(?!type\b)([^;'"]*?)\sfrom\s+["']([^"']+)["']/g; const SIDE_EFFECT_IMPORT_RE = /(?:^|\n)\s*import\s+["']([^"']+)["']/g; +/** + * True for a clause whose named bindings are *all* inline-`type` — e.g. + * `{ type A }` or `{ type A as B, type C }`. Deno erases such a statement + * whole, module specifier included (verified on 2.7.7: the target's top-level + * side effects never run), so it ships nothing and must not become an edge. + * A clause holding any value binding (`{ type A, open }`, `Default, { type A }`) + * still ships and stays an edge. + */ +function isTypeOnlyClause(clause: string): boolean { + const trimmed = clause.trim(); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return false; + const bindings = trimmed.slice(1, -1).split(",").map((b) => b.trim()).filter( + (b) => b !== "", + ); + return bindings.length > 0 && bindings.every((b) => /^type\s/.test(b)); +} + const textEncoder = new TextEncoder(); export function* staticSpecifiers(source: string): Generator { - for (const pattern of [VALUE_FROM_RE, SIDE_EFFECT_IMPORT_RE]) { - pattern.lastIndex = 0; - let match: RegExpExecArray | null; - while ((match = pattern.exec(source)) !== null) { - if (match[1]) yield match[1]; - } + let match: RegExpExecArray | null; + + VALUE_FROM_RE.lastIndex = 0; + while ((match = VALUE_FROM_RE.exec(source)) !== null) { + if (match[2] && !isTypeOnlyClause(match[1] ?? "")) yield match[2]; + } + + SIDE_EFFECT_IMPORT_RE.lastIndex = 0; + while ((match = SIDE_EFFECT_IMPORT_RE.exec(source)) !== null) { + if (match[1]) yield match[1]; } } From c5e9285654f69b17058ea50b8013114eaa41e41e Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 14 Aug 2026 08:21:22 +0200 Subject: [PATCH 5/5] fix(lint): keep value edges that a type-shaped clause still ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways the client-bundle graph could drop an edge, which makes the gate report a clean bundle while a server module actually ships. `isTypeOnlyClause` read the leading word of each binding as the type modifier, so `{ type as value }` — a binding *named* `type`, renamed — looked type-only and the whole edge disappeared. Only `type X` and `type X as Y` are erased; require the modifier not be followed by `as`. `staticSpecifiers` is a generator, so it suspends mid-scan with a live `lastIndex`. The `lastIndex = 0` reset covered sequential calls but not two interleaved iterations, where one source advances the other's cursor and silently skips specifiers. Use a fresh regex instance per call. Both regressions are covered; each fails against the previous code. --- scripts/lint/client-bundle-graph.test.ts | 43 ++++++++++++++++++++++++ scripts/lint/client-bundle-graph.ts | 20 ++++++++--- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/scripts/lint/client-bundle-graph.test.ts b/scripts/lint/client-bundle-graph.test.ts index b11254f7cd..4beaecb919 100644 --- a/scripts/lint/client-bundle-graph.test.ts +++ b/scripts/lint/client-bundle-graph.test.ts @@ -8,6 +8,7 @@ import { loadImportMap, type ReadModule, SERVER_ONLY_MODULE_PATTERNS, + staticSpecifiers, summarizeGraph, traceLeak, } from "./client-bundle-graph.ts"; @@ -122,6 +123,23 @@ describe("scripts/lint/client-bundle-graph", () => { ]); }); + it("still follows `{ type as value }`, which imports a binding named type", async () => { + // `type as value` renames a binding *called* `type`; it is not the type + // modifier, so the module ships. Reading the leading word as the modifier + // dropped the edge and let a server module through the gate unseen. + const graph = await graphFrom({ + "app/client-entry.ts": + 'import { type as value } from "#veryfront/platform/adapters/runtime/deno/adapter.ts";\n' + + "export const x = value;", + "src/platform/adapters/runtime/deno/adapter.ts": + "export const type = {};", + }); + + assertEquals(findServerOnlyLeaks(graph), [ + "src/platform/adapters/runtime/deno/adapter.ts", + ]); + }); + it("follows a bare side-effect import, which still ships the module", async () => { const graph = await graphFrom({ "app/client-entry.ts": @@ -135,6 +153,31 @@ describe("scripts/lint/client-bundle-graph", () => { }); }); + describe("staticSpecifiers", () => { + it("keeps two interleaved iterations independent", () => { + // It is a generator, so it suspends mid-scan with a live `lastIndex`. A + // shared global regex would let one source advance the other's cursor and + // silently drop a specifier — a dropped edge is a leak this gate misses. + const a = 'import x from "./a1.ts";\nimport y from "./a2.ts";'; + const b = 'import p from "./b1.ts";\nimport q from "./b2.ts";'; + + const ga = staticSpecifiers(a); + const gb = staticSpecifiers(b); + const fromA: string[] = []; + const fromB: string[] = []; + for (;;) { + const ra = ga.next(); + const rb = gb.next(); + if (!ra.done) fromA.push(ra.value); + if (!rb.done) fromB.push(rb.value); + if (ra.done && rb.done) break; + } + + assertEquals(fromA, ["./a1.ts", "./a2.ts"]); + assertEquals(fromB, ["./b1.ts", "./b2.ts"]); + }); + }); + describe("summarizeGraph", () => { it("counts every reached module and its source bytes", async () => { const entry = 'import "./a.ts";'; diff --git a/scripts/lint/client-bundle-graph.ts b/scripts/lint/client-bundle-graph.ts index b32c80eb3b..2238cd11d3 100644 --- a/scripts/lint/client-bundle-graph.ts +++ b/scripts/lint/client-bundle-graph.ts @@ -68,7 +68,10 @@ function isTypeOnlyClause(clause: string): boolean { const bindings = trimmed.slice(1, -1).split(",").map((b) => b.trim()).filter( (b) => b !== "", ); - return bindings.length > 0 && bindings.every((b) => /^type\s/.test(b)); + // `type as value` imports a binding *named* `type` and renames it, so it + // ships and must stay an edge. Only `type X` and `type X as Y` are erased. + return bindings.length > 0 && + bindings.every((b) => /^type\s+(?!as\s)/.test(b)); } const textEncoder = new TextEncoder(); @@ -76,13 +79,20 @@ const textEncoder = new TextEncoder(); export function* staticSpecifiers(source: string): Generator { let match: RegExpExecArray | null; - VALUE_FROM_RE.lastIndex = 0; - while ((match = VALUE_FROM_RE.exec(source)) !== null) { + // Fresh instances per call. These are generators, so two iterations can be + // interleaved by the caller; sharing a global regex would let one advance the + // other's `lastIndex` and skip specifiers in whichever source resumed second. + const valueFrom = new RegExp(VALUE_FROM_RE.source, VALUE_FROM_RE.flags); + const sideEffect = new RegExp( + SIDE_EFFECT_IMPORT_RE.source, + SIDE_EFFECT_IMPORT_RE.flags, + ); + + while ((match = valueFrom.exec(source)) !== null) { if (match[2] && !isTypeOnlyClause(match[1] ?? "")) yield match[2]; } - SIDE_EFFECT_IMPORT_RE.lastIndex = 0; - while ((match = SIDE_EFFECT_IMPORT_RE.exec(source)) !== null) { + while ((match = sideEffect.exec(source)) !== null) { if (match[1]) yield match[1]; } }