Gospel checks: one dev-assets gate, and cover the gap it leaves

The gospel-only dev-assets check is gone in favour of the shared one, which is
better at the job: it tests the /__leaf/<bundle>/<profile> prefix — the one that
actually broke — across all seven map bundles, and confirms each leaf hands its
element the base it claims. The gospel version never looked at that prefix, so
keeping both would have meant two implementations and the weaker one still green
while the map was broken.

What the shared gate samples is the POOL, the files every map shares. It cannot
know this leaf also fetches gospel tables by name, so a table that stopped being
staged would leave it green and this map fetching a 404. That is the only gap
left, and this covers exactly it.

The wanted list is parsed out of resources-map.vue rather than written down,
which immediately earned itself: the hand-written list it replaces was already
two short — pray-index and gospel-link-verification were being fetched and were
not in it. A list maintained by hand is the field-list-goes-stale bug, and this
bundle has produced that three times in one day.

Poison-tested by unstaging a real table: FAIL, exit 1, then restored.
This commit is contained in:
Doxa Maps 2026-09-25 23:50:31 +00:00
parent 3ab1d7aad5
commit 0b54b0354e
4 changed files with 62 additions and 108 deletions

View file

@ -36,6 +36,7 @@ const STATIC = [
['check:stale-sweep-claim', 'no caveat outliving its truth'],
['check:click-sheet', 'the country sheet resolves, links are absolute'],
['check:snapshot-guard', 'no fetcher writes around the guard'],
['check:gospel-tables-staged', 'every asset the map fetches is in the shipped tree'],
]
const BROWSER = [
['check:leaves-mount', 'all five leaves actually mount'],

View file

@ -1,107 +0,0 @@
#!/usr/bin/env node
/**
* check-gospel-dev-assets — every asset the gospel map fetches must be reachable in the DEV
* SERVER, not only in the built tree.
*
* WHY THIS EXISTS. Every other gate for this bundle measures public/ — the built tree — and the
* Driver found the map broken where none of them look: `bun run dev`. Boundary polygons were
* missing there on five maps at once, this one included, and every built-tree gate stayed green
* throughout, because the thing that differs is how the leaf RESOLVES its asset base, not what
* the build produces.
*
* MEASURED against the dev server, headless, with curl-equivalent fetches:
* /assets/boundaries/world-adm0.geojson 404
* /dx-maps/assets/boundaries/world-adm0.geojson 200, 1,911,385 bytes
* The file is there; the base is wrong. A map with no boundaries draws no countries at all, and
* a country is half of what this map is for.
*
* NO BROWSER, deliberately. Browsers on this box are rationed to one agent, and none is needed:
* the question is whether a URL answers, which fetch settles. That also makes this gate cheap
* enough to run on every change rather than when a slot happens to be free.
*
* ⚠ IT ASSERTS BYTES, NOT JUST STATUS. A dev server that answers 200 with an HTML fallback for a
* missing .geojson is the SPA-shell trap this repo has already been caught by twice, so each
* asset must come back as the KIND of thing it claims to be.
*
* ⚠ WHAT THIS DOES *NOT* PROVE, said plainly so nobody reads a green line as the all-clear.
* It proves the assets ARE SERVED at /<tree>/assets in dev. It does NOT prove the leaf ASKS for
* that path — and the reported defect is exactly that: resolveAssetsBase falls back to './assets'
* in dev, so the map fetches /assets/… and gets a 404 while the file sits at /<tree>/assets/…
* untouched. That fix is in library/ and belongs to studio. This gate is the other half: once the
* base is right, it says whether there is anything at the end of it, and it will keep saying so
* when a table is renamed or a boundary file is dropped from the staged pool.
*
* Usage: node internal/check-gospel-dev-assets.mjs [--base http://127.0.0.1:5399]
* Start the server yourself first: bun x vite --port 5399 --strictPort
*/
import { TREE_NAME } from './_tree-name.mjs'
const argBase = process.argv.indexOf('--base')
const BASE = (argBase > -1 && process.argv[argBase + 1]) || process.env.DEV_BASE || 'http://127.0.0.1:5399'
/* Exactly what resources-map.vue fetches, by the same paths, so this cannot drift into testing
files the map does not actually want. */
const ASSETS = [
{ path: 'boundaries/world-adm0.geojson', kind: 'json', min: 100000 },
{ path: 'tables/people-group-pins.json', kind: 'json', min: 100000 },
{ path: 'tables/gospel-by-country.json', kind: 'json', min: 1000 },
{ path: 'tables/doxa-people-groups.json', kind: 'json', min: 1000 },
{ path: 'tables/country-languages.json', kind: 'json', min: 1000 },
]
let reachable = false
try {
const r = await fetch(`${BASE}/${TREE_NAME}/assets/${ASSETS[0].path}`, { signal: AbortSignal.timeout(20000) })
reachable = r.ok
} catch { /* precondition below */ }
if (!reachable) {
console.log(`DEV-ASSETS: SKIP — no dev server answering at ${BASE}.`)
console.log(' Start one on your own port first: bun x vite --port 5399 --strictPort')
process.exit(0)
}
const fails = []
for (const a of ASSETS) {
const url = `${BASE}/${TREE_NAME}/assets/${a.path}`
let res, body
try {
res = await fetch(url, { signal: AbortSignal.timeout(60000) })
body = await res.text()
} catch (e) {
fails.push(`${a.path} — ${String(e.message).slice(0, 60)}`)
continue
}
if (!res.ok) { fails.push(`${a.path} — HTTP ${res.status}`); continue }
/* The shell trap: 200 with an HTML page instead of the asset. */
if (/^\s*<!doctype html|^\s*<html/i.test(body)) {
fails.push(`${a.path} — 200 but the body is HTML, not ${a.kind}; the dev server fell back to a page`)
continue
}
if (body.length < a.min) {
fails.push(`${a.path} — 200 but only ${body.length} bytes, expected at least ${a.min}`)
continue
}
try { JSON.parse(body) } catch { fails.push(`${a.path} — 200 but does not parse as JSON`); continue }
console.log(` ok ${a.path.padEnd(38)} ${String(body.length).padStart(9)} bytes`)
}
/* THE CONTROL. If a bogus path also answers 200, every line above means nothing — the server is
answering everything and the gate cannot tell a served asset from a missing one. */
let controlOk = false
try {
const c = await fetch(`${BASE}/${TREE_NAME}/assets/tables/zzz-not-a-real-table.json`, { signal: AbortSignal.timeout(20000) })
controlOk = !c.ok
} catch { controlOk = true }
if (!controlOk) {
console.error('DEV-ASSETS: NOT MEASURED — a bogus asset path also answers 200, so this server')
console.error(' cannot distinguish a present asset from a missing one.')
process.exitCode = 2
} else if (fails.length) {
console.error(`DEV-ASSETS: FAIL — ${fails.length} asset(s) the map fetches are not usable in the dev server:`)
for (const f of fails) console.error(` ${f}`)
console.error(` The map resolves its base through resolveAssetsBase; in dev that must land on`)
console.error(` /${TREE_NAME}/assets, not /assets. A map with no boundaries draws no countries.`)
process.exitCode = 1
} else {
console.log(`DEV-ASSETS: PASS — all ${ASSETS.length} assets served under /${TREE_NAME}/assets, and a bogus path 404s.`)
}

View file

@ -0,0 +1,60 @@
#!/usr/bin/env node
/**
* check-gospel-tables-staged — every table the gospel map fetches is actually in the shipped tree.
*
* WHY THIS IS NOT check-dev-assets. That gate owns the question "does the asset pool resolve at
* every prefix a dev page can produce", across all seven map bundles, and it does it better than
* the gospel-only version this replaces — it tests the /__leaf/<bundle>/<profile> prefix, which
* is the one that actually broke, and which mine never looked at. One implementation.
*
* What it samples, though, is the POOL: world-adm0 and country-connectivity, the files common to
* every map. It cannot know that this leaf also fetches four gospel tables by name, so a table
* that stopped being staged would leave that gate green and this map fetching a 404. That is the
* only gap left once the duplicate is gone, so this covers exactly it and nothing else.
*
* Read from the SOURCE OF TRUTH rather than a second list: the fetch() calls in resources-map.vue
* are parsed out of the file, so adding a table to the leaf adds it here with no edit. A list
* maintained by hand is the field-list-goes-stale bug, which this bundle has now produced three
* times in one day.
*
* Headless, filesystem only. No server, no browser.
*/
import { readFileSync, existsSync, statSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const LEAF = resolve(ROOT, 'plugin-designs/gospel-resources/resources-map.vue')
const POOL = resolve(ROOT, 'public', TREE_NAME, 'assets')
const src = readFileSync(LEAF, 'utf8')
/* `${assetsBase.value}/tables/x.json` and `${assetsBase.value}/boundaries/y.geojson` */
const wanted = [...new Set(
[...src.matchAll(/\$\{assetsBase\.value\}\/([A-Za-z0-9_\-./]+)/g)].map((m) => m[1]),
)]
if (!wanted.length) {
console.error('GOSPEL-TABLES-STAGED: NOT MEASURED — no asset fetches found in resources-map.vue.')
console.error(' The leaf changed shape; this check would pass vacuously, which is worse than failing.')
process.exit(2)
}
const missing = []
for (const rel of wanted) {
const p = resolve(POOL, rel)
if (!existsSync(p)) { missing.push(`${rel} — not staged`); continue }
const bytes = statSync(p).size
/* A staged-but-empty file is the same outage with a friendlier face. */
if (bytes < 200) { missing.push(`${rel} — staged but only ${bytes} bytes`); continue }
console.log(` ok ${rel.padEnd(42)} ${String(bytes).padStart(9)} bytes`)
}
if (missing.length) {
console.error(`GOSPEL-TABLES-STAGED: FAIL — ${missing.length} of ${wanted.length} asset(s) the map fetches are not in the tree:`)
for (const m of missing) console.error(` ${m}`)
console.error(` Looked under public/${TREE_NAME}/assets. Run \`bun run tree\` after building this bundle.`)
process.exitCode = 1
} else {
console.log(`GOSPEL-TABLES-STAGED: PASS — all ${wanted.length} asset(s) resources-map.vue fetches are staged.`)
}

View file

@ -40,7 +40,6 @@
"check:csv-shape": "node internal/check-csv-export-shape.mjs",
"check:csv-shape:self-test": "node internal/check-csv-export-shape.mjs --self-test",
"check:deck-click": "node internal/check-deck-click.mjs",
"check:dev-assets": "node internal/check-gospel-dev-assets.mjs",
"check:dep-names": "node internal/check-dependency-names.mjs",
"check:descriptions": "node internal/check-profile-descriptions.mjs",
"check:dev-assets": "node internal/check-dev-assets.mjs",
@ -81,6 +80,7 @@
"check:gallery-embed:live-cors": "node internal/check-research-gallery-embed.mjs --live-cors",
"check:gallery-embed:maplibre": "node internal/check-research-gallery-embed.mjs --provider maplibre",
"check:gospel-all": "node internal/check-gospel-all.mjs",
"check:gospel-tables-staged": "node internal/check-gospel-tables-staged.mjs",
"check:heartbeat": "node internal/check-heartbeat-threshold.mjs",
"check:heartbeat:self-test": "node internal/check-heartbeat-threshold.mjs --self-test",
"check:host-locale-contract": "node internal/check-host-locale-contract.mjs",