diff --git a/internal/fix-sri.mjs b/internal/fix-sri.mjs
new file mode 100644
index 000000000..7ed08e5d8
--- /dev/null
+++ b/internal/fix-sri.mjs
@@ -0,0 +1,276 @@
+#!/usr/bin/env node
+/**
+ * fix-sri.mjs — rewrite every baked SRI hash in an ALREADY-BUILT tree, and nothing else.
+ *
+ * ★ WHY THIS EXISTS, and the premise it CORRECTS.
+ * The dispatch that asked for this said: "`bun run tree` regenerates manifest.json + leaf pages
+ * but NOT the baked SRI hashes, so `bun run check:sri` fails." MEASURED 2026-09-23, that is
+ * FALSE — generate-tree.mjs hashes each bundle (its `integrity` local, ~line 1054) and threads
+ * the value into every page it writes. Proven by poisoning one leaf's integrity
+ * (check:sri -> exit 1), running `bun run tree`, and re-checking (exit 0).
+ *
+ * So the cheap path was never missing. What was missing is the SIGNPOST and the SIZE:
+ *
+ * 1. THE SIGNPOST. check:sri's own failure text said "FIX: run a full `bun run build`". An agent
+ * that trusts the gate it just ran is then sent straight at the destructive path — whose first
+ * step deletes public/doxa-maps/ and tears the tree out from under every concurrent reader
+ * (measured 3+ times: a 461-file phantom deletion, several false "element did not mount"
+ * diagnoses). The gate was TEACHING the failure mode the build-lock guard exists to prevent.
+ * That message now names this script. A gate that diagnoses correctly and prescribes the
+ * dangerous remedy is worse than one that says nothing, because it is obeyed.
+ *
+ * 2. THE SIZE. `bun run tree` is cheap only next to a full build. MEASURED on this box: 2.7-4.5s,
+ * and it WRITES 1234 files under public/ (450 under public/doxa-maps) every single run,
+ * because it re-copies 401 runtime assets and re-emits all 48 pages. That churn is not free
+ * to your neighbours: internal/settled-tree-gate.mjs fails a tree with ANY write in the last
+ * 45s, so one `bun run tree` blinds every other agent's check:settled for three quarters of a
+ * minute, and those agents are told "do NOT measure or publish this tree."
+ *
+ * Worse, on a SHARED BRANCH `tree` regenerates pages FROM SOURCE — so it silently bakes a
+ * neighbour's in-flight .vue edits into leaf pages under YOUR change. Same family as the
+ * measured hazard of committing a built bundle on a shared branch.
+ *
+ * This script writes ONLY the pages whose hash actually disagrees. In the normal case that is
+ * one file, and when the tree is already consistent it is ZERO files — nothing to churn,
+ * nothing to un-settle, no source re-read at all.
+ *
+ * ★ WHAT IT DOES NOT DO — and this limit is the point, not an omission.
+ * It makes the HASHES AGREE. It does not make the BUNDLE CORRECT. If public/doxa-maps/x/x.js was
+ * built from stale source, this bakes a matching hash for stale code and check:sri goes green.
+ * That is equally true of `bun run tree`, and it is why this is a REPAIR verb, not a build verb:
+ * build the bundle first (`BUNDLE=x bun x vite build`), then run this to re-agree the leaves.
+ * Bundle FRESHNESS is a different question with its own gate — `bun run check:composite`.
+ * It also asserts nothing about how many leaves SHOULD exist; a tree gutted by a partial build
+ * is still unpublishable after this passes. That is settled-tree-gate.mjs's signal 4.
+ * The publish runbook still runs BOTH: `bun run check:settled && bun run check:sri`.
+ *
+ * WHAT IT REWRITES
+ * - every `\n')
+ // (b) a leaf with NO integrity at all — invisible to check:sri, still a real downgrade
+ writeFileSync(join(dir, 'b', 'q', 'index.html'),
+ '\n')
+ // (c) a manifest with a wrong bundle hash AND a wrong paste-verbatim embed snippet
+ writeFileSync(join(dir, 'manifest.json'), JSON.stringify({
+ schema: 'self-test', bundles: [{
+ bundle: 'b', scriptUrl: './b/b.js', integrity: WRONG,
+ profiles: [{ profile: 'p', embed: { script: '' } }],
+ }],
+ }, null, 2) + '\n')
+
+ // 1. THE CONTROL MUST BE ABLE TO GO RED. A fixer validated by a check that has only ever
+ // printed PASS proves nothing at all.
+ const red = spawnSync(process.execPath, [CHECKER, dir], { encoding: 'utf8' })
+ ok(red.status === 1, 'check:sri goes RED on the poisoned tree (exit ' + red.status + ', expected 1)')
+ ok(/b\/p\/index\.html/.test(red.stderr + red.stdout),
+ 'it goes red for the RIGHT page (b/p/index.html named in the output)')
+
+ // 2. repair
+ const st = fixTree(dir)
+ ok(st.rehashed >= 2, 'rehashed the stale leaf AND the stale manifest bundle (rehashed=' + st.rehashed + ', expected >=2)')
+ ok(st.added === 1, 'added the ONE missing integrity (added=' + st.added + ')')
+ ok(st.missing.length === 0, 'no unresolvable script refs (missing=' + st.missing.length + ')')
+
+ // 3. GREEN AFTER — and not vacuously green: it must have had something to check.
+ const green = spawnSync(process.execPath, [CHECKER, dir], { encoding: 'utf8' })
+ ok(green.status === 0, 'check:sri goes GREEN after the repair (exit ' + green.status + ')')
+ const checked = Number((green.stdout.match(/checked (\d+) script reference/) || [])[1] || 0)
+ ok(checked >= 2, 'the green run actually MEASURED both leaves (checked=' + checked + ', expected >=2) — a tree with nothing to compare also prints PASS')
+
+ // 4. the manifest, which check:sri never reads, is repaired too
+ const m = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8'))
+ ok(m.bundles[0].integrity === REAL, 'manifest bundles[].integrity now matches the bytes')
+ ok(m.bundles[0].profiles[0].embed.script.includes(REAL), 'the paste-verbatim embed snippet now matches the bytes')
+ ok(readFileSync(join(dir, 'b', 'q', 'index.html'), 'utf8').includes('integrity="' + REAL + '" crossorigin="anonymous"'),
+ 'the integrity-less leaf gained one, in generate-tree\'s emitted shape')
+
+ // 5. IDEMPOTENT — the whole point is that a consistent tree costs ZERO writes, so running
+ // this never un-settles the tree for a neighbouring agent.
+ const again = fixTree(dir)
+ ok(again.filesWritten.length === 0, 'a second run writes 0 file(s) (wrote ' + again.filesWritten.length + ')')
+ } finally {
+ rmSync(dir, { recursive: true, force: true })
+ }
+
+ if (fails.length) {
+ console.error('\nFIX-SRI SELF-TEST: FAIL — ' + fails.length + ' assertion(s):')
+ for (const f of fails) console.error(' ✗ ' + f)
+ process.exit(1)
+ }
+ console.log('\nFIX-SRI SELF-TEST: PASS — the repair is proven against a check that was first proven to fail.')
+}
+
+if (SELF_TEST) {
+ selfTest()
+} else {
+ let code = 0
+ for (const root of (TREES.length ? TREES : ['public/doxa-maps'])) {
+ code = report(root, fixTree(root)) || code
+ }
+ process.exit(code)
+}
diff --git a/internal/sri-consistency-check.mjs b/internal/sri-consistency-check.mjs
index f75807ce7..b52a03fdb 100644
--- a/internal/sri-consistency-check.mjs
+++ b/internal/sri-consistency-check.mjs
@@ -10,8 +10,12 @@
* THE MECHANISM (know this before you "just rebuild one bundle"):
* `BUNDLE=x bun x vite build` rewrites public/doxa-maps/x/x.js
* but the leaf page public/doxa-maps/x/x/index.html carries a BAKED
- * integrity="sha384-..." that is only regenerated by `bun run tree`
- * (internal/generate-tree.mjs), which runs ONLY in the full build.
+ * integrity="sha384-..." that the per-bundle build does not touch.
+ * Two things re-bake it. `bun run fix:sri` (internal/fix-sri.mjs) rewrites the hashes and
+ * NOTHING else. `bun run tree` (internal/generate-tree.mjs) re-bakes them as a side effect of
+ * regenerating every page from source. NEITHER needs the full build — an earlier version of
+ * this comment said generate-tree "runs ONLY in the full build", which is false (it is its own
+ * script) and is exactly why agents kept reaching for the destructive rebuild.
* So a single-bundle build leaves the bundle NEWER than its leaf. Publish that pair and
* the browser refuses to execute the script:
* "Failed to find a valid digest in the 'integrity' attribute for resource ..."
@@ -109,8 +113,22 @@ if (bad.length) {
console.error(' bundle is : ' + b.got + '...')
}
console.error('\n CAUSE: a single-bundle `BUNDLE=x bun x vite build` rewrote the .js without')
- console.error(' regenerating the leaf (only the FULL `bun run build` runs `bun run tree`).')
- console.error(' FIX : run a full `bun run build` on a SETTLED tree, then re-check. Do NOT publish.')
+ console.error(' re-baking the hash into the leaf that loads it.')
+ console.error(' FIX : bun run fix:sri # rewrites ONLY the disagreeing hashes (~0.4s, writes')
+ console.error(' # only the offending pages, leaves the tree settled)')
+ console.error(' …then re-run this check. Do NOT publish until it passes.')
+ console.error('')
+ console.error(' ★ DO NOT reach for `bun run build` here. This message used to prescribe exactly')
+ console.error(' that, and it is the destructive remedy: its first step DELETES public/doxa-maps/,')
+ console.error(' tearing the tree out from under every other agent reading it (measured 3+ times,')
+ console.error(' incl. a 461-file phantom deletion and several false "element did not mount"')
+ console.error(' diagnoses). A full rebuild cannot fix anything here that rehashing does not —')
+ console.error(' the bundles are already built; only the recorded hashes disagree.')
+ console.error(' `bun run tree` also re-bakes them correctly, but it rewrites 1234 files under')
+ console.error(' public/ every run (measured), which fails your neighbours\' check:settled for 45s')
+ console.error(' and re-reads .vue SOURCE — so on a shared branch it bakes in whatever another')
+ console.error(' agent has half-finished. Prefer fix:sri; use tree when you actually want the')
+ console.error(' pages regenerated from source.')
process.exit(1)
}
diff --git a/package.json b/package.json
index af4578fec..161082c25 100644
--- a/package.json
+++ b/package.json
@@ -58,6 +58,7 @@
"check:template-literal": "node internal/template-literal-check.mjs",
"verify:map-paint": "node internal/verify-map-paint.mjs",
"check:sri": "node internal/sri-consistency-check.mjs",
+ "fix:sri": "node internal/fix-sri.mjs",
"check:composite": "node internal/check-composite-freshness.mjs # a COMPOSITE is a COPY taken at its own build time: fails if a nested child's source is newer than the parent's bundle. NOT in build:unlocked on purpose — there everything was just built, so it could never catch the real case (a PARTIAL build). Run it before PUBLISH.",
"check:shots": "node internal/check-shot-freshness.mjs # a README screenshot that predates the bundle it shows is a picture telling a lie — and it looks IDENTICAL to a current one. Compares each shot's PNG against the BUILT BUNDLE it renders (not raw sources: an unbuilt edit is not yet in any leaf). Fix: node internal/readme-shots.mjs",
"check:library-index": "node internal/check-library-index.mjs # the DERIVED library menu must not fall behind library/. Compares the index against the newest library SOURCE (not a bundle — this generator scans library/ directly, with no build step between). Fix: bun run library-index",