build(web): fail vite build on a browser-externalized Node built-in (#1769) (#1777)

* build(web): fail vite build on a browser-externalized Node built-in (#1769)

Vite 8 (rolldown) externalizes a node:* / Node built-in that reaches the
browser graph with a warning and ships a `module.exports = {}` stub — the
build still succeeds, shipping a broken bundle. The runtime browser smoke
(smoke:web:browser, #1615) only catches the subset where that stub is *called*
at module init; a stub imported but never called ships silently and nothing
gates it.

Add a Vite build plugin that turns that specific browser-externalization
warning into a hard `vite build` error, moving detection upstream into
`npm run build` / `validate` and additionally catching the never-called case
the runtime smoke can't see.

- clients/web/server/browser-externalized-builtin-gate.ts: Vite-agnostic
  detection + error + per-build state machine (unit-tested to the ≥90 gate).
- vite.config.ts: thin Vite wiring. A throw inside rolldown's onLog is
  swallowed (the one hook where it doesn't abort — verified against vite@8.0.0),
  so the gate records the warning in onLog and re-throws in buildEnd. Scoped to
  `vite build` (apply: 'build'); the Node runner build (tsup) is unaffected.
- scripts/verify-build-gate.mjs + verify:build-gate (in npm run ci and the
  GitHub workflow): runs a real build with a node:fs probe and asserts it fails
  via the gate — the only check that catches the warning phrasing drifting in a
  future Vite bump and silently disabling the message-keyed gate.

There is no stable log `code` on the rolldown warning (only message + plugin),
so the gate keys off the documented message phrasing; the drift guard above is
what keeps that safe across Vite versions. smoke:web:browser stays as the
runtime backstop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* build(web): harden the browser-externalized-builtin gate per review (#1769)

Review follow-ups on PR #1777:

- Per-build state: add gate.reset() called from the plugin's buildStart, so a
  `vite build --watch` rebuild doesn't inherit a prior build's recorded warning
  (the plugin instance is reused across rebuilds). Makes the docblock true.
- buildEnd(error) now only asserts on the success path — rolldown also calls
  buildEnd(error) when the build already failed for another reason, and throwing
  then would mask that real error with the #1769 message.
- verify-build-gate.mjs: drop the shell `rm -rf` cache-clear entirely (verified
  the gate fires reliably without it — the earlier "caching" symptom was the
  onLog-swallow behavior, not a stale cache); add a result.error spawn-failure
  check so a missing `npx` isn't misdiagnosed as "not via the gate"; add
  SIGINT/SIGTERM handlers so an interrupt during the multi-minute build still
  restores the mutated entry; add a progress log before the spawn.
- Match the double-quote style of the sibling server/ files.
- Docs: list browser-externalized-builtin-gate.ts in the AGENTS.md server/ block
  and the clients/web/README server/ list (with why a build helper lives under
  server/), and add verify-build-gate to the root README scripts/ line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* build(web): round-2 review hardening for the #1769 build gate

- Scope the gate to the browser (`client`) environment via applyToEnvironment,
  so a future SSR/node environment built from this config isn't failed for a
  legitimate node:* import — browser-only intent is now structural.
- Report ALL externalized built-ins in one build, deduped, instead of only the
  first offender (N leaks no longer means N fix-and-rebuild cycles).
  browserExternalizedBuiltinError now takes string[]; gate uses a Set.
- verify-build-gate.mjs: pin Vite with `npx --no-install` so the drift check
  can never silently run a registry-fetched Vite instead of the repo-pinned one
  (the whole point is version fidelity); append the probe instead of prepending
  (ES imports hoist, and appending won't demote a future leading directive);
  clarify the signal-handler comment (handlers are a backstop — a blocked
  spawnSync tears down via the process group first).
- AGENTS.md: update both `ci` pipeline enumerations to include verify:build-gate
  and drop the now-false "Storybook is the only CI step left out" claim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* build(web): round-3 review polish for the #1769 build gate

- Error message: name the transitive-dependency remedy (a resolve.alias to a
  browser shim) — "remove the import / gate behind the backend" doesn't apply
  when the node:* import lives in node_modules.
- isBrowserExternalizedBuiltinLog is now a `message is string` type guard, so
  recordLog drops its redundant `!== undefined` clause while still typing the
  Set.add() — a real one-liner, not a coverage-only removal.
- verify-build-gate.mjs: docblock now states WHY it builds the real config +
  entry (catches config-level regressions — plugin removed, onwarn suppression
  added — that a temp config would miss), so the minute-long build isn't later
  "optimized" into a temp entry and silently lose that coverage. Guard the
  entry read so a renamed main.tsx fails through fail() with an actionable
  message. On a botched restore, save the captured original to a sidecar .bak
  and point there instead of recommending `git checkout --`, which would
  discard uncommitted edits.
- AGENTS.md: fix the muddled CI-steps sentence (GitHub CI runs the whole chain
  as separate steps, Storybook last) and note the applyToEnvironment client-only
  scoping in the gate prose.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* build(web): round-4 review polish — sharper verify-build-gate diagnostics (#1769)

All in scripts/verify-build-gate.mjs's failure paths:

- Split the "build SUCCEEDED" failure into three diagnoses keyed off the
  captured output, so it names the right file: warning present but build passed
  → plugin not applying (removed from `plugins`, `applyToEnvironment` env-name
  mismatch, or an `onwarn` suppression); node:fs present but phrase absent →
  phrasing drift, update the constant; neither → probe never reached the graph.
  The old message hard-coded "phrasing drifted", which misdirects for the
  config-regression class this script exists to catch. Added applyToEnvironment
  to the docblock's config-regression list too.
- Bound the build with a 10-min spawnSync timeout (+ SIGKILL); a timeout sets
  result.error (ETIMEDOUT) and reports via the existing branch, instead of
  burning to the GitHub job's 360-min default with no output.
- Guard the probe write so a read-only/EACCES checkout fails through fail()
  with an actionable message instead of escaping as a raw stack.
- SIGTERM now exits 143 (was 130, which is SIGINT's code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* build(web): round-5 review polish — self-consistent verify-build-gate (#1769)

All in scripts/verify-build-gate.mjs:

- Drift guard: assert the gate source file still contains the mirrored
  KNOWN_PHRASE literal, failing fast if the two drift. Without it, a stale
  KNOWN_PHRASE after a phrasing update would make the three-way diagnosis
  misreport a plugin-not-applying regression as a phrasing drift — the exact
  misdirection the diagnosis exists to avoid.
- Make the .bak safety net reachable on the path that needs it most: restoreEntry
  now catches its own write failure and routes to a shared saveBackupAndFail
  helper, instead of letting the write escape the finally as a raw stack (which
  skipped the .bak fallback and left the probe injected in the entry).
- Add the fourth "plugin isn't applying" cause to the diagnosis: a future Vite
  emitting the warning before the client env's buildStart reset clears it (same
  symptom as the other three).
- Flag once, on the failing-check path, that clients/web/dist holds a probe
  build so a local debugger doesn't serve it unaware.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* build(web): round-6 review polish — verify-build-gate literal contracts (#1769)

All in scripts/verify-build-gate.mjs; one theme — the literals the script
asserts on can each go stale independently of what they mirror:

- Success key: assert on the gate's distinctive thrown-error prefix
  "Build failed (#1769)", not a bare "#1769". The issue number also appears in
  vite.config.ts comments, so a code frame from an unrelated build error there
  could otherwise make the check report OK with the gate dead — the one
  direction it must never get wrong (a false pass hiding a dead gate).
- Drift guard now anchors on the BROWSER_EXTERNALIZED_BUILTIN_PHRASE assignment
  (regex) instead of a whole-file substring match, so the old wording lingering
  in a comment can't mask a changed constant; and its readFileSync of the gate
  module is guarded so a moved file fails through fail() actionably.
- Extract PROBE_MODULE ("node:fs") used to build both the PROBE and the
  diagnosis check + messages, so changing the probe module can't silently
  misroute a phrasing-drift failure into "probe never reached the graph".
- Comment the load-bearing diagnosis order (phrase before module-name).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* build(web): round-7 review polish — drift-guard both mirrored literals (#1769)

- Extract ERROR_PREFIX ("Build failed (#1769)") and drift-guard it against the
  gate module too, and use it as the success key. Previously only KNOWN_PHRASE
  was guarded; the prefix is mirrored across the same TS/.mjs boundary for the
  same reason, so rewording the gate's thrown error would make a correctly-firing
  gate report "the build broke for another reason" (benign direction, but the
  same misdirection the other guards close).
- Write the restore-failure .bak under the OS temp dir, not next to the entry:
  a sidecar inside src/ is untracked and a `git add -A` (likely while recovering)
  would sweep it into a commit.
- Branch-(b) remediation now says update BOTH the gate constant AND KNOWN_PHRASE,
  so a phrasing-drift fix lands in one pass instead of tripping the drift guard
  on the next run.
- Note next to BROWSER_EXTERNALIZED_BUILTIN_PHRASE that it must stay a single
  string literal (the script's drift guard anchors a regex on the assignment).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* build(web): round-8 review polish — safe recovery path + structural onLog order (#1769)

- saveBackupAndFail no longer claims a backup it may not have written: it tracks
  whether the write succeeded and only points the user at the .bak in that case
  (otherwise it says the probe is still injected and to restore from VCS). Uses
  mkdtempSync for a unique dir instead of a fixed tmp filename, so a
  pre-existing/foreign-owned /tmp file can't make the recovery message point at
  someone else's content.
- Guard the restore-verification readFileSync too, routing a failure through
  saveBackupAndFail so the whole restore path is uniformly safe (a raw throw
  there would skip the backup net).
- vite.config.ts: enforce: 'pre' on the gate plugin so it runs ahead of the
  normal-plugin group in the onLog chain — a plugin whose onLog returns false
  filters the log for later plugins, so trailing the array would let a future
  log-filtering plugin silently blind the gate. Makes "the gate sees every log"
  structural rather than array-position-dependent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* build(web): round-9 review polish — prove the gate fired on the probe (#1769)

The success key confirmed the #1769 gate fired, but not that OUR probe tripped
it. If the repo already leaked a Node built-in, the gate would fire on that and
the script would report OK even if the probe was tree-shaken or entryPath went
stale — branch (c)'s failure silently inverted into a pass. Add an assertion
that PROBE_MODULE appears among the gate's embedded offender warnings. Unreachable
inside `npm run ci` (validate's build:web fails first on a pre-existing leak),
but this script is a documented standalone command — run exactly when debugging
a leak, the case where this mattered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* build(web): round-10 review polish — roll back a partial probe write (#1769)

The probe writeFileSync uses the default 'w' flag (truncates at open), so a
mid-write ENOSPC/EIO could leave clients/web/src/main.tsx empty or half-written
— and its catch called fail() directly, the one path on this script that could
destroy the entry rather than merely dirty it (every other read/write is already
routed into the recovery net). Call restoreEntry() in that catch to roll the
partial write back (falling through to the .bak net if the restore itself can't
write), and correct the comment that claimed a write failure happens "before any
mutation". Also widen the enforce:'pre' comment to note it orders ahead of Vite's
core plugins too (harmless — the gate defines no resolve/load/transform).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* build(web): round-11 review polish — don't let the rollback swallow the write error (#1769)

Round 10's restoreEntry() in the probe-write catch swallowed the primary error:
restoreEntry() on its own write failure calls saveBackupAndFail → fail → exit,
so the "could not write the probe" diagnosis never ran when the rollback also
failed — which is exactly the read-only-checkout / ENOSPC case (both writes fail
at open). On a read-only checkout nothing was ever written, yet the developer
was told the restore failed and pointed at a /tmp backup of an untouched file.

Roll back inline instead: attempt the restore without exiting, track whether it
succeeded, then always report the PRIMARY write error, appending "restore from
version control" only when the rollback didn't take. Also pin the
"Build failed (#1769)" error prefix as contiguous in a single string fragment
(a comment symmetric with the phrase pin), since verify-build-gate.mjs
drift-guards on it as ERROR_PREFIX.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* build(web): round-12 review polish — don't let the pin comment defeat its own guard (#1769)

The round-11 pin comment quoted "Build failed (#1769)" verbatim, so the prefix
appeared twice in the gate module and verify-build-gate.mjs's whole-file
includes(ERROR_PREFIX) drift guard passed on the comment alone — rewording the
thrown error would have kept the guard green while a correctly-firing gate
reported "the build broke for another reason." Reworded the comment to not repeat
the full lead (matching how the phrase pin avoids repeating its phrase); the
literal now occurs exactly once, restoring the guard's meaning.

Also make the probe-write recovery message factual instead of inferred: read the
entry back and only warn "restore from version control" when it actually differs
from the original. On a read-only checkout both writes fail at open, so the file
is untouched and the old hedge needlessly implied damage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-07-25 19:45:11 -04:00 • committed by GitHub
commit 07d6316fba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -0,0 +1,350 @@
#!/usr/bin/env node
/**
* Verifies the browser-externalized-builtin build gate (#1769).
*
* The gate (clients/web/vite.config.ts +
* clients/web/server/browser-externalized-builtin-gate.ts) fails `vite build`
* when a Node built-in reaches the browser graph — Vite 8 otherwise only warns
* and ships a `{}` stub, so the broken bundle builds green. The unit tests cover
* the detection logic against a *captured* message string; only a real build can
* prove the gate still fires against the *live* Vite version, catching the one
* risk the issue calls out: the warning phrasing drifts across Vite releases
* (8.0.x → 8.1.x), silently disabling a message-keyed gate.
*
* This temporarily injects a `node:fs` import into the browser entry
* (src/main.tsx), runs `vite build`, and asserts the build FAILS with the #1769
* error, then restores the entry. Run from `npm run ci`.
*
* Why the REAL config + entry (and not a fast throwaway temp entry / generated
* config): building the actual `clients/web` config is what catches config-level
* regressions — the plugin being deleted from the `plugins` array, its
* `applyToEnvironment` no longer matching the browser environment's name, or a
* `build.rollupOptions.onwarn` suppression added above it. A temp config would
* keep passing through all of those, degrading this from "the gate works in this
* repo" to "the gate's string still matches the live Vite." That fidelity is the
* point, and it's why this accepts a full (~minute) app build and a
* source-mutation-with-restore rather than something cheaper. Do NOT "optimize"
* it into a temp entry — that silently loses the config-regression coverage.
*/
import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const webDir = path.join(repoRoot, "clients/web");
// Hardcoded browser entry. If it's ever renamed, the guarded read below fails
// with an actionable message rather than a raw ENOENT stack.
const entryPath = path.join(webDir, "src/main.tsx");
const gatePath = path.join(
webDir,
"server/browser-externalized-builtin-gate.ts",
);
// The two literals this .mjs mirrors from the gate module (it can't import the
// TS source). KNOWN_PHRASE tells apart the ways a passing build can mean the gate
// broke (see the diagnoses below); ERROR_PREFIX is the success key — the
// distinctive lead of the gate's thrown error, so an unrelated build error whose
// output merely mentions "#1769" (it's in vite.config.ts comments) can't report
// OK. The drift guard below keeps BOTH honest against the source.
const KNOWN_PHRASE = "has been externalized for browser compatibility";
const ERROR_PREFIX = "Build failed (#1769)";
// The Node built-in the probe imports. Single source of truth: the diagnosis
// below tests the output for this exact name, so deriving both from one constant
// keeps a phrasing-drift failure from being misrouted as "probe never reached
// the graph" if the probe module ever changes.
const PROBE_MODULE = "node:fs";
// A namespace import + guarded use so the built-in isn't tree-shaken before Vite
// externalizes it (a bare side-effect import can be dropped). `__never__` is
// never truthy, so the reference survives to build time without running.
// Appended (not prepended): ES imports hoist, so this still externalizes at
// resolve time, and appending won't demote a leading directive (e.g. a future
// `"use client"`) the way prepending would.
const PROBE =
`\nimport * as __nodeBuiltinProbe from "${PROBE_MODULE}";\n` +
"if (globalThis.__never__) console.log(__nodeBuiltinProbe);\n";
function fail(message, detail) {
console.error(`verify:build-gate FAILED — ${message}`);
if (detail) console.error(detail);
process.exit(1);
}
// Escape a literal for safe interpolation into a RegExp.
function escapeRegExp(literal) {
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// Write the captured original to a backup and fail — the honest remedy when the
// in-place restore can't be trusted, since it preserves any uncommitted edits
// the developer had (unlike `git checkout --`). The backup goes in a fresh
// `mkdtempSync` dir, NOT next to the entry (a sidecar inside `src/` is untracked
// and a recovery-time `git add -A` would commit it) and NOT a fixed tmp filename
// (a pre-existing/foreign-owned file would make us point the user at someone
// else's content). The message only claims a backup when the write succeeded —
// this path runs during recovery, so it must not misdirect.
function saveBackupAndFail(reason) {
let savedTo;
try {
const dir = mkdtempSync(path.join(tmpdir(), "verify-build-gate-"));
savedTo = path.join(dir, path.basename(entryPath));
writeFileSync(savedTo, original);
} catch {
savedTo = undefined;
}
const remedy = savedTo
? `pre-run contents were saved to ${savedTo}; restore from there ` +
`(it preserves uncommitted edits, unlike 'git checkout --')`
: `a backup could NOT be written — the entry still has the probe injected; ` +
`restore it from version control (this discards uncommitted edits to it) ` +
`once the filesystem is writable`;
fail(`${reason} — ${remedy}`);
}
let original;
try {
original = readFileSync(entryPath, "utf8");
} catch (err) {
fail(
`could not read the browser entry ${path.relative(repoRoot, entryPath)} ` +
`(${err.message}) — if it was renamed, update entryPath in this script`,
);
}
// Fail fast if either mirrored literal drifted from the source of truth: a stale
// KNOWN_PHRASE would make the three-way diagnosis below misreport a
// plugin-not-applying regression as a phrasing drift, and a stale ERROR_PREFIX
// would make a correctly-firing gate report "the build broke for another
// reason" (both exactly the misdirection this script exists to avoid). Anchor
// the phrase on the *assignment* — matching the whole file would let the old
// wording lingering in a comment mask a changed constant; the prefix is
// distinctive enough that a plain substring is fine.
let gateSource;
try {
gateSource = readFileSync(gatePath, "utf8");
} catch (err) {
fail(
`could not read the gate module ${path.relative(repoRoot, gatePath)} ` +
`(${err.message}) — if it moved, update gatePath in this script`,
);
}
const gateRel = path.relative(repoRoot, gatePath);
const phraseAssignment = new RegExp(
`BROWSER_EXTERNALIZED_BUILTIN_PHRASE\\s*=\\s*["'\`]${escapeRegExp(KNOWN_PHRASE)}["'\`]`,
);
if (!phraseAssignment.test(gateSource)) {
fail(
`KNOWN_PHRASE here no longer matches the BROWSER_EXTERNALIZED_BUILTIN_PHRASE ` +
`assignment in ${gateRel} — the mirrored literals drifted; re-sync them.`,
);
}
if (!gateSource.includes(ERROR_PREFIX)) {
fail(
`ERROR_PREFIX here ("${ERROR_PREFIX}") no longer appears in ${gateRel} — the ` +
`gate's thrown error was reworded; re-sync ERROR_PREFIX with it.`,
);
}
let restored = false;
// Restore the entry in place. On failure the sidecar-`.bak` path is the reachable
// safety net — letting the write escape the `finally` would skip it and leave the
// probe injected in the entry (the worst outcome this script can produce).
function restoreEntry() {
if (restored) return;
try {
writeFileSync(entryPath, original);
restored = true;
} catch (err) {
saveBackupAndFail(
`failed to restore ${path.relative(repoRoot, entryPath)} (${err.message})`,
);
}
}
// A `finally` doesn't run on Ctrl-C during the multi-minute build; restore the
// mutated entry on a signal too so an interrupt never leaves the tree dirty.
// While `spawnSync` blocks, a Ctrl-C reaches `vite` via the shared process
// group (the child dies, `spawnSync` returns, the `finally` restores) and these
// handlers run afterward as a backstop — e.g. for a `kill <pid>` that targets
// only this process, where the queued handler is the sole restore path.
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
restoreEntry();
// Conventional 128 + signal number: SIGINT → 130, SIGTERM → 143.
process.exit(signal === "SIGINT" ? 130 : 143);
});
}
// Mutate the entry (guarded), THEN wrap only the build in the restore-`finally`
// — so a write failure fails actionably before the build, and any partial write
// is rolled back rather than escaping as a raw stack. `writeFileSync`'s default
// `'w'` flag truncates at open, so a mid-write ENOSPC/EIO can leave the entry
// empty or half-written; `restoreEntry()` puts the captured original back (and
// itself falls through to the `.bak` net if it can't write).
try {
writeFileSync(entryPath, original + PROBE);
} catch (err) {
// Roll back a possible partial write ('w' truncates at open), but inline —
// NOT via restoreEntry(), whose own saveBackupAndFail→exit on a failing
// rollback would swallow this primary write error (the actual diagnosis) and,
// on a read-only checkout where nothing was ever written, misdirect the
// developer to a /tmp backup of an untouched file.
let rolledBack = false;
try {
writeFileSync(entryPath, original);
restored = rolledBack = true;
} catch {
// Reported via the damage check below.
}
// Only warn of damage when the entry actually differs from the original — on a
// read-only checkout (EACCES/EROFS, the likeliest trigger) both writes fail at
// open so the file is untouched, and "restore from version control" would
// needlessly discard uncommitted edits. Read failing → assume the worst.
let damaged = false;
if (!rolledBack) {
try {
damaged = readFileSync(entryPath, "utf8") !== original;
} catch {
damaged = true;
}
}
fail(
`could not write the probe into ${path.relative(repoRoot, entryPath)} (${err.message})` +
(damaged
? " — the entry is truncated or partially written; restore it from version control"
: ""),
);
}
let result;
try {
console.log(
"verify:build-gate: running a real `vite build` with a node:fs probe (takes a minute)…",
);
// `--no-install` pins to the locally installed (repo-pinned) Vite: the whole
// point is proving the message-keyed gate fires against THIS Vite, so `npx`
// must never silently fetch a different version from the registry when
// clients/web/node_modules is missing/partial. A missing local bin then
// surfaces via the `result.error` check below. `timeout` bounds a hung build:
// spawnSync sets `result.error` (ETIMEDOUT) on timeout, so the same branch
// reports it — otherwise a hang would burn to the GitHub job's 360-min default
// with no output (this step captures rather than inherits stdio).
result = spawnSync("npx", ["--no-install", "vite", "build"], {
cwd: webDir,
encoding: "utf8",
timeout: 10 * 60_000,
killSignal: "SIGKILL",
});
} finally {
// Always restore the entry, even if the build spawn threw.
restoreEntry();
}
// Guard against a botched restore that wrote *something* other than the original
// (distinct from restoreEntry's write throwing, which it handles itself). The
// verifying read is itself routed through saveBackupAndFail so the restore path
// is uniformly safe — a raw throw here would skip the backup net.
let afterRestore;
try {
afterRestore = readFileSync(entryPath, "utf8");
} catch (err) {
saveBackupAndFail(
`could not re-read ${path.relative(repoRoot, entryPath)} to verify the restore (${err.message})`,
);
}
if (afterRestore !== original) {
saveBackupAndFail(
`failed to restore ${path.relative(repoRoot, entryPath)} (contents differ)`,
);
}
// A spawn failure (e.g. `npx` missing) leaves `status` null with no output —
// surface it as itself rather than falling through to the "not via the gate"
// diagnosis, which would send someone chasing a build regression that isn't real.
if (result.error) {
fail(`could not run \`vite build\` (${result.error.message})`);
}
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
if (result.status === 0) {
// A passing build with a Node built-in in the browser graph means the gate
// broke — but in distinct ways, each pointing at a different file. The
// captured output distinguishes them (Vite prints the warning at the default
// log level, and no build script passes `--logLevel`, so its presence is
// reliable). All three paths completed the build, so `clients/web/dist` now
// holds a probe bundle — harmless (the probe never runs) and overwritten by
// the next `validate`/`build`; flagged once here so a local debugger doesn't
// serve it via `npm run web` unaware.
console.error(
"verify:build-gate: note — clients/web/dist now holds a probe build; run a normal build before serving it.",
);
// Order matters: a phrase match implies the probe reached the graph, so it
// must be checked before the module-name fallback — don't reorder these.
if (output.includes(KNOWN_PHRASE)) {
fail(
"vite build SUCCEEDED but Vite DID emit the externalization warning — the " +
"gate plugin isn't applying. In clients/web/vite.config.ts the plugin may " +
"have been removed from `plugins`, its `applyToEnvironment` may no longer " +
"match the browser environment's name, a `build.rollupOptions.onwarn` " +
"suppression was added above it, or a future Vite emitted the warning " +
"before the client environment's `buildStart` reset (which then cleared it).",
output,
);
}
if (output.includes(PROBE_MODULE)) {
fail(
"vite build SUCCEEDED and the warning phrasing drifted — the probe reached " +
`the graph (${PROBE_MODULE} is named) but the known phrase is absent. Update ` +
"BOTH BROWSER_EXTERNALIZED_BUILTIN_PHRASE in browser-externalized-builtin-gate.ts " +
"AND KNOWN_PHRASE in this script to the new Vite wording (the drift guard " +
"requires they stay in sync).",
output,
);
}
fail(
"vite build SUCCEEDED and the probe never reached the browser graph (neither " +
`the known phrase nor ${PROBE_MODULE} appears in the output) — the entry may ` +
"have moved or the probe was tree-shaken. Check this script's PROBE / entryPath.",
output,
);
}
// Assert on the gate's distinctive thrown-error prefix, NOT a bare "#1769":
// that issue number also appears in clients/web/vite.config.ts comments, so a
// code frame from an unrelated build error there could otherwise make this
// report OK with the gate dead. ERROR_PREFIX + KNOWN_PHRASE are the script's
// contract with the gate module, and both are drift-guarded above.
if (!output.includes(ERROR_PREFIX)) {
fail(
"vite build failed, but not via the #1769 gate — the build broke for another " +
"reason, so this check no longer proves the gate works.",
output,
);
}
// The gate fired — but confirm it fired on OUR probe, not a pre-existing leak.
// The gate's error embeds every original Vite warning, and those name the module,
// so PROBE_MODULE must appear. Without this, a repo that already leaks a built-in
// would report OK even if the probe was tree-shaken or entryPath went stale —
// branch (c)'s failure, silently inverted into a pass. (Unreachable inside
// `npm run ci`: `validate`'s `build:web` fails first on a pre-existing leak — but
// this script is a documented standalone command, run exactly when debugging one.)
if (!output.includes(PROBE_MODULE)) {
fail(
`the #1769 gate fired, but ${PROBE_MODULE} isn't among the offenders — it ` +
`tripped on a pre-existing leak, so this run doesn't prove the probe reached ` +
`the graph (was it tree-shaken, or has entryPath gone stale?).`,
output,
);
}
console.log(
"verify:build-gate OK — vite build fails on a Node built-in in the browser graph (#1769 gate fired).",
);
process.exit(0);