doxa-maps/internal/check-packet-render.mjs
Doxa Maps fbc517c94a A browser gate refuses to launch outside the browser slot; check:browser-gates-guarded keeps it that way
Only the browser seat runs browsers, and only inside internal/browser-slot.sh.
That rule was remembered, not enforced: an agent ran a gate it took for a
headless source check and it launched Chromium outside the slot.
internal/lib/playwright-guarded.mjs is now the one way a repo file reaches
playwright: its launch(), launchPersistentContext(), launchServer(), connect()
and connectOverCDP() exit 75 (NOT MEASURED) with a message naming
browser-slot.sh unless DOXA_BROWSER_SLOT is set, and browser-slot.sh exports it
for the command it runs, so a bash -c grandchild inherits it. All 72 files that
imported playwright switch to it (internal/lib/browser.mjs and 64 internal gates;
plugin-designs scripts: doxa-research-map 3, my-upg-100-list 2,
research-map-plus-grid 2, research-dashboard 1): a mechanical import swap.
check:browser-gates-guarded (gates:pre) fails a direct import;
bun run browser-gates lists the 97 scripts that launch a browser. isBrowserGate
moves to internal/lib/browser-gates.mjs so the runner and the check share it.
2026-09-28 08:20:21 +00:00

870 lines
57 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// check-packet-render — the UPG-100 prayer packet must RENDER, not merely generate.
//
// Every bug this gate exists for produced a file of the right size, with no error, no
// broken image, and a generator that reported success. They were only visible at the
// RENDERED layer, which is why this drives a real browser and reads computed style.
//
// ★ THE LAW THIS GATE ENCODES: never print a number without asserting on it. An earlier
// version of this probe PRINTED "PDF pages: 16" for several runs while reporting
// "✓ packet verified", because the failure counter never looked at that value. A number
// that is displayed but not asserted on is decoration, not verification.
//
// What it catches, each measured on a real regression:
// 1. PAGE COUNT. transform:scale() is PAINT-time, so a scaled sheet still RESERVES its
// full layout box and the paginator splits it — 8 sheets came out as 16 PDF pages,
// inking only the top of each. (zoom is LAYOUT-time and paginates correctly.)
// The packet is printed front-and-back, so an exact page count is load-bearing.
// 2. MAP PALETTE. The map SVG carries almost no presentation attributes; it is styled
// from the poster document's stylesheet. Lift the <svg> without those rules and SVG
// `fill` falls back to its INITIAL value, which is BLACK — 670 elements painted solid
// black with no error of any kind. Asserted as a WHOLE-MAP property, because the first
// attempt at this check tested one hand-picked <rect> (the one just repaired) and went
// green while 647 others were still black.
// 3. Content the Driver specified explicitly: Mark 5:1-20 in full, 12 wheel segments,
// the prayer rhythms, and exactly two minimal metacamp.org attributions.
//
// Usage: node internal/check-packet-render.mjs [path/to/packet.html]
// Default path is overridable with PACKET_HTML so this is not machine-specific.
import { chromium } from './lib/playwright-guarded.mjs'
import { existsSync, readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { pathToFileURL, fileURLToPath } from 'node:url'
import { requirePathInThisCheckout } from './_serves-this-tree.mjs'
// ★ --expect-funnel TURNS AN OPTIONAL PAGE INTO A DECLARED REQUIREMENT, and it exists because of a
// real near-miss (regions-plugin, 2026-09-26, reported unprompted). A merge resolved with
// `git checkout --ours` silently deleted the printPacket call site that supplies the funnel data —
// 60 lines — AND EVERY GATE STAYED GREEN. It had to: the funnel page is optional by design
// (buildPacket omits it when the arg is null), so a packet with no page 2 is not malformed, just
// smaller. My own design choice is what made the deletion invisible.
//
// ⚠ THE FIX IS NOT "ALWAYS REQUIRE IT". That would fail the 11 pre-funnel laos packets and any host
// that legitimately has no funnel data — a false red, which costs as much as a false green. Instead
// the CALLER, who knows whether this packet was built with funnel data, says so. An absence is then
// either DECLARED (no flag, fine) or a FAILURE (flag passed, page missing) — never a silent nothing.
// ⚠ Flags are filtered out of the positional read: a bare `--expect-funnel` was otherwise taken as
// the packet path, which is the flag-eaten-as-positional trap this repo has hit three times.
const EXPECT_FUNNEL = process.argv.includes('--expect-funnel')
|| process.env.PACKET_EXPECT_FUNNEL === '1'
const positional = process.argv.slice(2).filter((a) => !a.startsWith('--'))
const target = positional[0] || process.env.PACKET_HTML
if (!target) {
console.log('PACKET-RENDER: SKIP — no packet given (pass a path or set PACKET_HTML).')
console.log(' The packet is generated on demand, so this gate is opt-in rather than')
console.log(' failing a build on a file that is not expected to exist.')
process.exit(0)
}
const file = resolve(target)
if (!existsSync(file)) {
console.error(`PACKET-RENDER: FAIL — no such packet: ${file}`)
process.exit(1)
}
// Measure THIS build, or measure nothing (exit 75 = NOT MEASURED; see a9550858 / _serves-this-tree.mjs).
requirePathInThisCheckout(file, resolve(dirname(fileURLToPath(import.meta.url)), '..'), 'PACKET-RENDER')
const browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 1500, height: 1941 } })
const errs = []
page.on('pageerror', (e) => errs.push(String(e).slice(0, 120)))
await page.goto(pathToFileURL(file).href, { waitUntil: 'networkidle' })
// Resolve the rhythm words for THIS packet's language from the shared catalog.
// Per-key fallback to English, the same contract the renderer honours.
const lang = await page.evaluate(() => document.documentElement.lang || 'en')
// ★ WHAT THE PACKET SAYS ABOUT ITS OWN BUILD. packet.mjs stamps data-packet-build with the
// flags it was generated under ("full", "nophotos", "nomap", or a combination). Without it
// the only way to tell a fast layout-iteration build from a shippable one was for the PERSON
// RUNNING THE GATE to remember and pass PACKET_EXPECT_PHOTOS=0 — which is why the sweep sat
// RED in doxa-maps-plugin-studio on two probe artifacts nobody intended to ship.
// The ARTEFACT declares its own intent; the env vars below remain as an override for packets
// generated before this stamp existed.
const build = await page.evaluate(() => document.documentElement.getAttribute('data-packet-build') || '')
// ★ AND WHICH LIST IT HOLDS. A packet built under a non-default list is a DIFFERENT DOCUMENT from the
// same zone's default packet — different rows, a different funnel denominator, an extra funnel step —
// and out/packets/ is gitignored, so a sweep grades whatever files are there. Printing the list makes
// 'which packet is this?' answerable from the gate's own output rather than from whoever last ran the
// generator. Empty means the default list.
const packetList = await page.evaluate(() => document.documentElement.getAttribute('data-packet-list') || '')
// RTL languages must actually be laid out RTL. Without dir="rtl" an Arabic packet
// renders readable glyphs LEFT-aligned — it looks like a styling quirk rather than
// the bug it is, and no other assertion here would notice.
const RTL = new Set(['ar', 'he', 'fa', 'ur', 'ps', 'sd', 'yi'])
const dir = await page.evaluate(() => document.documentElement.dir || 'ltr')
// ── AND THE TEXT MUST ACTUALLY BE LAID OUT THAT WAY ─────────────────────────
// dir="rtl" is a DECLARATION, not a layout. It can be set correctly on <html>
// while an inner element overrides it, or while the sheet keeps its LTR flow —
// and every other assertion here would still pass, because an Arabic packet with
// readable glyphs in LTR order looks like a styling quirk rather than the bug it
// is. So measure where the painted text actually sits.
//
// ⚠ MEASURE THE INLINE TEXT RUN, NOT THE BLOCK. A first attempt compared each
// element's own box to its parent's and got IDENTICAL numbers for Arabic and
// English — it was measuring container geometry, which does not move when
// direction flips. A Range over the text node gives the painted extent, which
// does: in ar the run pins to the RIGHT edge (rightGap 0, slack on the left), in
// en/hi to the LEFT. Centred text is skipped because it proves nothing either
// way, and so is text that fills its box — no room to tell.
const layout = await page.evaluate(() => {
const out = { pinnedRight: 0, pinnedLeft: 0, sampled: 0, rtlComputed: 0 }
for (const el of document.querySelectorAll('.sheet *')) {
if (el.childElementCount) continue
if ((el.textContent || '').trim().length < 8) continue
const cs = getComputedStyle(el)
if (cs.textAlign === 'center') continue
const box = el.getBoundingClientRect()
const rng = document.createRange(); rng.selectNodeContents(el)
const tb = rng.getBoundingClientRect()
if (tb.width < 20 || box.width < 50 || box.width - tb.width < 12) continue
if (cs.direction === 'rtl') out.rtlComputed += 1
if (Math.round(box.right - tb.right) <= 1) out.pinnedRight += 1
else if (Math.round(tb.left - box.left) <= 1) out.pinnedLeft += 1
out.sampled += 1
if (out.sampled >= 25) break
}
return out
})
// SCRIPT FONT. Some languages need a face the system stack does not carry — Hindi
// renders as tofu boxes in Georgia/Palatino/serif. When the packet declares one,
// it must actually LOAD; a declared-but-failed @font-face is silent, and the page
// then prints as empty rectangles with every other assertion still green.
//
// ⚠ This asserts the FONT LOADED, not that glyphs are correct. A general tofu
// detector was attempted and abandoned: comparing shaped width against a missing
// font false-positives on every system-covered script (Cyrillic, CJK, Arabic all
// legitimately measure the same in both stacks), and an ink-profile heuristic
// scored CORRECTLY-RENDERED Devanagari the same as tofu, because its baseline bar
// gives a uniform column profile. A detector that blocks working packets is worse
// than none. Read the pixels for that; this covers the mechanical half.
const fontStatus = await page.evaluate(async () => {
await document.fonts.ready
const faces = [...document.fonts]
return { declared: faces.length, loaded: faces.filter((f) => f.status === 'loaded').length,
names: faces.map((f) => `${f.family}:${f.status}`) }
})
// The catalog sits beside the generator that produced this packet, so derive it
// from the packet's own path rather than hardcoding a machine-specific location.
const catalogPath = process.env.PACKET_STRINGS
|| resolve(dirname(file), '..', '..', 'packet-strings.json')
if (!existsSync(catalogPath)) {
console.error(`PACKET-RENDER: FAIL — no strings catalog at ${catalogPath}`)
console.error(' Set PACKET_STRINGS if the packet lives outside the generator tree.')
process.exit(1)
}
const catalog = JSON.parse(readFileSync(catalogPath, 'utf8'))
const enR = catalog.en.rhythms
const langR = (catalog[lang] || {}).rhythms || {}
const expected = [...new Set(Object.keys(enR).map((k) => langR[k] ?? enR[k]))]
// ★ A NON-ENGLISH PACKET MUST ACTUALLY DIFFER FROM ENGLISH. The failure this
// catches is a language block that exists but does nothing — a typo'd key, a
// block under the wrong ISO code, or a renderer that ignored the lang argument.
// Every assertion below would still pass in that case, because an all-English
// packet is a perfectly valid packet; only the comparison reveals it.
const translated = lang !== 'en'
? Object.keys(catalog.en).filter((k) => typeof catalog.en[k] === 'string'
&& (catalog[lang] || {})[k] && catalog[lang][k] !== catalog.en[k]).length
: null
// ── THE LARGEST BLOCK OF READER-FACING TEXT, REPORTED SEPARATELY ────────────
// point4_passage is Mark 5:1-20 in full — 2,141 characters, by far the longest string
// in the packet and the one the Driver singled out ("prayer point number four is
// actually read the word"). It is ABSENT from all ten non-English blocks, so every
// translated packet prints its longest passage in English. The per-locale "19 strings
// translated" headline looked healthy while this was true, because 19 short keys and
// one 2,141-character one count the same.
// Reported, NOT failed: a Bible passage cannot be machine-translated or written from
// memory — it must be an exact, correctly-licensed text, and the English here is
// deliberately public-domain (World English Bible). Picking a translation per language
// is a sourcing decision with licensing consequences, not a code change. Failing the
// build over it would only pressure someone into pasting a copyrighted text.
const scriptureIsEnglish = lang !== 'en'
&& !(catalog[lang] || {}).point4_passage
await page.evaluate((words) => { window.__EXPECTED_RHYTHMS = words }, expected)
const r = await page.evaluate(() => {
const body = document.body.innerText
const imgs = [...document.querySelectorAll('.card img')]
// ⚠ The expected rhythm words come from the STRINGS CATALOG FOR THIS PACKET'S
// LANGUAGE, never a hardcoded English list. A fixed list tests the opposite of
// what i18n means: it passed a packet whose translation silently did nothing and
// FAILED every correctly-translated one (measured — the Spanish packet reported
// 0/11 rhythms while rendering all twelve correctly as ALABANZA/ESPERAR/...).
// Read the packet's own <html lang> and resolve per-key, exactly as the renderer
// does, so this asserts the PROPERTY (all rhythms present, in this language)
// rather than a vocabulary.
const RHYTHMS = window.__EXPECTED_RHYTHMS || []
// Whole-map property, NOT one sampled element — see header note 2.
const mapFaults = (() => {
// ⚠ SCOPE TO PAGE 1. This was `document.querySelector('.sheet svg')` — "the first svg
// ANYWHERE" — which is a FALLBACK SELECTOR, and a fallback selector turns an absence
// into a pass. Measured 2026-09-24: deleting the map svg from page 1 entirely left this
// gate reporting "map: palette resolves, not black — PASS, map painted", because the
// query silently retargeted to the PRAYER WHEEL's svg two sheets later. The wheel has no
// CSS variables to leave unresolved and no black fills, so both checks passed vacuously
// while page 1 — "the map one-pager", the first line of the Driver's packet spec — was
// blank. The sheet count stayed 8 because the empty sheet still exists.
// The packet's page order is the product spec and does not vary: sheet 0 IS the map page.
const sheet0 = document.querySelector('.sheet')
const svg = sheet0 ? sheet0.querySelector('svg') : null
if (!svg) return ['no map svg on page 1 — the map one-pager rendered without its map']
const faults = []
// ⚠ AND ASSERT IT PAINTS GEOGRAPHY. Palette-resolves + not-black are both properties a
// map with ZERO country shapes satisfies perfectly. Geography is counted as paths whose
// `d` is long enough to be a coastline rather than an icon or a chart tick. MEASURED
// across zones: laos 13, brazil 18, middle-east-core 55 — it varies with how many
// countries a zone spans, so the floor is well under the smallest rather than an exact
// count. Zones whose groups do not resolve are SKIPPED by the generator and never reach
// this gate ("europe: no groups resolved — skipped"), so a zero here is a real fault.
const geoPaths = [...svg.querySelectorAll('path')]
.filter((e) => (e.getAttribute('d') || '').length > 200).length
if (geoPaths < 5) {
faults.push(`only ${geoPaths} geography path(s) on the map page — the map one-pager is `
+ 'blank or drew nothing but furniture (a correct palette and no black fills are both '
+ 'satisfied by a map with no countries on it)')
}
const names = [...new Set((svg.outerHTML.match(/var\((--[a-z0-9-]+)/g) || []).map((v) => v.slice(4)))]
const cs = getComputedStyle(svg)
for (const n of names) if (!cs.getPropertyValue(n).trim()) faults.push(`unresolved ${n}`)
const all = [...svg.querySelectorAll('path,circle,text,rect,line')]
const black = all.filter((e) => getComputedStyle(e).fill === 'rgb(0, 0, 0)')
if (all.length && black.length > all.length * 0.25) {
faults.push(`${black.length}/${all.length} map elements fill BLACK`)
}
return faults
})()
return {
sheets: document.querySelectorAll('.sheet').length,
cards: document.querySelectorAll('.card').length,
// The page-2 funnel is OPTIONAL (buildPacket omits it when no funnel data was supplied), so its
// presence is DETECTED rather than assumed — see the sheet arithmetic below, which must stay
// exact about list pages whether or not this page is there.
funnel: document.querySelectorAll('.funnel-title').length,
lifeSheets: document.querySelectorAll('.sheet-life').length,
christianSheets: document.querySelectorAll('.sheet-christian').length,
lifeCells: document.querySelectorAll('.lc-grid .lc-c').length,
prayBoxes: document.querySelectorAll('.pray-box').length,
funnelTip: document.querySelectorAll('.funnel-tip').length,
lifeShaded: document.querySelectorAll('.lc-grid .lc-reached').length,
// ★ THE SHADED CELLS' VERTICAL SPREAD, as a fraction of the grid's own height. A COUNT cannot tell a
// scatter from a bar — 32 shaded is 32 shaded whether they are spread through a life or stacked in
// row 1 — so this measures the SHAPE, which is the thing the page is actually claiming.
lifeSpread: (() => {
const grid = document.querySelector('.sheet-christian .lc-grid')
if (!grid) return null
const on = [...grid.querySelectorAll('.lc-reached')]
if (on.length < 2) return null
const gb = grid.getBoundingClientRect()
if (!(gb.height > 0)) return null
const tops = on.map((el) => el.getBoundingClientRect().top)
return (Math.max(...tops) - Math.min(...tops)) / gb.height
})(),
funnelEq: document.querySelectorAll('.funnel-eq').length,
funnelSteps: document.querySelectorAll('.funnel-mine').length,
// ★ MEASURED SO THE GATE CAN REPORT WHAT IT ASSERTED. rega: "the gate prints no funnel line, even
// with --expect-funnel ... a reader cannot see WHAT it asserted about page 2." A silent pass is
// the mirror of a printed-but-unasserted number: the check is real and the evidence is invisible.
// ⚠ ZERO POPULATION IS COUNTED ON PURPOSE. The reader's row printed "0 people" in every packet
// (Number(null) is 0, so the finite-number guard admitted a deliberate no-data marker). Fixed in
// the renderer — and counted here so the fix cannot silently regress.
funnelRows: (() => {
const sheet = document.querySelector('.funnel-title')?.closest('.sheet')
return sheet ? sheet.querySelectorAll('div[style*="line-height:1"]').length : 0
})(),
funnelBars: (() => {
const sheet = document.querySelector('.funnel-title')?.closest('.sheet')
return sheet ? sheet.querySelectorAll('div[style*="height:16px"]').length : 0
})(),
funnelZeroPop: (() => {
const sheet = document.querySelector('.funnel-title')?.closest('.sheet')
if (!sheet) return 0
return [...sheet.querySelectorAll('div')]
.filter((d) => !d.children.length && /(^|\D)0(\D|$)/.test(d.textContent.trim())
&& d.textContent.trim().length < 24 && /\d/.test(d.textContent))
.filter((d) => /^0(\.0+)?\s+\D/.test(d.textContent.trim())).length
})(),
funnelShare: (document.querySelector('.funnel-share')?.textContent || '').trim().slice(0, 80),
// ★ LATIN RUNS ON A NON-LATIN SHEET, which is the one gap rega named and I left open: "the gate
// passed: it counts rows, not the row's language." It asserted the funnel page's STRUCTURE and
// nothing about whether its prose reached the reader's language — my own standing rule turned on
// my own gate.
//
// ⚠ IT ONLY WORKS IN A NON-LATIN-SCRIPT LOCALE, and that is why it is worth having rather than
// aspiring to a general "is this translated?" test, which is not decidable. On a hi/ar/ru/zh sheet
// a run of Latin letters is a proper name, a number, or an UNTRANSLATED STRING — three
// possibilities, two of which are enumerable. In de/fr/es an untranslated string looks like
// ordinary text and this measures nothing, so those locales are not covered and the gate does not
// pretend otherwise.
//
// MEASURED across every non-Latin packet in out/ (hi, ar, ru, zh): exactly TWO Latin runs appear,
// both the data source's name. Anything else is the defect. Verified against the real one — with
// the pre-fix English list label restored on the hi sheet it reports ['Project', 'frontier people
// groups'], and an untranslated key reports ['THIS packet'].
funnelLatinRuns: (() => {
const sheet = document.querySelector('.funnel-title')?.closest('.sheet')
if (!sheet) return []
const txt = sheet.textContent || ''
// Digits break a run, so "Project 4700" surfaces as "Project" — deliberate: the designation is
// allowed by name below, and a bare "Project" beside a number is exactly what an untranslated
// label looks like.
const runs = txt.match(/[A-Za-z][A-Za-z.'/&-]{2,}(?:\s+[A-Za-z][A-Za-z.'/&-]*)*/g) || []
return [...new Set(runs.map((r) => r.trim()))]
})(),
// ★ A CARD MUST NAME ITS PEOPLE GROUP. Counting .card proves the element exists; the spec says
// pages 2..n carry "the people groups, 20 per page, with photos" — a card with a photo and a
// BLANK NAME satisfies every count here: the total holds, the photo/typographic split holds,
// the page arithmetic holds. Same gap the .nophoto count had, one element up.
// ⚠ MEASURE THE NAME ELEMENT, NOT THE WHOLE CARD. A first version tested card.textContent and
// FAILED ITS OWN MUTATION TEST: blanking every name still left 18 characters per card, because
// the typographic fallback ("no photo available") lives INSIDE .card. The card was not empty;
// it just said nothing about a people group. .nm is the name line the spec means
// (packet-render.js:229), so that is what has to be non-empty.
// MEASURED 2026-09-25 on laos: 95 names, 0 blank, shortest "1. Aheu" — a threshold of 3 sits
// far below any real entry and above an empty one.
cardsBlank: [...document.querySelectorAll('.card')]
.filter((c) => {
const nm = c.querySelector('.nm')
return !nm || (nm.textContent || '').replace(/\s+/g, ' ').trim().length < 3
}).length,
imgs: imgs.length,
// ⚠ DISTINCT sources, not the tag count. A PDF DE-DUPLICATES identical images, so the
// number of /Subtype /Image objects can never equal the number of <img> tags when two
// people groups share a portrait. MEASURED on laos: 95 cards, 95 <img>, but only 85
// distinct srcs — ten groups reuse another group's photo, which is a fact about the
// upstream photo set, not a rendering fault. Comparing the PDF against 95 would have
// reported "8 photos dropped at print time" on a correct packet; comparing against 85
// asks the question that has a right answer.
distinctImgSrc: new Set(imgs.map((i) => i.getAttribute('src'))).size,
broken: imgs.filter((i) => !i.complete || i.naturalWidth === 0).length,
nophoto: document.querySelectorAll('.nophoto').length,
// ★ A TYPOGRAPHIC CARD MUST SAY SOMETHING. The count above proves the element exists; it says
// nothing about whether the reader sees a label or an empty grey box. The renderer writes
// t('no_photo') there (packet-render.js:225), which is translated in all 11 catalogs — so a
// blank fallback is both a layout defect and a silent i18n failure, and counting alone catches
// neither.
// MEASURED 2026-09-25 on the first MIXED packet ever rendered (PHOTOS_EVERY=3 over laos:
// 31 photos, 64 typographic): all 64 read "no photo available", 0 blank, all 64 with a real box.
nophotoBlank: [...document.querySelectorAll('.nophoto')]
.filter((e) => !(e.textContent || '').trim()).length,
nophotoUnsized: [...document.querySelectorAll('.nophoto')]
.filter((e) => { const r = e.getBoundingClientRect(); return r.width < 4 || r.height < 4 }).length,
rhythms: RHYTHMS.filter((w) => body.includes(w)).length,
rhythmsTotal: RHYTHMS.length,
wheelSegs: document.querySelectorAll('svg path[fill^="hsl"]').length,
// ⚠ COUNT THE WHEEL'S OWN LABELS, SCOPED TO THE WHEEL SHEET. `rhythms` below
// searches document.body.innerText, and the SAME rhythm vocabulary is rendered
// TWICE — once per wheel segment, and once per prayer point (each point prints
// tr(p.rhythm) as its heading). So "every rhythm word appears in the document" is
// satisfied by the POINTS page alone and says nothing about the wheel.
// Measured 2026-09-24: blanking every wheel segment label left the gate reporting
// "12 segments, 11/11 rhythms, content intact — PASS" on a prayer wheel that was a
// twelve-colour pie chart with no words on it. Both numbers stayed true: the
// segments are <path> elements and survive, and the words were still on the points
// page. The Driver's spec names the wheel as one of the packet's four sections.
//
// COUNT ELEMENTS, NOT DISTINCT WORDS. `order` renders 12 segments from 12 keys, but
// `praise` and `praise_end` collapse to the same word in English (which is why the
// line below prints 11/11, not 12/12) — and that collision is LOCALE-DEPENDENT. A
// distinct-word count would be a different number per language.
wheelLabels: (() => {
const title = document.querySelector('.wheel-title')
const sheet = title ? title.closest('.sheet') : null
const svg = sheet ? sheet.querySelector('svg') : null
if (!svg) return -1
// The segment labels are the non-numeric <text> runs: the pips are all "5" and
// the footer reads "5 <minutes> x 12".
return [...svg.querySelectorAll('text')]
.map((t) => (t.textContent || '').trim())
.filter((s) => s && !/^\d+$/.test(s) && !/^\d[\s\S]*×\s*12$/.test(s)).length
})(),
// ⚠ NOT body.includes('Gadarenes'). That was an ENGLISH proper noun, so this
// assertion would go RED on a correctly-translated packet and stays green today
// only because every locale falls back to English for this key. The check and the
// bug were locked together. Assert the STRUCTURE instead — the passage block exists
// and carries a full passage's worth of text — which holds in any language.
mark5Len: (document.querySelector('.p4-passage')?.textContent || '').trim().length,
// ⚠ COUNT THE POINTS. The sheet-sum invariant below already required a points PAGE —
// and a page with three points on it is still exactly one sheet, so a packet headed
// "12 Prayer Points" printed THREE and every number this gate asserted stayed correct.
// Measured 2026-09-24 against a deliberately partial locale block: the renderer did
// `L.points || EN.points`, which is a whole-block fallback when the key is ABSENT but
// TRUNCATES when a translator has done some of the 12 (a 3-element array is truthy).
// Counted from the rendered numbered rows, not from the strings file, because the
// strings file was complete in all 11 locales — the defect only exists downstream.
points: (() => {
const title = document.querySelector('.points-title')
if (!title) return -1
const host = title.parentElement?.parentElement
if (!host) return -1
return (host.innerText || '').split('\n').filter((l) => /^\s*\d{1,2}\.\s/.test(l)).length
})(),
metacamp: (body.match(/metacamp\.org/g) || []).length,
mapFaults,
// Printed beside the verdict: a reader should see the number the assertion rests on,
// not just the word "painted".
mapGeoPaths: (() => {
const sheet0 = document.querySelector('.sheet')
const svg = sheet0 ? sheet0.querySelector('svg') : null
if (!svg) return -1
return [...svg.querySelectorAll('path')].filter((e) => (e.getAttribute('d') || '').length > 200).length
})(),
// The per-page "{from}-{to} of {count}" headers, as DIGIT TRIPLES rather than text.
// Read from the rendered DOM (not the source template) so what is checked is what a
// reader would see. Locale digits are normalised to ASCII first: several catalogs
// render through toLocaleString, and Arabic-Indic digits would not match \d.
ranges: [...document.querySelectorAll('.hd-r')].map((el) => {
const ascii = el.textContent
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660))
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
.replace(/[०-९]/g, (d) => String(d.charCodeAt(0) - 0x0966))
.replace(/[0-9]/g, (d) => String(d.charCodeAt(0) - 0xff10))
.replace(/[,٬   ]/g, '') // thousands separators
return (ascii.match(/\d+/g) || []).map(Number)
}).filter((nums) => nums.length === 3),
}
})
const pdf = await page.pdf({ preferCSSPageSize: true })
await browser.close()
const raw = pdf.toString('latin1')
const boxes = [...raw.matchAll(/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)\s*\]/g)].map((m) => [+m[1], +m[2]])
const pages = (raw.match(/\/Type\s*\/Page[^s]/g) || []).length
const landscape = boxes.filter(([w, h]) => h <= w).length
// ★ THE PRINT LAYER, WHICH NOTHING ASSERTED. This gate already GENERATED a PDF and then
// read only its STRUCTURE — page count and MediaBox. The packet exists to be PRINTED (it
// is a standalone file with photos and fonts inlined precisely so it prints offline), so
// "8 pages of the right size" is satisfied by eight blank sheets.
// FontFile2 = a TrueType face actually EMBEDDED in the document. Zero means the text was
// either rasterised or left to whatever fonts the printer happens to have, which for a
// packet carrying a vendored Devanagari face is the difference between Hindi and tofu.
const pdfFonts = (raw.match(/\/FontFile2/g) || []).length
const pdfImages = (raw.match(/\/Subtype\s*\/Image/g) || []).length
const pdfBytes = pdf.length
// Counts are RELATIVE (sheets vs pages, cards vs photos) so this gate is not pinned to
// one zone's group count — a per-zone packet has a different number of both.
const fails = []
if (!r.sheets) fails.push('no .sheet elements — the packet did not render')
if (!r.cards) fails.push('no people-group cards')
if (r.cardsBlank) {
fails.push(`${r.cardsBlank} of ${r.cards} people-group card(s) have no NAME — a card that `
+ 'names nobody is a slot on the page, not an entry in a prayer list. The counts above cannot '
+ 'see this: the total, the photo split and the page arithmetic all hold, and so does '
+ 'card.textContent, because the "no photo available" fallback sits inside the card.')
}
if (r.broken) fails.push(`${r.broken} broken photo(s)`)
// ── "20 PER PAGE **WITH IMAGES**" WAS PRINTED, NEVER ASSERTED ────────────────
// The packet spec names the photos explicitly, and this gate printed
// "95 cards (0 photos, 95 typographic)" and PASSED — every locale, every run.
// Measured 2026-09-24: all 2,107 source records carry an image_url, so zero photos
// means the fetch was skipped or failed wholesale, not that the groups have none.
//
// ⚠ THE COUNT CANNOT BE PINNED. Roughly half the groups genuinely have no photo,
// the CDN drops some, and NOPHOTOS=1 is a SUPPORTED dev mode for fast layout
// iteration (packet.mjs:35). A packet built that way is a valid artefact, not a
// defect — so the assertion has to tell "deliberately none" from "silently none".
// Two invariants do that without pinning a number:
// 1. every card is one or the other — photos + typographic === cards. A card that
// is neither means the renderer emitted a third shape nobody is checking.
// 2. a packet with SOME photos proves the pipeline ran. Zero is only acceptable
// when EVERY card is typographic AND the run was declared photo-less, which is
// what PACKET_EXPECT_PHOTOS=0 says out loud.
if (r.nophotoBlank) {
fails.push(`${r.nophotoBlank} of ${r.nophoto} typographic card(s) render an EMPTY box — the `
+ 'renderer writes a localized "no photo" label there, so a blank one is both a layout defect '
+ 'and a silent i18n failure. Counting the elements cannot see this.')
}
if (r.nophotoUnsized) {
fails.push(`${r.nophotoUnsized} of ${r.nophoto} typographic card(s) collapsed to no box — a `
+ 'fallback that occupies no space leaves a hole where a face should be')
}
if (r.cards && r.imgs + r.nophoto !== r.cards) {
fails.push(`${r.cards} people cards but ${r.imgs} photo + ${r.nophoto} typographic = `
+ `${r.imgs + r.nophoto} — every card must be exactly one of the two`)
}
// The packet's own stamp is authoritative; the env var stays as an override for
// artefacts generated before data-packet-build existed.
// `fixture` implies no photos: a fixture packet is built from a committed records subset
// with image_url stripped precisely so CI never fetches ~43 images from someone else's CDN.
const expectPhotos = process.env.PACKET_EXPECT_PHOTOS !== '0' && !/\b(nophotos|fixture)\b/.test(build)
if (r.cards && expectPhotos && r.imgs === 0) {
fails.push(`${r.cards} people cards and NOT ONE photo — the packet spec is "20 per page `
+ 'WITH IMAGES", and every source record carries an image_url, so this is a skipped or '
+ 'failed photo fetch, not a zone without pictures. Re-run without NOPHOTOS=1, or set '
+ 'PACKET_EXPECT_PHOTOS=0 to declare this a deliberately photo-less packet.')
}
if (r.wheelSegs !== 12) fails.push(`prayer wheel has ${r.wheelSegs} segments, expected 12`)
// A SEGMENT WITHOUT ITS WORD IS A COLOUR. 12 labels, one per segment, on the wheel
// sheet itself — not merely somewhere in the document.
const WHEEL_LABELS = 12
if (r.wheelLabels !== WHEEL_LABELS) {
fails.push(r.wheelLabels === -1
? 'no prayer-wheel svg found (no .wheel-title sheet) — the wheel page did not render'
: `the prayer wheel paints ${r.wheelLabels} segment label(s), expected ${WHEEL_LABELS} — `
+ 'the rhythm words are rendered TWICE in this packet (wheel segments and prayer-point '
+ 'headings), so the document-wide rhythm check below passes on the points page alone '
+ 'and cannot see an unlabelled wheel')
}
if (r.rhythms !== r.rhythmsTotal) fails.push(`only ${r.rhythms}/${r.rhythmsTotal} prayer rhythms present`)
// Mark 5:1-20 in full is a Driver requirement for point 4: "it actually lists out the
// full scripture, because prayer point number four is actually read the word." The
// English passage is 2,141 chars; 1,200 is a floor that no truncation or placeholder
// survives while leaving room for languages that render the same text more compactly
// (zh is typically ~40% shorter in characters).
const MARK5_MIN = 1200
if (r.mark5Len < MARK5_MIN) {
fails.push(r.mark5Len === 0
? 'Mark 5:1-20 is NOT printed in full (Driver requirement for point 4) — no .p4-passage block'
: `Mark 5:1-20 looks truncated: ${r.mark5Len} chars, expected at least ${MARK5_MIN}`)
}
// THE PRODUCT IS NAMED FOR THIS NUMBER. The page header says "12 Prayer Points" in every
// locale, so 12 is a contract, not a preference — and the failure mode it guards is SILENT:
// a partial translation rendered fewer, with nothing blank and no error raised.
const POINTS_EXPECT = 12
if (r.points !== POINTS_EXPECT) {
fails.push(r.points === -1
? 'no .points-title block — the 12 prayer points page did not render at all'
: `${r.points} prayer point(s) rendered, expected ${POINTS_EXPECT} — a partial locale `
+ 'block truncates the list instead of falling back per key, and the page still '
+ 'carries its "12 Prayer Points" heading')
}
if (r.metacamp !== 2) fails.push(`${r.metacamp} metacamp.org attributions, expected exactly 2`)
// THE MAP PAGE IS OPT-OUT-ABLE, THE SAME WAY THE PHOTOS ARE — and for the same reason.
// packet.mjs lifts the map from out/posters/<zone>.html and, when that file is absent,
// deliberately "still builds (cover page without the map) rather than failing". That soft
// fail is intentional, but it is also INVISIBLE: measured 2026-09-24, a packet built with
// no poster sources has 8 sheets and an <svg> present (the prayer wheel's) and carries no
// marker at all, so it is structurally indistinguishable from a good one while page 1
// promises a map one-pager and delivers an empty sheet. doxa-maps-plugin-studio ships 0
// poster html files against wt-packet's 28, so every packet generated there is degraded
// this way and nothing said so.
// Default to FAILING, so the degradation has to be DECLARED rather than discovered.
const expectMap = process.env.PACKET_EXPECT_MAP !== '0' && !/\bnomap\b/.test(build)
if (r.mapFaults.length && expectMap) {
fails.push(`map: ${r.mapFaults.join('; ')}`
+ (r.mapGeoPaths === -1
? ' — build the poster sources first (node library/scripts/posters/generate.mjs), or set '
+ 'PACKET_EXPECT_MAP=0 to declare this a deliberately map-less packet'
: ''))
}
if (fontStatus.declared && fontStatus.loaded !== fontStatus.declared) {
fails.push(`a declared @font-face did not load (${fontStatus.names.join(', ')}) — `
+ 'text in that script will print as empty boxes')
}
if (RTL.has(lang) && dir !== 'rtl') fails.push(`lang="${lang}" is right-to-left but dir="${dir}"`)
if (!RTL.has(lang) && dir === 'rtl') fails.push(`lang="${lang}" is not RTL but dir="rtl"`)
// The attribute is necessary; this is the part that proves it took effect.
if (layout.sampled >= 5) {
const wantRight = RTL.has(lang)
const pinned = wantRight ? layout.pinnedRight : layout.pinnedLeft
if (pinned < Math.ceil(layout.sampled * 0.8)) {
fails.push(`lang="${lang}" should paint text to the ${wantRight ? 'RIGHT' : 'LEFT'}, but only `
+ `${pinned}/${layout.sampled} sampled runs do (right ${layout.pinnedRight}, left ${layout.pinnedLeft}) — `
+ `dir="${dir}" is declared and not taking effect`)
}
if (RTL.has(lang) && layout.rtlComputed < layout.sampled) {
fails.push(`${layout.sampled - layout.rtlComputed}/${layout.sampled} sampled elements compute `
+ 'direction:ltr inside an RTL packet — something overrides the document direction')
}
}
if (translated === 0) {
fails.push(`lang="${lang}" but no string differs from English — `
+ 'the language block is missing, mis-keyed, or the renderer ignored it')
}
// ── "N strings translated" WAS PRINTED, AND ONLY ZERO WAS ASSERTED ──────────
// A locale regressing from 19 translated keys to 1 passed silently: the only check
// was `=== 0`. But the count CANNOT be pinned — measured 2026-09-24, de translates
// 18 of the same 19 keys and fr 17, because a word or two legitimately matches
// English. Hardcoding 19 would fail two correct catalogs.
// So assert a FLOOR derived from the catalog itself: most of what a block carries
// must actually differ from English. Two-thirds leaves generous room for shared
// proper nouns while still catching a block that has quietly emptied out.
// The structural half of this — every key PRESENT, all 12 points, all 12 rhythms —
// is check:packet-strings-parity, which does not need a rendered packet to run.
if (translated !== null && translated > 0) {
const present = Object.keys(catalog.en).filter((k) => typeof catalog.en[k] === 'string'
&& typeof (catalog[lang] || {})[k] === 'string').length
const floor = Math.ceil(present * 2 / 3)
if (present && translated < floor) {
fails.push(`lang="${lang}" carries ${present} string(s) but only ${translated} differ from `
+ `English (floor ${floor}) — most of this block has reverted to English text`)
}
}
if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`)
if (landscape) fails.push(`${landscape} landscape page(s) — the packet must be portrait`)
if (pages !== r.sheets) fails.push(`${pages} PDF pages for ${r.sheets} sheets — one sheet must be exactly one page`)
// ── THE PRINTED ARTEFACT ────────────────────────────────────────────────────
// TEXT MUST BE EMBEDDED TEXT. Measured across scripts: en 7 FontFile2, ar/hi/zh all
// embed too. Zero would mean the packet prints with whatever the printer has, which
// defeats the reason photos and fonts are inlined in the first place.
if (pdfFonts === 0) {
fails.push('the PDF embeds NO fonts (0 /FontFile2) — the packet is a standalone file '
+ 'meant to print offline, so its text must travel with it rather than depend on the '
+ "printer's font set")
}
// THE PHOTOS MUST SURVIVE PRINTING. Compared against DISTINCT srcs, because the PDF
// de-duplicates identical images — see the note on distinctImgSrc above. A small
// allowance covers the map and page furniture, which are images too.
if (expectPhotos && r.distinctImgSrc > 0 && pdfImages < r.distinctImgSrc) {
fails.push(`${pdfImages} image(s) in the PDF for ${r.distinctImgSrc} distinct photo source(s) — `
+ 'photos are being dropped at PRINT time, which no on-screen check can see')
}
// AND IT MUST NOT BE AN EMPTY SHELL. A packet with 95 inlined photos is megabytes; a
// few hundred KB means the content did not make it into the print output at all.
const PDF_MIN_KB = expectPhotos ? 500 : 40
if (pdfBytes / 1024 < PDF_MIN_KB) {
fails.push(`the PDF is only ${Math.round(pdfBytes / 1024)}KB, expected at least ${PDF_MIN_KB}KB — `
+ 'the pages are the right size and count but carry almost nothing')
}
// ── THE PAGE SIZE WAS PRINTED BUT NEVER CHECKED ─────────────────────────────
// `landscape` only catches h <= w, so a WRONG PORTRAIT size sailed through: A4 is
// 595x842pt, taller-than-wide like Letter, and would have been reported as a clean
// pass on a packet that prints with the wrong margins on every US printer.
// That is not hypothetical here — this repo has already paid for it once. `@page
// {size:...}` silently falls back to Letter in Chromium for any named size beyond
// letter/A4, so the declared size and the produced size can disagree with no error
// anywhere. The renderer declares `@page{size:letter portrait}` (packet-render.js:97);
// this asserts the PDF actually came out that way.
// Tolerance is 1pt for float formatting in the MediaBox, not for real drift.
const LETTER = [612, 792]
if (!boxes.length) {
fails.push('the PDF declares no MediaBox — page geometry could not be read at all')
} else {
const wrong = boxes.filter(([w, h]) => Math.abs(w - LETTER[0]) > 1 || Math.abs(h - LETTER[1]) > 1)
if (wrong.length) {
fails.push(`${wrong.length}/${boxes.length} page(s) are not US Letter — got `
+ `${wrong[0].join(' x ')}pt, expected ${LETTER.join(' x ')}pt `
+ '(@page{size:letter portrait} did not take effect)')
}
}
// ── THE CARD COUNT WAS PRINTED BUT ONLY CHECKED FOR ZERO ────────────────────
// `if (!r.cards)` passes a packet carrying ONE card. The existing comment is right
// that counts must stay RELATIVE — a per-zone packet has a different group count, so
// pinning 95 would make this gate zone-specific. But relative does not mean unasserted:
// the renderer lays out PER_PAGE cards per list page (packet-render.js:59, COLS*ROWS),
// and the packet is map + list pages + points + wheel. So the whole structure is
// derivable from the card count alone, for ANY zone:
//
// sheets === 1 + ceil(cards / PER_PAGE) + 2
//
// That ties the printed number to the sheet count without pinning either. It catches
// silently dropped groups (a selector that resolves 95 but renders 40 keeps the same
// page arithmetic only if the pages drop too) and a mis-sized grid, which is the way
// "20 per page WITH IMAGES" actually regresses. Verified against all 11 locale packets
// of laos (95 cards -> 8 sheets) before being turned on.
//
// ⚠ THE PAGE-2 FUNNEL IS COUNTED, NOT TOLERATED. When it was added (2026-09-26) every fixture went
// red here with "43 cards should lay out as 6 sheets, but the packet has 7" — the gate doing its job
// on a structural change it had not been told about. The wrong fix is to accept N or N+1 sheets:
// this assertion exists to catch SILENTLY DROPPED PEOPLE GROUPS, and a ±1 tolerance is exactly the
// slack a lost list page would hide in. So the funnel is DETECTED (.funnel-title) and added as a
// known term, leaving the list-page arithmetic as strict as it was.
// ⚠ AND THE SAME FOR THE LIFE-CALENDAR PAGES (2026-09-27, Driver's page order). Two more structural
// sheets — a 100-year life in weeks, then the SAME grid shaded by the evangelical share of these
// groups' population — so they are DETECTED and added as known terms for exactly the reason the
// funnel was, and for the reason stated above: a tolerance here would hide a dropped list page.
const PER_PAGE = 20
const funnelSheets = r.funnel ? 1 : 0
const lifeSheets = (r.lifeSheets || 0) + (r.christianSheets || 0)
const expectedSheets = 1 + lifeSheets + funnelSheets + Math.ceil(r.cards / PER_PAGE) + 2
// ── THE LIFE-CALENDAR PAGES, asserted rather than merely counted ──────────────────────────────────
// A page that EXISTS is not a page that is RIGHT: an empty grid, a grid of the wrong size, or a
// christian page whose shading silently fell to zero would all still be one sheet. 5,200 is the
// Driver's own arithmetic (100 years x 52 weeks) and is the only number here that may not drift.
if (r.lifeSheets) {
const EXPECT = 5200
const perGrid = r.lifeCells / (r.lifeSheets + (r.christianSheets || 0))
if (perGrid !== EXPECT) {
fails.push(`the life calendar should be ${EXPECT} cells per grid (100 years x 52 weeks) `
+ `but rendered ${perGrid} — the grid changed size, so the page no longer shows a human life`)
}
}
// ⚠ AND THE SHADE MUST BE SCATTERED, NOT STACKED — the assertion that would have caught what three gates
// and a passing suite did not. rega's contact sheet (2026-09-28) showed page 3 reading as ONE SOLID BAR:
// 32 contiguous cells fill most of row 1 of 52, so the page said "a big green block" where the Driver's
// intent is "32 boxes lost in 5,200". Every assertion passed, because they all measured the COUNT.
// Half the grid height is a deliberately loose floor: a genuine scatter of even a few cells spans most of
// the page (measured: 32 cells over 26 rows, 95% of the height), and a bar spans ~1%. Anything in between
// is worth a human look.
if (r.christianSheets && r.lifeSpread !== null && r.lifeSpread !== undefined && r.lifeSpread < 0.5) {
fails.push(`the christian-share page's shaded cells span only ${(r.lifeSpread * 100).toFixed(0)}% of the `
+ 'grid height — they are stacked, not scattered, so the page reads as a bar rather than as rare marks '
+ 'in a whole life. Shade by a deterministic scatter, not a contiguous run.')
}
// ★ THE SHADED SHARE MUST BE NON-ZERO AND NOT EVERYTHING. 0 shaded means the percentage never
// reached the page (a null christian-share file renders the page with an empty grid, which reads as
// "nobody is reached" — a false claim rather than missing data); 5,200 means the maths inverted.
if (r.christianSheets) {
const shaded = r.lifeShaded ?? null
if (shaded !== null && (shaded <= 0 || shaded >= 5200)) {
fails.push(`the christian-share page shaded ${shaded} of 5200 cells — 0 would claim nobody is `
+ `reached and 5200 would claim everybody is; both mean the percentage did not arrive`)
}
}
// The three prayer columns: one tick box per group per question, so the count is exactly 3 x cards.
if (r.cards && r.prayBoxes !== r.cards * 3) {
fails.push(`${r.cards} people cards should carry ${r.cards * 3} prayer tick boxes `
+ `(blessed / what to know / what to do) but the packet has ${r.prayBoxes}`)
}
if (r.cards && r.sheets !== expectedSheets) {
fails.push(`${r.cards} people cards should lay out as ${expectedSheets} sheets `
+ `(1 map${lifeSheets ? ` + ${lifeSheets} life-calendar` : ''}${funnelSheets ? ' + 1 funnel' : ''} + ${Math.ceil(r.cards / PER_PAGE)} list + points + wheel), `
+ `but the packet has ${r.sheets} — either groups were dropped from a page or the `
+ `${PER_PAGE}-per-page grid changed`)
}
// ★ AND IF THE FUNNEL PAGE IS THERE, IT MUST CARRY ITS NUMBERS. A sheet holding the title and no
// figures would satisfy the arithmetic above perfectly — the same "a number printed but not asserted
// on is decoration" trap the card-name check above exists for, one page earlier. .funnel-mine is the
// reader's own share, the one figure on the page that is about THIS packet.
if (r.funnel && !r.funnelSteps) {
fails.push('the funnel page rendered its title but no share figure (.funnel-mine) — '
+ 'page 2 is asserting a narrowing it never states')
}
// ★ AND IF THE CALLER DECLARED IT, ITS ABSENCE IS A FAILURE. See the --expect-funnel note at the top:
// a lost call site produces a packet that is CORRECT but missing page 2, which is why nothing caught
// a merge deleting one.
// ★ EVERY FUNNEL ROW MUST HAVE A BAR. rega measured the first version: the reader's step had no bar,
// so the funnel never visibly narrowed to "yours" — a funnel whose final term is not drawn as part of
// the funnel fails at the one thing it is for. Rows and bars are counted separately so the next
// regression is a mismatch rather than an invisible omission.
if (r.funnel && r.funnelRows !== r.funnelBars) {
fails.push(`the funnel page has ${r.funnelRows} step row(s) but ${r.funnelBars} bar(s) — `
+ 'every step must be drawn as part of the funnel, including the reader\'s own')
}
// ★ AND NO STEP MAY PRINT A ZERO POPULATION. "0 people" appeared on the reader's row of every packet
// because Number(null) is 0 and the renderer's guard tested finiteness rather than PRESENCE. A people
// group with no people is not a fact; it is missing data wearing a number's clothes.
// ★ AND ON A NON-LATIN SHEET, THE FUNNEL'S PROSE MUST BE IN THE READER'S SCRIPT. See funnelLatinRuns
// above for why this is only meaningful in hi/ar/ru/zh. This is the gap rega named after the ?list=
// render: "the gate passed: it counts rows, not the row's language", and it was right — the English
// list label sat among Devanagari and every assertion here was green.
//
// ⚠ THE ALLOWLIST IS TWO ENTRIES AND BOTH ARE DECISIONS, NOT BOOKMARKS:
// IMB, peoplegroups.org — the publisher's name and its domain. A reader following the citation meets
// the English name at the destination, so translating it would hide the thing it identifies. Same
// reasoning the i18n allowlist uses for "Open Bible Stories" and "Scripture Earth".
// Project — the campaign designation ("Project 4700" / "مشروع 4700" / "4700 计划"). Digits break a run,
// so the numeral is not part of the match; the WORD is allowed because it is a name, while the
// description beside it ("frontier people groups") is prose and must be translated.
// If a third entry is ever needed, say in the entry whether it is settled or deferred — an allowlist
// that does not distinguish the two turns an open defect into a verdict.
// 'UPG-' is the Latin run left by "UPG-100", the PRODUCT NAME, which every locale keeps verbatim —
// the same reason 'Project' is here (Project 4700 / 700 are list names, not prose). Verified it is the
// only Latin left in the ar funnel page after the tip and sidebar strings were genuinely translated:
// an allowlist entry added BEFORE translating would have hidden fifteen untranslated strings.
const LATIN_OK = new Set(['IMB', 'peoplegroups.org', 'Project', 'UPG-'])
const NON_LATIN_LOCALES = new Set(['hi', 'ar', 'ru', 'zh', 'he', 'fa', 'ur', 'bn', 'ta', 'th', 'ko', 'ja'])
if (r.funnel && NON_LATIN_LOCALES.has(String(lang).split('-')[0])) {
const stray = (r.funnelLatinRuns || []).filter((x) => !LATIN_OK.has(x))
if (stray.length) {
fails.push(`the funnel page carries Latin-script prose in a ${lang} packet: `
+ `${stray.map((x) => JSON.stringify(x)).join(', ')} — an untranslated string, or a name that `
+ 'belongs in LATIN_OK with a reason')
}
}
if (r.funnel && r.funnelZeroPop) {
fails.push(`${r.funnelZeroPop} funnel row(s) print a ZERO population — absent data must be hidden, `
+ 'not rendered as the number 0 (Number(null) === 0, so test presence, not finiteness)')
}
if (EXPECT_FUNNEL && !r.funnel) {
fails.push('--expect-funnel was passed but this packet has NO funnel page (.funnel-title) — '
+ 'the caller that supplies funnel data to buildPacket is missing or passing null, '
+ 'and a packet without page 2 is otherwise indistinguishable from a correct one')
}
// ── ...AND THAT ALONE IS NOT ENOUGH: IT ONLY PROVES SELF-CONSISTENCY ────────
// ⚠ MUTATION-TESTED, AND THE FIRST VERSION OF THIS GATE FAILED THE TEST. Deleting a
// whole list sheet from a finished packet removed 15 cards AND one sheet, so
// `sheets === 1 + ceil(cards/20) + 2` still held: 80 cards / 7 sheets is internally
// valid arithmetic, and the packet PASSED while missing 15 people groups. A packet can
// be perfectly self-consistent and still have lost content — exactly the shape of the
// index.json "declares 16, 15 on disk" bug.
// So compare against an INDEPENDENT WITNESS the renderer prints for the reader: each
// list page's header carries {from}-{to} of {count} (packet-render.js:219). `count` is
// the zone's TRUE group total, derived from groups.length rather than from the pages,
// so it does not move when a page is lost.
//
// ⚠ PARSE THE NUMBERS, NEVER THE WORD ORDER. That header is TRANSLATED and the
// languages disagree on order: en/ar read "1-20 of 95", hi reads "95 में से 1–20" and
// zh reads "95 之中的 1–20" — the total comes FIRST. A positional regex would read hi
// and zh backwards and assert against 1 instead of 95. Since count >= to >= from always,
// max() recovers the total without knowing any locale's grammar.
if (r.ranges.length) {
const totals = [...new Set(r.ranges.map((t) => Math.max(...t)))]
if (totals.length > 1) {
fails.push(`list pages disagree on the zone total: ${totals.join(' vs ')}`)
} else {
const total = totals[0]
if (r.cards !== total) {
fails.push(`the packet's own page headers say this zone has ${total} people groups, `
+ `but only ${r.cards} cards rendered — ${total - r.cards} were silently dropped`)
}
// The pages must TILE [1..total] with no gap and no overlap: a lost middle page
// leaves a hole that the totals check above cannot see.
const spans = r.ranges.map((t) => { const s = [...t].sort((a, b) => a - b); return [s[0], s[1]] })
.sort((a, b) => a[0] - b[0])
let cursor = 0
for (const [from, to] of spans) {
if (from !== cursor + 1) {
fails.push(`list pages do not tile: expected a page starting at ${cursor + 1}, got ${from} — `
+ 'a page is missing or duplicated')
break
}
cursor = to
}
if (cursor !== total && !fails.some((f) => f.includes('do not tile'))) {
fails.push(`list pages stop at ${cursor} but the zone has ${total} groups`)
}
}
} else if (r.cards) {
fails.push('no list-page range headers found — the "N of M" witness this gate '
+ 'cross-checks the card count against is missing')
}
console.log(` build : ${build || '(unstamped — generated before data-packet-build)'}`)
console.log(` people-group list: ${packetList || '(default)'}`)
if (r.funnel && NON_LATIN_LOCALES.has(String(lang).split('-')[0])) {
console.log(` funnel Latin runs: ${(r.funnelLatinRuns || []).length
? r.funnelLatinRuns.map((x) => JSON.stringify(x)).join(', ') : '(none)'}`)
}
console.log(` sheets/PDF pages : ${r.sheets} / ${pages}`)
console.log(` printed artefact : ${(pdfBytes / 1024 / 1024).toFixed(1)}MB, ${pdfFonts} embedded font(s), `
+ `${pdfImages} image(s) for ${r.distinctImgSrc} distinct source(s)`)
console.log(` people cards : ${r.cards} (${r.imgs} photos, ${r.nophoto} typographic)`)
console.log(` funnel (page 2) : ${r.funnel
? `${r.funnelRows} row(s), ${r.funnelBars} bar(s)${r.funnelShare ? ` — "${r.funnelShare}"` : ' — no share line'}`
: 'absent' + (EXPECT_FUNNEL ? ' (REQUIRED — see FAIL above)' : ' (not required)')}`)
console.log(` prayer points : ${r.points} / ${POINTS_EXPECT}`)
console.log(` wheel segments : ${r.wheelSegs} (${r.wheelLabels}/${WHEEL_LABELS} labelled)`)
console.log(` Mark 5 in full : ${r.mark5Len} chars`
+ `${scriptureIsEnglish ? ' ⚠ UNTRANSLATED (English fallback)' : ''}`)
console.log(` metacamp.org : ${r.metacamp}`)
console.log(` map (page 1) : ${r.mapFaults.length
? `${r.mapFaults.join('; ')}${expectMap ? '' : ` [declared deliberate: ${/\bnomap\b/.test(build) ? 'data-packet-build="' + build + '"' : 'PACKET_EXPECT_MAP=0'}]`}`
: `${r.mapGeoPaths} geography paths, palette resolves, not black`}`)
console.log(` page size : ${boxes[0] ? boxes[0].join(' x ') : '?'} pt`
+ `${boxes.length ? ` (all ${boxes.length} pages, asserted US Letter)` : ''}`)
console.log(` layout : ${r.cards} cards / ${PER_PAGE} per page `
+ `= ${expectedSheets} sheets expected, ${r.sheets} rendered`)
console.log(` script font : ${fontStatus.declared ? fontStatus.names.join(', ') : 'system stack'}`)
console.log(` text layout : ${layout.pinnedRight} right / ${layout.pinnedLeft} left of `
+ `${layout.sampled} sampled run(s)${layout.sampled < 5 ? ' (too few to assert)' : ''}`)
console.log(` language : ${lang} (dir=${dir}) (${r.rhythms}/${r.rhythmsTotal} rhythms in ${lang}`
+ `${translated === null ? '' : `, ${translated} strings translated`})`)
if (fails.length) {
console.error(`\nPACKET-RENDER: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(` - ${f}`)
process.exit(1)
}
// ⚠ THE VERDICT MUST NOT CLAIM MORE THAN WAS CHECKED. It read "map painted" unconditionally,
// so a packet passing under PACKET_EXPECT_MAP=0 — one with NO map on page 1 — was summarised
// as having a painted map. The summary line is the part a reader actually quotes.
console.log(`\nPACKET-RENDER: PASS — ${r.sheets} sheets, ${r.sheets} pages, portrait, `
+ `${r.mapFaults.length ? 'NO map (declared)' : 'map painted'}, content intact.`)