#!/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 = { point4_passage: 'Mark 5:1-20 in full — public-domain licensing per language (DECIDE-MARK5-…)', point4_passage_credit: 'the attribution line for the passage above', } // 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') } } 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.')