add a catalog CURRENCY gate: is the live tree the build we have?
Some checks are pending
build-gate / build (push) Waiting to run
Some checks are pending
build-gate / build (push) Waiting to run
check:catalog-identity hashes each live bundle against the CATALOG'S OWN manifest - deliberately, per its header. That proves the served tree is internally consistent; it can never prove it is current. A catalog three publishes behind is perfectly self-consistent and passes clean. Measured today: after baking the Mapbox token, catalog-identity reported '17 of 17 identity-proven - PASS' while every live leaf still served BAKED_TOKEN = '' and every map on the platform was broken. The two gates answer different questions and neither subsumes the other: identity is the live catalog consistent WITH ITSELF? (tamper/corruption) currency is the live catalog the SAME BUILD as ours? (staleness) It checks BOTH bundles and leaf pages, because a bundle-only version of this gate also passed: baking the token changed 37 leaf HTML pages and ZERO .js bundles. generate-tree owns the leaves, vite owns the bundles, and a publish can change either independently. Current reading: bundles 17 current, leaves 17 STALE. Negative-tested: unreachable catalog SKIPs (offline must not fail a build), a manifest yielding 0 integrity values FAILs rather than passing vacuously, and a missing local build SKIPs.
This commit is contained in:
parent
ff2c12fcff
commit
0c7bfb9b9c
2 changed files with 116 additions and 0 deletions
115
internal/check-catalog-currency.mjs
Normal file
115
internal/check-catalog-currency.mjs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-catalog-currency.mjs — is the LIVE catalog serving what we have BUILT?
|
||||
*
|
||||
* THE BLIND SPOT THIS FILLS. `check:catalog-identity` hashes each live bundle and compares it to
|
||||
* the CATALOG'S OWN manifest integrity — deliberately, per its own header: "COMPARE AGAINST THE
|
||||
* CATALOG'S OWN MANIFEST, NOT THE LOCAL ONE." That proves the served tree is INTERNALLY
|
||||
* CONSISTENT. It can never prove the served tree is CURRENT: a catalog three publishes behind is
|
||||
* perfectly self-consistent and passes with a clean PASS.
|
||||
*
|
||||
* Measured 2026-09-21, which is why this exists: after baking the Mapbox token locally,
|
||||
* `check:catalog-identity` reported "17 of 17 identity-proven — PASS" while the live leaf still
|
||||
* served `BAKED_TOKEN = ""`, i.e. every map on the platform was still broken. Two gates, two
|
||||
* questions:
|
||||
*
|
||||
* 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)
|
||||
*
|
||||
* Neither 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.
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const args = process.argv.slice(2)
|
||||
const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d }
|
||||
const BASE = (argOf('--base', 'https://nuxtbase.app/plugin-assets/doxa-maps')).replace(/\/+$/, '')
|
||||
const TIMEOUT = Number(argOf('--timeout', 25000))
|
||||
|
||||
const sha384 = (buf) => 'sha384-' + createHash('sha384').update(buf).digest('base64')
|
||||
|
||||
const localManifestPath = join(ROOT, 'public', 'doxa-maps', 'manifest.json')
|
||||
if (!existsSync(localManifestPath)) {
|
||||
console.log('CATALOG-CURRENCY: SKIP — no local build (public/doxa-maps/manifest.json missing). Run `bun run build`.')
|
||||
process.exit(0)
|
||||
}
|
||||
const local = JSON.parse(readFileSync(localManifestPath, 'utf8'))
|
||||
const localBundles = new Map()
|
||||
for (const b of local.bundles || []) {
|
||||
// VERIFIED against the real manifest shape 2026-09-21: integrity sits at bundle.integrity
|
||||
// (bundle keys are bundle/scriptUrl/output/integrity/elements/profiles). It is NOT under
|
||||
// profiles[].embed — that object holds only `iframe` and `script` snippet strings.
|
||||
if (b.integrity) localBundles.set(b.bundle, b.integrity)
|
||||
}
|
||||
if (!localBundles.size) {
|
||||
console.error('CATALOG-CURRENCY: FAIL — parsed 0 integrity values from the local manifest; the matcher is broken.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const fetchBuf = async (url) => {
|
||||
const ctl = new AbortController()
|
||||
const t = setTimeout(() => ctl.abort(), TIMEOUT)
|
||||
try {
|
||||
const r = await fetch(url, { signal: ctl.signal, redirect: 'follow' })
|
||||
if (!r.ok) return { err: `HTTP ${r.status}` }
|
||||
return { buf: Buffer.from(await r.arrayBuffer()) }
|
||||
} catch (e) {
|
||||
return { err: String(e.message || e).slice(0, 60) }
|
||||
} finally { clearTimeout(t) }
|
||||
}
|
||||
|
||||
const stale = []; const matched = []; let unreachable = 0
|
||||
for (const [bundle, wantIntegrity] of localBundles) {
|
||||
const { buf, err } = await fetchBuf(`${BASE}/${bundle}/${bundle}.js`)
|
||||
if (err) { unreachable++; 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) {
|
||||
console.log(`CATALOG-CURRENCY: SKIP — catalog unreachable at ${BASE} (${unreachable} bundle(s)). Offline is not a failure.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// ── LEAF PAGES TOO, and this half is not optional ─────────────────────────────────────────────
|
||||
// Measured 2026-09-21: baking the Mapbox token changed 37 LEAF HTML pages and ZERO .js bundles
|
||||
// (BAKED_TOKEN is emitted by generate-tree into each leaf, never into the bundle). A bundle-only
|
||||
// currency check therefore reported "17 current, 0 STALE — PASS" while every live leaf still
|
||||
// served BAKED_TOKEN = "" and every map on the platform was broken.
|
||||
// The lesson generalises: generate-tree owns the leaf pages, vite owns the bundles, and a publish
|
||||
// can change EITHER independently. Checking one proves nothing about the other.
|
||||
const leafStale = []; let leafMatched = 0; let leafUnreachable = 0
|
||||
for (const b of local.bundles || []) {
|
||||
const profile = b.profiles?.[0]?.profile
|
||||
if (!profile) continue
|
||||
const rel = `${b.bundle}/${profile}/index.html`
|
||||
const localPath = join(ROOT, 'public', 'doxa-maps', rel)
|
||||
if (!existsSync(localPath)) continue
|
||||
const { buf, err } = await fetchBuf(`${BASE}/${rel}`)
|
||||
if (err) { leafUnreachable++; continue }
|
||||
const want = sha384(readFileSync(localPath))
|
||||
const got = sha384(buf)
|
||||
if (want === got) leafMatched++
|
||||
else leafStale.push({ rel, want: want.slice(0, 22), got: got.slice(0, 22) })
|
||||
}
|
||||
console.log(`CATALOG-CURRENCY: leaves — ${leafMatched} current · ${leafStale.length} STALE · ${leafUnreachable} unreachable`)
|
||||
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)`)
|
||||
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')
|
||||
console.error(' _qa-cycle/03-deployment-cycle/delegation/RUNBOOK-publish-bundles-to-catalog.md')
|
||||
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.')
|
||||
|
|
@ -57,6 +57,7 @@
|
|||
"capability-exemplars": "node internal/generate-capability-exemplars.mjs # regenerate docs/reference-capability-exemplars.md — the DERIVED answer to \"which bundle is the capability-N example?\", a question answered WRONGLY in three separate artifacts (a gate, the README, the /plugin-designer skill).",
|
||||
"check:a11y-button-names": "node internal/check-a11y-button-names.mjs # every VISIBLE button must have an accessible name (aria-label / aria-labelledby / visible text — a `title` is a TOOLTIP and does NOT count). Measures the RENDERED DOM through shadow roots: a source grep cannot answer this (132 `<button>` hits, most with visible text). Needs a browser, so NOT in build:unlocked and NOT in CI. ALL=1 sweeps every profile.",
|
||||
"check:catalog-identity": "node internal/check-catalog-identity.mjs # proves the LIVE server serves the CATALOG’s exact bytes, by hashing each live bundle and comparing sha384 against the manifest’s own integrity field. An HTTP 200 cannot do this: /app/doxa-maps/ returns 200 with a 2872-byte HTML SIGN-IN PAGE. Network-dependent, so NOT in build:unlocked and NOT in CI; SKIPs cleanly when unreachable. CATALOG=<dir> also requires the live manifest to match a catalog checkout.",
|
||||
"check:catalog-currency": "node internal/check-catalog-currency.mjs",
|
||||
"check:capability-exemplars": "node internal/check-capability-exemplars.mjs # REGENERATES and content-compares (not mtimes: a full build rewrites manifest.json every time, so an mtime rule would cry wolf after every build and get switched off).",
|
||||
"check:capability-citations": "node internal/check-capability-citations.mjs # a doc that names a bundle as \"capability N\" must match manifest.json, which is what the BUILD computed. README captioned doxa-vision-card as cap-1; it ships 2 instances so it is parameterized (cap-2). The SAME error was fixed once in verify-5-capabilities.mjs and never reached the README — two copies of one fact, nothing forcing agreement.",
|
||||
"check:shot-distinctness": "node internal/check-shot-distinctness.mjs # two README slots must not show the SAME view. README shipped \"List — page 1\" and \"page 2\" as the same picture: the shot chased a Next control and a scroll that DO NOT EXIST, every attempt inside try/catch so the miss was silent, and the PNGs differed by 0.16% render noise so no checksum caught them. Keys on url+VIEWPORT — same url at a different vp (deploy2 mobile) is intended.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue