doxa-maps/internal/_serves-this-tree.mjs
doxa-maps-88655c70 a9550858ed Browser gates: measure THIS build, or measure nothing
About thirty browser gates could pass while describing another worktree's build.
This machine runs several checkouts and a default port belongs to whichever
server started first — measured 2026-09-24, :4173 was another worktree's, and an
hour of probing reported confidently on code nobody here had touched. check:sri
cannot see it: a stale tree and its stale leaf agree perfectly.

Twenty gates now call requireThisTree() right after they resolve their target and
exit 75 on a mismatch — EX_TEMPFAIL, the code this repo already uses for "not
measured", which the wave runner reports as ⊘ rather than ✗. Exiting 1 would turn
a mis-pointed probe into a red product gate, which is the failure this whole
family of checks exists to prevent.

★ NARROWER THAN THE PROBLEM STATEMENT, ON PURPOSE. Three classes do NOT get it,
because there it would be wrong rather than missing:
  self-serving gates  start their own server on an OS-assigned port
  file:// gates       read this checkout directly — no server to be wrong about
  remote targets      verify-map-paint defaults to the LIVE site, and "does
                      production match what we built" is a deploy question asked
                      deliberately. MEASURED mid-rollout: it reported NOT
                      MEASURED against the live host, which was the guard being
                      wrong, not the gate. It now enforces on localhost only.

TWO THINGS THE ROLLOUT TAUGHT, both caught by running rather than reading:
  · node --check passes a file that will die on ReferenceError. Adding a ROOT
    constant to sixteen gates that do not import resolve/dirname/fileURLToPath
    parsed perfectly and crashed on first run — the same shape that broke
    check-locale-reachable twice. The imports are added under aliased names so
    they cannot collide with a gate's own.
  · Five gates take their URL from argv with no default and are left for a
    per-file pass rather than patched blind.

CONTROLS: against another worktree's server a guarded gate prints NOT MEASURED
naming the mismatch and exits 75; against this checkout's server it runs and
reports normally; against the live site it proceeds.
2026-09-25 08:20:11 +00:00

125 lines
6.6 KiB
JavaScript

/**
* _serves-this-tree.mjs — a browser gate must prove the server it talks to is THIS build.
*
* THE FAILURE THIS EXISTS FOR, MEASURED. Browser gates here default to
* http://127.0.0.1:4173 and adopt whatever answers 200 on it. On this machine that port was
* held by an unrelated process belonging to a different worktree, and the gospel-resources
* gate spent three runs reporting on a build that was not the one on disk — first a PASS,
* then a mount timeout that looked exactly like a regression in code that had nothing to do
* with it. A gate that can report on the wrong tree is worse than no gate: it produces
* confident findings about code nobody changed, and hides real ones.
*
* "Answers 200" and "is serving this checkout" are different questions. This asks the second.
*
* THE FINGERPRINT is public/doxa-maps/manifest.json, which generate-tree rewrites on every
* build and which names every bundle and profile in the tree. If the bytes served differ
* from the bytes on disk, it is a different build — a different worktree, or a stale server
* someone forgot to restart, both of which produce the same lie.
*
* Deliberately NOT fatal on its own: the caller decides whether to start its own server
* instead, or to skip. What it must never do is quietly continue.
*/
import { readFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { resolve } from 'node:path'
const MANIFEST = 'doxa-maps/manifest.json'
/**
* @param {string} base e.g. http://127.0.0.1:4173 (a trailing /doxa-maps/ is tolerated)
* @param {string} repo absolute path to the repo root
* @returns {Promise<{ok:boolean, why:string}>}
*/
export async function servesThisTree(base, repo) {
let local
try {
local = createHash('sha256').update(readFileSync(resolve(repo, 'public', MANIFEST))).digest('hex')
} catch {
return { ok: false, why: `no local public/${MANIFEST} to compare against — build the tree first` }
}
const root = String(base).replace(/\/+$/, '').replace(/\/doxa-maps$/, '')
const r = await fetch(`${root}/${MANIFEST}`).catch(() => null)
if (!r || !r.ok) return { ok: false, why: `nothing serving ${MANIFEST} at ${root}` }
const served = createHash('sha256').update(Buffer.from(await r.arrayBuffer())).digest('hex')
if (served !== local) {
return { ok: false, why: `${root} serves a DIFFERENT build than public/ — another worktree, or a server that needs restarting` }
}
return { ok: true, why: 'same build' }
}
/**
* Start a short-lived static server over THIS checkout, so a gate whose default port belongs
* to another worktree can still MEASURE instead of skipping.
*
* Skipping was the honest thing to do once the mismatch was visible, but a gate that skips on
* every wave is a gate nobody is running: check:provider-switch and check:packet-browser both
* went quiet the moment the guard landed, because their default ports (4173, 4898) are held by
* other worktrees on this box. Serving our own tree costs a few hundred milliseconds and gives
* the gate the one thing it needs — a server it can prove is this build.
*
* @param {string} dir absolute directory to serve (public/, or the repo root)
* @returns {Promise<{origin:string, stop:() => void}|null>} null when no server could be started
*/
export async function serveThisTree(dir) {
const { spawn } = await import('node:child_process')
for (let attempt = 0; attempt < 5; attempt++) {
// An ephemeral high port, re-rolled on collision — two agents doing this at once must not
// fight over one number.
const port = 20000 + Math.floor(Math.random() * 20000)
const child = spawn('python3', ['-m', 'http.server', String(port), '--bind', '127.0.0.1', '--directory', dir],
{ stdio: 'ignore', detached: false })
const origin = `http://127.0.0.1:${port}`
let up = false
for (let i = 0; i < 40 && !up; i++) {
await new Promise((r) => setTimeout(r, 100))
up = await fetch(origin + '/', { signal: AbortSignal.timeout(500) }).then((r) => r.ok).catch(() => false)
}
if (up) {
// unref so a caller that forgets to stop() still exits — a gate that hangs on its own
// helper is a worse failure than the one it was written to catch.
child.unref()
return { origin, stop: () => { try { child.kill() } catch { /* already gone */ } } }
}
try { child.kill() } catch { /* nothing to kill */ }
}
return null
}
/**
* The same check, as a one-liner a gate can put right after it resolves its base URL.
*
* ★ EXIT 75, NOT 1. A gate pointed at another worktree's server has NOT MEASURED this build — it
* has not found a defect in it. flock uses 75 for the same idea (EX_TEMPFAIL) and the wave runner
* already reports 75 as ⊘ rather than ✗, so one code means "not measured" everywhere in this repo.
* Exiting 1 here would turn a mis-pointed probe into a red product gate, which is the failure this
* whole family of checks exists to prevent.
*
* @param {string} base origin (or any URL on it) that should serve public/
* @param {string} repo absolute path to the repo root
* @param {string} label the gate's own name, for the message
*/
export async function requireThisTree(base, repo, label) {
let origin = String(base || '')
// A gate that loads file:// URLs is reading THIS checkout by construction — there is no server to
// be wrong about, so the guard stays silent rather than inventing a precondition.
if (!/^https?:/i.test(origin)) return { ok: true, why: 'not an http target' }
// ★ ONLY LOCAL SERVERS. The defect this guards is several worktrees sharing one machine's ports.
// A gate aimed at a DEPLOYED site (verify-map-paint defaults to the live host) is asking a
// different question on purpose — "does production match what we built" is a deploy check, not a
// mis-pointed probe — and enforcing local identity there would make it permanently unmeasured.
// MEASURED while rolling this out: that gate reported NOT MEASURED against the live site, which
// is the guard being wrong rather than the gate.
try {
const host = new URL(origin).hostname
if (!/^(localhost|127\.|0\.0\.0\.0$|\[?::1\]?$)/.test(host)) return { ok: true, why: 'remote target, not a worktree port' }
} catch { /* unparseable — fall through to the content check */ }
try { origin = new URL(origin).origin } catch { /* already an origin, or unusable — pass through */ }
const verdict = await servesThisTree(origin, repo)
if (/DIFFERENT build/.test(verdict.why)) {
console.log(`${label}: NOT MEASURED — ${verdict.why}`)
console.log(' Point this gate at a server for THIS checkout (bun run serve) and re-run.')
process.exit(75)
}
return verdict
}