doxa-maps/internal/check-packet-strings-parity.mjs
Doxa Maps 7e18948698 Prayer points page: the seven prayer themes and three prayer scenes head the twelve points, x11; point 12 is the John 17 blessing ladder
Themes (bless the nations, perfect unity, protection from evil, hearing God's voice, true identity in Christ,
deep relational health, the priesthood of believers) to weave into the wheel's five-minute blocks, then the
scenes treasure, mountain and sea, each with its two references from the Wall of People Groups' poster
(Haggai 2:7 · Matthew 13:44, Isaiah 2:2 · Matthew 21:21, Habakkuk 2:14 · Matthew 8:26). Point 12: the blessing,
the great, the greater and one of the greatest blessings of John 17, unity and protection, beyond all we ask
or think (John 17:3 · John 17:11–23 · Ephesians 3:20). Words drafted by the gospel lane x11; books Haggai,
Isaiah, Habakkuk, Ephesians x11. The page still fits one sheet in every language (19px text, 6px between points).
Gates: parity fails a point whose references are not English's (ten languages kept point 12's old ones) and
needs 7 themes + 3 scenes per language; variants checks the themes block and point 12 in both packets.
2026-10-02 21:51:18 +00:00

182 lines
11 KiB
JavaScript

#!/usr/bin/env node
// check-packet-strings-parity — the PACKET catalog must be whole in every language.
//
// ★ THE GAP THIS CLOSES. check:i18n-parity guards library/i18n/locales — the APP
// catalogs — and nothing guarded library/scripts/posters/packet-strings.json, which
// is a separate file with its own shape. Six gates READ it (check-packet-render,
// check-card-deck, check-print-geometry, check-packet-browser, check-locale-reachable,
// check-poster-prints) and not one checks that a language block is complete.
//
// That matters because the packet is where the 12 PRAYER POINTS and the PRAYER WHEEL
// live, and those are a stated requirement: they must be translatable into every
// locale doxa-life needs. A point silently dropped from one block renders that slot
// through the per-key English fallback — correct runtime behaviour, invisible defect.
//
// ⚠ IT ASSERTS PRESENCE, NOT TRANSLATEDNESS, and that distinction is load-bearing.
// check-packet-render prints "19 strings translated" and only ever asserted
// `translated === 0`, so a locale regressing from 19 to 1 passed silently. The fix is
// NOT to pin 19: measured 2026-09-24, de translates 18 and fr 17 of the same 19 keys
// because a word or two legitimately matches English ("Metacamp", "Status"). Pinning a
// count fails correct catalogs. What DOES hold for every locale is that every English
// key EXISTS in every block — a structural fact no translation choice can change.
//
// THE ONE DECLARED EXCEPTION is point4_passage + point4_passage_credit: Mark 5:1-20 in
// full, absent from all ten non-English blocks. That is a LICENSING decision, not an
// oversight — the English is deliberately public-domain (World English Bible) and most
// major translations are copyrighted, so it is tracked as
// DECIDE-MARK5-SCRIPTURE-IS-ENGLISH-IN-EVERY-TRANSLATED-PACKET. It is named here
// rather than silently tolerated, so the day someone lands a licensed translation the
// exception can be deleted and the gate tightens by itself.
import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const REPO = process.env.DOXA_REPO
|| resolve(dirname(fileURLToPath(import.meta.url)), '..')
const CATALOG = process.env.PACKET_STRINGS
|| join(REPO, 'library/scripts/posters/packet-strings.json')
if (!existsSync(CATALOG)) {
console.error(`PACKET-STRINGS-PARITY: FAIL — no catalog at ${CATALOG}`)
process.exit(1)
}
const cat = JSON.parse(readFileSync(CATALOG, 'utf8'))
const en = cat.en
if (!en) { console.error('PACKET-STRINGS-PARITY: FAIL — no English block to compare against'); process.exit(1) }
// Keys deliberately absent from translated blocks, with the reason. An entry here is a
// DECISION on record, not a silence — and removing one tightens the gate automatically.
const DECLARED_ABSENT = {
// ⚠ STILL DECLARED, BUT FOR FIVE FEWER LOCALES (2026-09-27). ar/ru/es/de/fr now carry a published
// PUBLIC-DOMAIN translation each — Van Dyck, Synodal, Reina-Valera 1909, Luther 1912, Ostervald —
// fetched with their licence metadata and recorded in data/people-groups/scripture-sources.json.
// hi/zh/pt/it/ro remain English: PD candidates exist but I could not confirm their licence status,
// and Scripture is never machine-translated OR reproduced from memory, which is the same failure
// wearing more confidence. This entry shrinks as each locale's text is cited, not as a batch.
point4_passage: 'Mark 5:1-20 — awaiting a CITED public-domain translation for hi/zh/pt/it/ro',
point4_passage_credit: 'the attribution line for the passage above',
point4_passage_bold: 'verse 19, verbatim from the passage above (so only where that passage is on file)',
// Numbers 6:24-26 on the lightweight list's first page: ro and hi have no strict public-domain text on the
// pipeline that vetted the other nine, so they print the English per key rather than an unvetted rendering.
blessing_text: 'Numbers 6:24-26 — no strict public-domain text for ro (only a Cyrillic-script 1924) or hi',
blessing_credit: 'the attribution line for the blessing above',
// ── The new packet pages, 2026-09-27: a 100-year life calendar, the same grid shaded by
// the evangelical share of these groups' population, and the funnel's equation sidebar + tip.
// ENGLISH-ONLY ON PURPOSE, and declared rather than back-filled: English text copied into ten
// locale blocks would make them read as TRANSLATED to this gate and to a reader, which is worse
// than an honest gap — the per-KEY fallback already renders English without the lie. These come out
// of this list one at a time as real translations arrive.
}
// Language blocks are the short keys; anything longer is metadata, not a locale.
const locales = Object.keys(cat).filter((k) => k !== 'en' && k.length <= 3)
const strKeys = Object.keys(en).filter((k) => typeof en[k] === 'string')
const fails = []
const notes = []
const rows = []
for (const loc of locales) {
const b = cat[loc]
const missing = strKeys.filter((k) => typeof b[k] !== 'string' && !(k in DECLARED_ABSENT))
const absentOk = strKeys.filter((k) => typeof b[k] !== 'string' && (k in DECLARED_ABSENT))
if (missing.length) {
fails.push(`${loc} is missing ${missing.length} string key(s): ${missing.slice(0, 4).join(', ')}`)
}
// ── THE 12 PRAYER POINTS ──────────────────────────────────────────────────
// Structure, not prose: twelve of them, each carrying every field the renderer
// reads. A point with no `text` prints an empty block on a page the reader is
// meant to pray through.
const pts = b.points
if (!Array.isArray(pts)) {
fails.push(`${loc} has no points array — the 12 prayer points would fall back wholesale to English`)
} else if (pts.length !== en.points.length) {
fails.push(`${loc} has ${pts.length} prayer points, expected ${en.points.length}`)
} else {
for (let i = 0; i < pts.length; i++) {
const missF = Object.keys(en.points[i]).filter((f) => pts[i][f] === undefined)
if (missF.length) fails.push(`${loc} point ${i + 1} is missing ${missF.join(', ')}`)
if (typeof pts[i].text === 'string' && !pts[i].text.trim()) {
fails.push(`${loc} point ${i + 1} has an EMPTY text — a blank prayer point is worse than an English one`)
}
}
}
// ── THE PRAYER WHEEL ──────────────────────────────────────────────────────
// Twelve rhythms, keyed the same as English. check-packet-render already asserts
// all twelve RENDER; this asserts the catalog can supply them in the first place.
const rh = b.rhythms
if (!rh || typeof rh !== 'object') {
fails.push(`${loc} has no rhythms block — the prayer wheel would be English in a ${loc} packet`)
} else {
const missR = Object.keys(en.rhythms).filter((k) => typeof rh[k] !== 'string')
if (missR.length) fails.push(`${loc} is missing ${missR.length} wheel rhythm(s): ${missR.slice(0, 4).join(', ')}`)
}
const translated = strKeys.filter((k) => typeof b[k] === 'string' && b[k] !== en[k]).length
const present = strKeys.filter((k) => typeof b[k] === 'string').length
rows.push({ loc, present, translated, absentOk: absentOk.length })
// Reported, never failed: a locale sharing a word with English is normal, but a
// locale sharing ALL of them is indistinguishable from an untranslated block.
if (present && translated === 0) {
fails.push(`${loc} has ${present} string(s) and NOT ONE differs from English — `
+ 'the block is present but untranslated, which a reader cannot tell from a missing one')
}
}
// ── SCRIPTURE ADDRESSES ARE ENGLISH'S, AND THE THEMES ARE WHOLE ───────────────────
// A point's refs are canonical addresses: a language may not keep an older set (point 12 kept Acts 2 / Matthew 28
// in ten languages after English moved to John 17). The prayer themes are seven and the scenes three in every
// language, or the page falls back to English for the whole list.
for (const loc of locales) {
const b = cat[loc]
;(b.points || []).forEach((pt, i) => {
if (pt && pt.refs !== undefined && JSON.stringify(pt.refs) !== JSON.stringify((en.points[i] || {}).refs)) {
fails.push(`${loc} point ${i + 1} carries references ${JSON.stringify(pt.refs)}, not English's ${JSON.stringify((en.points[i] || {}).refs)}`)
}
})
if (!Array.isArray(b.themes) || b.themes.length !== 7 || b.themes.some((x) => typeof x !== 'string' || !x.trim())) fails.push(`${loc} needs 7 prayer themes`)
if (!Array.isArray(b.scenes) || b.scenes.length !== 3 || b.scenes.some((x) => typeof x !== 'string' || !x.trim())) fails.push(`${loc} needs 3 prayer scenes`)
}
if (!Array.isArray(en.scene_refs) || en.scene_refs.length !== 3 || en.scene_refs.some((r) => !Array.isArray(r) || r.length !== 2)) fails.push('en scene_refs must give each of the 3 scenes two references')
// ── ARABIC: NO ALEF WASLA (U+0671) ─────────────────────────────────────────────
// The Van Dyck texts write the article with alef wasla (ٱ). A device whose fonts lack U+0671 printed it as a
// BOX, three times in the blessing and eight in Mark 5. The stored ar text uses plain alef (U+0627), the
// ordinary print form: display only, no word or vowel changes (_meta.note_on_blessing). Asserted on EVERY ar
// value, nested ones included, so the next passage pasted from the same source cannot bring it back.
{
const hits = []
const walk = (v, path) => {
if (typeof v === 'string') { const n = [...v].filter((ch) => ch === '\u0671').length; if (n) hits.push(`${path} (${n})`) }
else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) walk(x, `${path}.${k}`)
}
walk(cat.ar || {}, 'ar')
if (hits.length) fails.push(`ar carries U+0671 alef wasla, which prints as a box where a font lacks it — store plain alef (U+0627): ${hits.slice(0, 4).join(', ')}`)
else notes.push('ar: no U+0671 alef wasla in any value (plain alef, the print form every Arabic font draws)')
}
console.log('PACKET-STRINGS-PARITY')
console.log(` catalog : ${CATALOG.replace(REPO + '/', '')}`)
console.log(` locales : ${locales.length} (${locales.join(' ')})`)
console.log(` shape : ${strKeys.length} string key(s) + ${en.points.length} prayer points + `
+ `${Object.keys(en.rhythms).length} wheel rhythms`)
for (const r of rows) {
console.log(` ${r.loc.padEnd(3)} ${String(r.present).padStart(2)}/${strKeys.length} present`
+ ` ${String(r.translated).padStart(2)} translated`
+ (r.absentOk ? ` (${r.absentOk} declared-absent)` : ''))
}
if (Object.keys(DECLARED_ABSENT).length) {
console.log('\n declared absent from every translated block, on record:')
for (const [k, why] of Object.entries(DECLARED_ABSENT)) console.log(` ${k} — ${why}`)
}
for (const n of notes) console.log(' ' + n)
if (fails.length) {
console.error(`\nPACKET-STRINGS-PARITY: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(' - ' + f)
process.exit(1)
}
console.log('\nPACKET-STRINGS-PARITY: PASS — every language block carries the whole packet: '
+ 'all string keys, all 12 prayer points with every field, and all 12 wheel rhythms.')