doxa-maps/internal/verify-gospel-map.mjs
doxa-maps-88655c70 44ef31b2c6 Tooling reads the tree's name from the build config, in one place
The output tree's name has always been configurable (vite.doxa-maps-build-config.json "name"),
but 61 tooling files ignored it and hardcoded 'doxa-maps' (181 literals), so a rename would have
moved the build while the gates kept reading the old folder.

- internal/_tree-name.mjs reads it once (validated as one URL-safe path segment; falls back to the
  historical name) and exports TREE_NAME / TREE_NAME_RE; `node internal/_tree-name.mjs` prints it
  for shell scripts.
- A codemod rewrote 171 literals in internal/ to TREE_NAME; the regex literals, messages with
  backticks, atomic-public-swap.sh (fails loudly if the name cannot be read), build-lock.sh and
  vite.config.js (its duplicate readMfeName is gone) were done by hand.
- The name is unchanged: old and new tooling produce all 1408 files under public/ byte for byte.
  Code lines still naming the tree in internal/ + vite.config.js: 2, both intended (the helper's
  fallback, the private-repo-name pattern in public-hygiene-check).

This renames nothing visible; the eventual rename becomes a one-line config change.
2026-09-25 16:25:22 +00:00

238 lines
12 KiB
JavaScript

#!/usr/bin/env node
/**
* verify-gospel-map.mjs — the gospel-resources map must render its pins and answer a tap.
*
* WHY THIS IS A GATE AND NOT A SCRATCH PROBE. Every browser check written for this bundle
* lived in _qa-cycle/, which is gitignored: a dozen probes that proved the pins render, the
* sheet opens with real links, and the deep link filters the table — all of which vanish
* with the working tree. check:coverage-consistent guards the DATA and cannot see the map.
* The repo has carded orphaned gates twice; this is the same loss one step earlier, a gate
* that was never committed at all.
*
* WHAT IT ASSERTS, against the SHIPPED tree through the production-parity server:
* 1. the element mounts and exposes window.__grState — a stale-SRI leaf silently refuses
* to execute its script, which looks exactly like a mount failure;
* 2. every pin in the source is RENDERED, not merely loaded;
* 3. tapping a pin opens a group whose language is NAMED and whose links are real URLs
* belonging to the screened hosts — never a fabricated or off-list link;
* 4. the gap filter actually filters: fewer features, and only tier 0 left;
* 5. the sheet's "every resource" link carries the group's own ISO code.
*
* ★ WAIT FOR IDLE BEFORE COUNTING. queryRenderedFeatures before first paint returns 0,
* which is indistinguishable from a layer that is not painting — the first version of this
* check reported a false negative on itself for exactly that reason.
*
* ★ AND THE HOST LIST IS THE DOCTRINE BOUNDARY, enforced here rather than trusted: only
* eBible/PNG.Bible/Door43/JESUS Film may appear. A link to an excluded publisher would be
* a doctrinal failure, not a broken link, so it fails this gate loudly.
*
* ★ IT STARTS ITS OWN SERVER. Browser gates in this repo rot because they need a server
* someone else remembered to start: verify:browser-all is invoked NOWHERE, build:unlocked
* runs only `bun run verify`, and verify-all.mjs skips anything marked browser:true unless
* --full is passed. The repo has already lost two gates that way — "both had rotted
* unnoticed precisely because the build chain has never run them". A gate with a manual
* precondition is a gate that will not run, so this one removes the precondition: if
* nothing answers, it spawns serve-strict on an ephemeral port and shuts it down after.
*
* An explicit BASE_URL or argv[2] is honoured as-is and never auto-served — a caller
* naming a host means it, and silently pointing at a different tree would be worse than
* failing.
*
* USAGE: node internal/verify-gospel-map.mjs [baseUrl] (no setup needed)
* Skips loudly (exit 0) only if the tree is not built, the way CI sees it.
*/
import { chromium } from 'playwright'
import { servesThisTree } from './_serves-this-tree.mjs'
import { spawn } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
const BASE = (process.env.BASE_URL || process.argv[2] || 'http://127.0.0.1:4173').replace(/\/+$/, '')
const GL = ['--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', '--enable-webgl', '--no-sandbox']
// The screened publishers. Anything else is a doctrine failure, not a typo.
const ALLOWED = /^(https:\/\/)?([a-z0-9-]+\.)*(ebible\.org|png\.bible|alkitab\.pw|git\.door43\.org|jesusfilm\.org|arc\.gt|zume\.training|bible\.is|knowgod\.com)(\/|$)/i
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '..')
const EXPLICIT = !!(process.env.BASE_URL || process.argv[2])
let base = BASE
let server = null
// ADOPTING A SERVER REQUIRES PROVING IT SERVES *THIS* TREE.
// This used to accept anything that answered 200 on the default port, and that is not the
// same question. Measured: port 4173 was held by an unrelated python3 process belonging to
// another worktree, the gate adopted it, and it spent three runs reporting on a build that
// was not this one — first a PASS, then a mount timeout that looked exactly like a
// regression in code that had nothing to do with it.
// The fingerprint is the built bundle itself: if the bytes served differ from the bytes on
// disk, it is a different tree and must not be used.
const alive = async (b) => {
const r = await fetch(`${b}/${TREE_NAME}/gospel-resources/resources-map/index.html`).catch(() => null)
if (!(r && r.ok)) return false
const tree = await servesThisTree(b, REPO)
if (tree.ok) return true
console.log(`GOSPEL-MAP: not adopting ${b} — ${tree.why}`)
return false
}
if (!(await alive(base))) {
if (EXPLICIT) {
console.log(`GOSPEL-MAP: SKIP — nothing served at ${base} (you named it explicitly, so none was started).`)
process.exit(0)
}
if (!existsSync(resolve(REPO, `public/${TREE_NAME}/gospel-resources/resources-map/index.html`))) {
console.log('GOSPEL-MAP: SKIP — no built tree. Run `bun run tree` (cheap) or a full build first.')
process.exit(0)
}
// Ephemeral port so two gates running at once cannot collide on 4173.
const port = 5100 + Math.floor(Math.random() * 800)
base = `http://127.0.0.1:${port}`
server = spawn(process.execPath, [resolve(HERE, 'serve-strict.mjs'), '--port', String(port), '--directory', resolve(REPO, 'public')], { stdio: 'ignore', detached: false })
const until = Date.now() + 15000
while (Date.now() < until && !(await alive(base))) await new Promise((r) => setTimeout(r, 250))
if (!(await alive(base))) {
try { server.kill() } catch (_) {}
console.error('GOSPEL-MAP: FAIL — could not start serve-strict for the check.')
process.exit(1)
}
console.log(`GOSPEL-MAP: started serve-strict on ${port} for this run.`)
}
const PAGE_URL = `${base}/${TREE_NAME}/gospel-resources/resources-map/index.html`
const fails = []
let linkCount = 0
let exitCode = 0
const browser = await chromium.launch({ headless: true, args: GL })
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
const pageErrors = []
page.on('pageerror', (e) => pageErrors.push(String(e)))
try {
await page.goto(PAGE_URL, { waitUntil: 'networkidle', timeout: 60000 })
// 1 — mounted. A stale-SRI leaf never executes, and that reads as "did not mount".
await page.waitForFunction(() => typeof window.__grState === 'function', { timeout: 45000 })
// ★ idle FIRST: counting before first paint returns 0 and looks like a dead layer.
await page.evaluate(() => new Promise((r) => {
const m = window.__grMap
m.loaded() ? r() : m.once('idle', r)
setTimeout(r, 8000)
}))
const st = await page.evaluate(() => window.__grState())
if (!st.hasLayer || !st.hasSource) fails.push('pin layer or source missing')
// 2 — rendered, not merely loaded
if (!st.pins || st.rendered !== st.pins) {
fails.push(`${st.rendered} of ${st.pins} pins rendered`)
}
// 3 — a tap answers, with a named language and real links
const opened = await page.evaluate(() => {
const f = window.__grMap.getSource('gr-pin-src')._data.features
for (const x of f) {
window.__grOpenPin(x.properties.i)
const g = window.__grState().group
if (g && (g.scripture_url || g.film_url)) {
return { pg: g.people_group, lang: g.language, iso: g.iso639_3, s: g.scripture_url, f: g.film_url }
}
}
return null
})
if (!opened) fails.push('no pin opened a group carrying a link')
else if (!String(opened.lang || '').trim()) fails.push(`${opened.pg} opened with no language name`)
// ★ EVERY link in the index the page loaded, not just the one group we opened. The
// first version checked only the opened group's two URLs — a sample of 1 out of 2,685
// codes — so an excluded publisher injected anywhere else passed cleanly. MEASURED: a
// jw.org URL planted in the served data did NOT fail this gate until the scan covered
// the whole index. The doctrine boundary is the one thing here that must never be
// spot-checked: a single off-list link is a doctrinal failure, not a broken link.
const idxRes = await fetch(`${base}/${TREE_NAME}/assets/tables/people-group-pins.json`)
if (!idxRes.ok) fails.push(`pin index not served (${idxRes.status})`)
else {
const idx = await idxRes.json()
const bad = []
let scanned = 0
for (const [c, v] of Object.entries(idx.codes || {})) {
for (const u of [v.s, v.f, v.z, v.za && v.za.u, v.au && v.au.u, v.bv && v.bv.u, v.gp && v.gp.u, ...(v.d || []).map((d) => d.u)]) {
if (!u) continue
scanned++
if (!ALLOWED.test(u)) bad.push(`${c} -> ${u}`)
}
}
if (!scanned) fails.push('pin index carries no links at all — this check would pass vacuously')
if (bad.length) fails.push(`${bad.length} link(s) on an unscreened publisher (e.g. ${bad[0]})`)
linkCount = scanned
}
// 5 — the table deep link carries this group's own code
await page.waitForTimeout(400)
const href = await page.evaluate(() => {
const walk = (r) => {
for (const el of r.querySelectorAll('*')) {
const h = el.getAttribute && el.getAttribute('href')
if (el.tagName === 'A' && h && /data-tables/.test(h)) return h
if (el.shadowRoot) { const f = walk(el.shadowRoot); if (f) return f }
}
return null
}
return walk(document)
})
if (opened?.iso && (!href || !href.includes(`iso=${opened.iso}`))) {
fails.push(`table link does not carry iso=${opened.iso} (got ${href})`)
}
// 4 — the gap filter filters
const filtered = await page.evaluate(async () => {
const click = (i) => {
const walk = (r) => {
const b = [...r.querySelectorAll('.gm-kbtn')]
if (b[i]) { b[i].click(); return true }
for (const el of r.querySelectorAll('*')) if (el.shadowRoot && walk(el.shadowRoot)) return true
return false
}
return walk(document)
}
const m = window.__grMap
const before = m.queryRenderedFeatures({ layers: ['gr-pins'] }).length
click(2)
// ★ WAIT FOR IDLE, not a fixed sleep. setFilter is applied asynchronously and the
// layer re-renders after it; querying on a 900ms timer read the layer MID-TRANSITION
// and still saw tier 1 and 2 features. MEASURED at 1 failure in 3 runs — a gate that
// fails a third of the time on correct code is a gate that gets switched off, which
// is worse than not having written it. The file's own header says to wait for idle
// before counting; clause 4 was not doing it.
await new Promise((r) => { m.once('idle', r); setTimeout(r, 6000) })
const after = m.queryRenderedFeatures({ layers: ['gr-pins'] })
return { before, after: after.length, tiers: [...new Set(after.map((f) => f.properties.tier))] }
})
if (!(filtered.after > 0 && filtered.after < filtered.before)) {
fails.push(`gap filter did not reduce the layer (${filtered.before} -> ${filtered.after})`)
}
if (filtered.tiers.length !== 1 || filtered.tiers[0] !== 0) {
fails.push(`gap filter left tiers ${JSON.stringify(filtered.tiers)}, expected only [0]`)
}
if (pageErrors.length) fails.push(`page error: ${pageErrors[0].slice(0, 120)}`)
if (fails.length) {
console.error(`GOSPEL-MAP: FAIL — ${fails.length} problem(s):`)
for (const f of fails) console.error(` ${f}`)
// ★ NOT process.exit() here. Calling it inside the try terminates the process before
// the finally block's cleanup runs, so a FAILING run left its spawned server alive
// while a passing run did not. Measured: 0 strays after a pass, 1 after a fail — a
// gate that leaks only when it fails leaks exactly when someone is re-running it.
exitCode = 1
} else {
console.log(`GOSPEL-MAP: PASS — ${st.pins} pins rendered; "${opened.pg}" (${opened.lang}) opens with`)
console.log(` a named language; all ${linkCount} links in the index are on screened publishers;`)
console.log(` the table link carries iso=${opened.iso}; the gap filter`)
console.log(` cuts ${filtered.before} features to ${filtered.after}, all tier 0.`)
}
} finally {
await browser.close()
// Never leave a stray server behind, on any exit path.
if (server) { try { server.kill('SIGTERM') } catch (_) {} }
}
process.exit(exitCode)