diff --git a/internal/check-catalog-currency.mjs b/internal/check-catalog-currency.mjs index 4709a0fd2..588d55e85 100644 --- a/internal/check-catalog-currency.mjs +++ b/internal/check-catalog-currency.mjs @@ -15,12 +15,19 @@ * * check:catalog-identity is the live catalog consistent WITH ITSELF? (tamper / corruption) * check:catalog-currency is the live catalog the SAME BUILD as ours? (staleness) + * …and, since 2026-09-22, is anything we built ABSENT from it? (loss) * - * Neither subsumes the other. A tampered catalog fails identity and passes currency; a stale one - * passes identity and fails currency. + * Neither of the first two subsumes the other. A tampered catalog fails identity and passes + * currency; a stale one passes identity and fails currency. * - * SKIPs (exit 0) when the catalog is unreachable — an offline box must not fail a build. Only - * "reached it and it is behind" is a failure. + * ★ THE THIRD QUESTION USED TO BELONG TO NOBODY. A bundle that 404s live was counted "unreachable" + * and skipped, and unreachable never gated the exit — so a catalog with an ENTIRE PLUGIN DELETED + * passed with exit 0 (proven by experiment; see the MISSING ≠ UNREACHABLE note below). That is the + * exact guarantee Card 1 Step 1 asks for — "merge all bundles so nothing is LOST" — and it fell + * between two gates that each answered their own question correctly. + * + * SKIPs (exit 0) only when the catalog could not be REACHED AT ALL — an offline box must not fail + * a build. A 4xx is not an offline condition: it is the server telling us the file is not there. */ import { readFileSync, existsSync } from 'node:fs' import { createHash } from 'node:crypto' @@ -65,16 +72,43 @@ const fetchBuf = async (url) => { } finally { clearTimeout(t) } } -const stale = []; const matched = []; let unreachable = 0 +// ── MISSING ≠ UNREACHABLE ───────────────────────────────────────────────────────────────────── +// ★ THE HOLE THIS CLOSES (measured 2026-09-22). Every local bundle that failed to fetch used to be +// counted `unreachable` and skipped, and `unreachable` never gated the exit. PROVEN by deleting one +// whole bundle from a copy of the catalog and pointing this gate at it: +// CATALOG-CURRENCY: 17 current · 0 STALE · 1 unreachable (of 18 local bundles) +// CATALOG-CURRENCY: PASS ← exit 0, with an entire plugin missing from the catalog +// That is precisely the failure Card 1 Step 1 exists to prevent ("merge all bundles so nothing is +// LOST"), and no gate owned it: catalog-identity proves the live bytes are self-consistent, this +// check proves they are the same BUILD — neither asked "is anything absent?". The tell was in the +// PASS text itself: "every REACHABLE live bundle". A qualifier inside a success message is a +// description of what the check does not cover. +// +// A 404 is a STATEMENT FROM THE SERVER that the bundle is not there — that is a publish defect, not +// a network condition, so it FAILS. A connection error / timeout / DNS failure is an environment +// condition and still counts as unreachable, preserving the offline SKIP below (an offline box must +// never fail a build). +const stale = []; const matched = []; const missing = []; let unreachable = 0 for (const [bundle, wantIntegrity] of localBundles) { const { buf, err } = await fetchBuf(`${BASE}/${bundle}/${bundle}.js`) - if (err) { unreachable++; continue } + if (err) { + // Record the URL that was actually probed. An earlier version stored only a name and let the + // printer rebuild the path, which produced a mangled URL for leaf entries + // (…/index.html/…/index.html.js) — a wrong URL in a failure message sends the reader hunting + // for a file that was never requested. + if (/^HTTP 4\d\d/.test(err)) missing.push({ bundle, url: `${BASE}/${bundle}/${bundle}.js`, err }) + else unreachable++ // the network says it couldn't ask + continue + } const got = sha384(buf) if (got === wantIntegrity) matched.push(bundle) else stale.push({ bundle, want: wantIntegrity.slice(0, 22), got: got.slice(0, 22) }) } -if (unreachable === localBundles.size) { +// OFFLINE SKIP. Only when NOTHING could be asked — i.e. every bundle failed at the network layer +// and none came back as a 4xx. A single 404 means the server IS answering, so we are online and a +// missing bundle is a real finding, not an environment condition. +if (unreachable === localBundles.size && missing.length === 0) { console.log(`CATALOG-CURRENCY: SKIP — catalog unreachable at ${BASE} (${unreachable} bundle(s)). Offline is not a failure.`) process.exit(0) } @@ -94,7 +128,13 @@ for (const b of local.bundles || []) { const localPath = join(ROOT, 'public', 'doxa-maps', rel) if (!existsSync(localPath)) continue const { buf, err } = await fetchBuf(`${BASE}/${rel}`) - if (err) { leafUnreachable++; continue } + // Same MISSING-vs-UNREACHABLE split as the bundle loop above: a 4xx is the server telling us the + // leaf page is not published, which is a loss; anything else is an environment condition. + if (err) { + if (/^HTTP 4\d\d/.test(err)) missing.push({ bundle: rel, url: `${BASE}/${rel}`, err }) + else leafUnreachable++ + continue + } const want = sha384(readFileSync(localPath)) const got = sha384(buf) if (want === got) leafMatched++ @@ -104,7 +144,25 @@ console.log(`CATALOG-CURRENCY: leaves — ${leafMatched} current · ${leafStale. for (const s of leafStale) console.error(` ✗ ${s.rel}\n local ${s.want}…\n live ${s.got}…`) stale.push(...leafStale.map(s => ({ bundle: s.rel, want: s.want, got: s.got }))) -console.log(`CATALOG-CURRENCY: ${matched.length} current · ${stale.length} STALE · ${unreachable} unreachable (of ${localBundles.size} local bundles)`) +console.log(`CATALOG-CURRENCY: ${matched.length} current · ${stale.length} STALE · ` + + `${missing.length} MISSING · ${unreachable} unreachable (of ${localBundles.size} local bundles)`) + +// MISSING is reported before STALE because it is the worse finding: a stale bundle serves old code, +// an absent one serves nothing at all, and the remedy differs (re-publish vs. work out what dropped +// it from the merge). +if (missing.length) { + console.error(`\nCATALOG-CURRENCY: FAIL — ${missing.length} locally-built bundle(s) are ABSENT from the live catalog:`) + for (const m of missing) console.error(` ✗ ${m.bundle}\n ${m.url} → ${m.err}`) + console.error( + '\n Something was LOST between the build and the catalog — the merge dropped it, or the publish\n' + + ' never included it. This is the guarantee Card 1 Step 1 asks for ("merge all bundles so nothing\n' + + ' is lost"). Re-run the publish and assert the resulting bundle SET, not just the changed files:\n' + + ' rsync -a --delete public/doxa-maps/ /app/doxa-maps/ # --delete mirrors removals\n' + + ' then confirm every local bundle name appears live before pushing.' + ) + process.exit(1) +} + if (stale.length) { for (const s of stale) console.error(` ✗ ${s.bundle}\n local ${s.want}…\n live ${s.got}…`) console.error('\n The live catalog is BEHIND this build. Publish per') @@ -112,4 +170,4 @@ if (stale.length) { console.error(' (rsync public/doxa-maps/ into the catalog, then push with -c http.postBuffer=524288000).') process.exit(1) } -console.log('CATALOG-CURRENCY: PASS — every reachable live bundle matches this build.') +console.log('CATALOG-CURRENCY: PASS — every local bundle is present live and matches this build.')