Every reference to the Driver in internal/ is reworded the same way as in library/: the reason stays, the speaker and the quote go, an open decision reads "provisional, pending a product decision", and agent names and decision- sheet rows are dropped. Where a gate printed the word, its message now names the reviewer or the ruling instead. Three spots were code, not comments, and changed with their callers: - the element census's allowlist field `driver` is now `ruling`, in the gate and both presets (same validation: match, ruling, date and card required); - check:i18n-never-translated's DRIVER-GATED marker is DECISION-PENDING, in the entries and in the regex that counts them (its output is otherwise byte-identical); - the package.json description of the census says "ruling". public-hygiene-check keeps its two patterns for the word: they are what keep it out of the served folder.
164 lines
9.2 KiB
JavaScript
164 lines
9.2 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-legend-matches-source.mjs — the numbers the maps SHOW must match the numbers the SOURCE HAS.
|
|
*
|
|
* THE GAP THIS FILLS. Every existing gate verifies INTERNAL consistency. check:facet-changes-map
|
|
* proves the dots change and that the categories sum to 2,107 — but it asserts that inside the
|
|
* page, against data the page already loaded. If the upstream corpus shifted (a new engagement
|
|
* category, a reclassification, a renamed field) the page would stay perfectly self-consistent
|
|
* while its legend quietly stopped describing reality. Nothing fetched the live endpoint.
|
|
*
|
|
* That is the worst failure this product can ship, because it is invisible to every structural
|
|
* check: the element mounts, the canvas paints, the counts are formatted correctly, the partition
|
|
* sums. Only comparison against the SOURCE catches a legend that is beautifully wrong — and these
|
|
* maps exist to tell people where the gospel has not reached, so a wrong number is not a cosmetic
|
|
* defect.
|
|
*
|
|
* WHAT IT ASSERTS, measured by hand first on 2026-09-23 and encoded here:
|
|
* - the corpus is still 2,107 rows;
|
|
* - engagement_status partitions it EXACTLY as the cap-2 legend claims (Unengaged 2,080 /
|
|
* Engaged 27) with no third bucket and no remainder;
|
|
* - adopted_by_churches is 21 of 2,107 = 1%, which is what the cap-4 dashboard KPI strip prints.
|
|
*
|
|
* THE EXPECTED VALUES ARE A BASELINE, NOT A LAW. The corpus is living data and these numbers WILL
|
|
* move. A change here is not automatically a bug — it is a prompt to update the legend, the KPI and
|
|
* this baseline together, which is exactly the coupling that was missing. The failure message says
|
|
* so, so a future reader does not "fix" the data to please the gate.
|
|
*
|
|
* OFFLINE IS A SKIP, NOT A FAILURE. This box cannot always reach pray.doxa.life, and reporting a
|
|
* mismatch that may not exist is the fail-open/fail-closed mistake in reverse. A network error
|
|
* SKIPs with exit 0 and says why; only a reachable endpoint that DISAGREES fails.
|
|
*
|
|
* NOTE the browser UA. pray.doxa.life answers 403 to a default script user-agent — a trap already
|
|
* recorded in this repo's notes, and one that presents as "the data is gone".
|
|
*/
|
|
import process from 'node:process'
|
|
|
|
const API = process.env.PRAY_BASE || 'https://pray.doxa.life'
|
|
const UA = 'Mozilla/5.0 (compatible; doxa-maps-gate/1.0)'
|
|
|
|
// Baseline re-measured 2026-09-23 (second reading) against the live endpoint. See the header: these
|
|
// move with the data.
|
|
//
|
|
// ★ WHY THIS UPDATE WAS SAFE, and what was checked before touching it. The gate's own failure text
|
|
// forbids updating the baseline alone, because that silences the check while leaving the maps wrong.
|
|
// So the maps were checked FIRST: grep for 2107/2,107/2080 across plugin-designs/ and library/ found
|
|
// the number ONLY inside explanatory comments — no shipping code hardcodes a corpus total. Every
|
|
// legend and KPI computes from the rows it loaded. The corpus moving 2107 -> 2108 therefore changes
|
|
// what the maps DISPLAY automatically, and only this baseline lagged.
|
|
// Re-measured 2026-09-24 (THIRD reading). The corpus has now moved 2107 -> 2108 -> 2107, and one
|
|
// group crossed from Unengaged to Engaged: 2081/27 -> 2079/28. Sums check (2079 + 28 = 2107).
|
|
//
|
|
// ★ THE SAME SAFETY CHECK WAS REPEATED, not assumed from last time. The gate's own failure text
|
|
// forbids updating this baseline alone, because that silences the check while leaving the maps
|
|
// wrong. So the maps were checked FIRST: grepping 2107/2108/2,107/2,108/2081/2079 across
|
|
// plugin-designs/ and library/ finds them ONLY inside explanatory comments — no shipping code
|
|
// hardcodes a corpus total, every legend and KPI computes from the rows it loaded. The corpus
|
|
// figure was also confirmed INDEPENDENTLY against the live endpoint (2107 rows) rather than
|
|
// taken from this gate's own reading.
|
|
//
|
|
// ⚠ THIS DRIFTS, AND THAT IS THE POINT. Three readings in two days, moving in both directions.
|
|
// A failure here is normally UPSTREAM DATA MOVING, not a regression — but it must still be seen,
|
|
// because the one thing it would also catch is a legend that stopped computing and started
|
|
// asserting. Do not widen this into a range to stop the noise: the noise IS the signal that the
|
|
// numbers on the page are live.
|
|
const EXPECT = {
|
|
total: 2107,
|
|
engagement: { Unengaged: 2079, Engaged: 28 },
|
|
adoptedPct: 1,
|
|
}
|
|
|
|
// ── THE TWO LOSTNESS AXES, AND THE FACT THAT THEY DISAGREE ──────────────────────────────────
|
|
// Two independent measurements of "who has nothing" now ship in this product, and a reader who
|
|
// meets both needs to know they are not the same question:
|
|
//
|
|
// imb_gsec — evangelical presence + church-planting activity (the Gospel Access filter)
|
|
// the six imb_*_available flags — whether a RESOURCE exists (Bible, recordings, JESUS Film,
|
|
// audio scripture, radio, Bible stories)
|
|
//
|
|
// MEASURED 2026-09-23 over the live corpus:
|
|
// gsec rung 0 "No Evangelicals, Churches or Resources" .... 225
|
|
// zero of the six resource flags ......................... 285
|
|
// BOTH (no presence AND no resource) ..................... 209
|
|
// rung 0 yet HAS at least one resource .................... 16 <- the label overstates itself
|
|
// zero resources yet NOT rung 0 ........................... 76
|
|
//
|
|
// ★ Of the 225 groups filed under "No Evangelicals, Churches OR RESOURCES", 11 have one of the six
|
|
// and 5 have FOUR each. The "or Resources" clause is not literally true in the data, so that phrase
|
|
// must never be repeated to a mission team as fact.
|
|
//
|
|
// NOT asserted as a gate condition here, deliberately: which figure is OFFICIAL is provisional,
|
|
// pending a product decision, and a gate must not quietly canonise one answer
|
|
// to a question that is still open. Recorded so the next reader inherits the measurement, not the
|
|
// task of re-deriving it.
|
|
|
|
async function get(fields) {
|
|
const url = `${API}/api/people-groups/list?fields=${fields}&lang=en`
|
|
const r = await fetch(url, { headers: { 'user-agent': UA }, signal: AbortSignal.timeout(30000) })
|
|
if (!r.ok) throw new Error(`HTTP ${r.status} for ${fields}`)
|
|
const d = await r.json()
|
|
if (!Array.isArray(d.posts)) throw new Error(`no posts[] in the response for ${fields}`)
|
|
return d.posts
|
|
}
|
|
|
|
// A field may arrive as a bare string or as a {value,label} pair — the DX-life shape. Read the
|
|
// label when present, so a change in envelope shape is not mistaken for a change in the data.
|
|
const labelOf = (v) => (v && typeof v === 'object' ? (v.label ?? v.value) : v) ?? '(none)'
|
|
|
|
let posts
|
|
try {
|
|
posts = await get('name,engagement_status')
|
|
} catch (e) {
|
|
console.log(`LEGEND-SOURCE: SKIP — could not reach ${API} (${e.message}).`)
|
|
console.log(' Offline is not a mismatch. Re-run where the endpoint is reachable.')
|
|
process.exit(0)
|
|
}
|
|
|
|
const problems = []
|
|
|
|
if (posts.length !== EXPECT.total) {
|
|
problems.push(
|
|
`corpus size moved: ${posts.length} rows, baseline ${EXPECT.total}.\n` +
|
|
' Every legend and KPI derived from the corpus should be re-read before updating this baseline.')
|
|
}
|
|
|
|
const counts = {}
|
|
for (const p of posts) counts[labelOf(p.engagement_status)] = (counts[labelOf(p.engagement_status)] || 0) + 1
|
|
|
|
for (const [k, n] of Object.entries(EXPECT.engagement)) {
|
|
if (counts[k] !== n) problems.push(`engagement "${k}": ${counts[k] ?? 0} rows, legend claims ${n}.`)
|
|
}
|
|
const extra = Object.keys(counts).filter((k) => !(k in EXPECT.engagement))
|
|
if (extra.length) {
|
|
problems.push(
|
|
`engagement has ${extra.length} category(ies) the legend does not show: ${extra.join(', ')}.\n` +
|
|
' A two-row legend over a three-value field hides rows from the reader.')
|
|
}
|
|
const sum = Object.values(counts).reduce((a, b) => a + b, 0)
|
|
if (sum !== posts.length) problems.push(`engagement partition is incomplete: ${sum} of ${posts.length} rows carry a value.`)
|
|
|
|
try {
|
|
const ad = await get('name,adopted_by_churches')
|
|
const n = ad.filter((p) => p.adopted_by_churches).length
|
|
const pct = Math.round((n / ad.length) * 100)
|
|
if (pct !== EXPECT.adoptedPct) problems.push(`adopted: ${n}/${ad.length} = ${pct}%, KPI strip prints ${EXPECT.adoptedPct}%.`)
|
|
else console.log(`LEGEND-SOURCE: adopted ${n}/${ad.length} = ${pct}% — matches the KPI strip.`)
|
|
} catch (e) {
|
|
console.log(`LEGEND-SOURCE: adoption check skipped (${e.message}).`)
|
|
}
|
|
|
|
console.log(`LEGEND-SOURCE: ${posts.length} rows · ` +
|
|
Object.entries(counts).map(([k, n]) => `${k} ${n}`).join(' · '))
|
|
|
|
if (problems.length) {
|
|
console.error(`\nLEGEND-SOURCE: FAIL — ${problems.length} mismatch(es) between what the maps SHOW and what the source HAS.\n`)
|
|
for (const p of problems) console.error(' ✗ ' + p + '\n')
|
|
console.error(' This is a PROMPT, not a verdict: the corpus is living data. Update the legend, the')
|
|
console.error(' KPI strip and the EXPECT baseline in this file TOGETHER — never the baseline alone,')
|
|
console.error(' which would silence the gate while leaving the maps wrong.')
|
|
process.exit(1)
|
|
}
|
|
|
|
console.log('LEGEND-SOURCE: PASS — every rendered figure matches the live source.')
|
|
console.log(' Internal consistency is already covered by check:facet-changes-map; this is the')
|
|
console.log(' EXTERNAL check — the page agreeing with itself proves nothing about the world.')
|