Guard the packet catalog: the 12 prayer points and the wheel had no parity gate
check:i18n-parity covers library/i18n/locales — the APP catalogs. Nothing covered library/scripts/posters/packet-strings.json, which is a separate file with its own shape and is where the 12 PRAYER POINTS and the PRAYER WHEEL live. SIX gates read it and not one checked that a language block is complete. All ten locales happened to be healthy — 19/21 string keys, 12 points with every field, 12 rhythms — but nothing held them there. A point dropped from one block renders that slot through the per-key English fallback: correct runtime behaviour, invisible defect, on a page the reader is meant to pray through. ★ IT ASSERTS PRESENCE, NOT TRANSLATEDNESS, and that is the load-bearing choice. check-packet-render printed "19 strings translated" and only ever asserted `!== 0`, so a locale regressing from 19 to 1 passed silently. The fix is NOT to pin 19: measured, de translates 18 of the same 19 keys and fr 17, because a word or two legitimately matches English — a hardcoded count fails two CORRECT catalogs. So parity asserts every English key EXISTS in every block, a structural fact no translation choice can change, and the render gate gains a FLOOR of two-thirds derived from the catalog itself. point4_passage + point4_passage_credit are DECLARED-ABSENT with the reason on record (Mark 5:1-20, public-domain licensing per language). Naming the exception beats tolerating it silently: the day a licensed translation lands, deleting the entry tightens the gate by itself. MUTATION-TESTED, five defect classes, each caught with a precise message: a dropped string key, an 11th-of-12 prayer point, an EMPTY point text, a missing wheel rhythm, and a block reverted wholesale to English. The floor was tested separately by reverting de to 3 translated of 19 — "only 3 differ from English (floor 13)". ★ THE TWO GATES ANSWER DIFFERENT QUESTIONS, and a partial language shows both at once. Swahili at 3 of 21 keys, no points, no rhythms: check-packet-render PASSES — a partial translation renders, Swahili where translated, English where not, zero blank slots — while check:packet-strings-parity FAILS and names exactly what is missing, including that the points and wheel would fall back wholesale. Before this, only the first question had a gate, so a half-finished block was indistinguishable from a complete one at build time. gates:post is now 22 steps; 11/11 packets still pass the sweep.
This commit is contained in:
parent
820a9015ca
commit
6732c8d430
3 changed files with 155 additions and 2 deletions
|
|
@ -244,6 +244,25 @@ 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`)
|
||||
|
|
|
|||
133
internal/check-packet-strings-parity.mjs
Normal file
133
internal/check-packet-strings-parity.mjs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
#!/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.')
|
||||
|
|
@ -141,12 +141,13 @@
|
|||
"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.",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:wp-embed check:doc-scripts",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity",
|
||||
"build:bundles": "node internal/run-parallel.mjs --bundles",
|
||||
"check:maplibre-drops": "node internal/check-maplibre-layer-drops.mjs",
|
||||
"check:tabs-localized": "node internal/check-tabs-localized.mjs # a chosen locale must CHANGE the rendered tab text. check:i18n-parity compares catalogs to each OTHER, so it cannot tell a shipped translation from a dead one; tabs.json is reached only via a DYNAMIC key (SemanticTreeLegend.vue:40), invisible to any grep. Renders it and reads the result.",
|
||||
"check:packets-all": "node internal/check-packets-all.mjs # runs check:packet-render over EVERY generated packet, not one hand-named file. The single-file gate SKIPs with no argument, so it was in package.json, passing in all 11 locales, and in neither gates:pre nor gates:post — it only ran when someone typed a filename. SKIPs when no packets are generated.",
|
||||
"check:poster-print-fallback": "node internal/check-poster-print-fallback.mjs # clicks the REAL poster print control with every posters/*.webp aborted, and asserts the preview RASTERISES from posters/<id>.html instead of opening on \"Poster not available yet\". Needs a served tree built with posterSources:true; SKIPs otherwise."
|
||||
"check:poster-print-fallback": "node internal/check-poster-print-fallback.mjs # clicks the REAL poster print control with every posters/*.webp aborted, and asserts the preview RASTERISES from posters/<id>.html instead of opening on \"Poster not available yet\". Needs a served tree built with posterSources:true; SKIPs otherwise.",
|
||||
"check:packet-strings-parity": "node internal/check-packet-strings-parity.mjs # the PACKET catalog (packet-strings.json) must be whole in every language: all string keys, all 12 prayer points with every field, all 12 wheel rhythms. check:i18n-parity covers library/i18n/locales only — six gates READ this file and none checked it. Asserts PRESENCE, not translatedness, with point4_passage declared-absent on record."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/android": "^7.6.9",
|
||||
|
|
|
|||
Loading…
Reference in a new issue