diff --git a/internal/check-legend-matches-source.mjs b/internal/check-legend-matches-source.mjs new file mode 100644 index 000000000..443b30ca8 --- /dev/null +++ b/internal/check-legend-matches-source.mjs @@ -0,0 +1,116 @@ +#!/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 measured 2026-09-23 against the live endpoint. See the header: these move with the data. +const EXPECT = { + total: 2107, + engagement: { Unengaged: 2080, Engaged: 27 }, + adoptedPct: 1, +} + +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.') diff --git a/package.json b/package.json index f90aad217..fb0eb263d 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,8 @@ "check:synced-css:self-test": "node internal/check-synced-css-copies.mjs --self-test", "serve:permissive": "python3 -m http.server 4173 --bind 127.0.0.1 --directory public # LOOPBACK ONLY. This was --bind 0.0.0.0, which published the built tree on every interface of whatever machine ran a gate; measured on the build VPS it answered HTTP 200 on the box's PUBLIC IP while a browser gate was running. Every consumer (verify-browser-all, check:a11y-button-names, check:nav-reachable, smoke:share-all) already defaults to 127.0.0.1, so nothing needed the wider bind. Override with BASE_URL/BASE if you deliberately want to reach it from another host.", "check:doc-scripts": "node internal/check-doc-quotes-scripts.mjs", - "check:doc-scripts:self-test": "node internal/check-doc-quotes-scripts.mjs --self-test" + "check:doc-scripts:self-test": "node internal/check-doc-quotes-scripts.mjs --self-test", + "check:legend-source": "node internal/check-legend-matches-source.mjs # the numbers the maps SHOW must match the numbers the live SOURCE has. Every other gate checks INTERNAL consistency — a page can agree with itself perfectly while its legend has stopped describing reality. Offline SKIPs (exit 0); only a reachable endpoint that disagrees fails." }, "devDependencies": { "@capacitor/android": "^7.6.9",