A leak gate that had to be corrected twice before it could be trusted

rega's method: a string is a LEAK when the catalogue has a different value for
the locale and the page still shows the English one — a render fault, not a
missing translation. This measures that across four leaves and four locales.

IT TOOK THREE VERSIONS AND THE FIRST TWO WERE WRONG IN INSTRUCTIVE WAYS.

One: it failed with no explanation. process.exit(1) terminates before pending
stderr flushes, so the run returned 1 with every reason line missing. A gate that
cannot say why it failed is barely better than no gate. It sets exitCode now and
lets the process end.

Two: it read the whole shadow root and reported 113 leaks, nearly all false.
"JESUS Film" and "Scripture" appear inside the explanatory paragraphs this bundle
deliberately keeps in English, and short entries like "for" match inside ordinary
sentences. Crying 113 when a handful are real is worse than silence — someone
would have gone and "fixed" strings that were already correct. Every English
prose block here declares itself dir="ltr", so the check now walks the shadow
root and skips those subtrees, and ignores catalogue entries under six
characters.

WHAT IT FINDS, now that it is trustworthy: 70 real leaks, and every one is in
resources-list. resources-map, data-tables and charts are clean in ar, zh, ru and
hi. That is exactly the debt I declared two commits ago — that leaf got its title
translated and nothing else — now measured instead of estimated.

Scope is the leaf on purpose. Six keys were reported leaking in the gallery and
the leaf is clean for all six: the gallery builds its iframe src with no lang
parameter, so the embed never learns the locale and correctly falls back to
English. Embedder defect, not a bundle one, and a check that conflated them would
send someone to translate what is already translated.
This commit is contained in:
doxa-maps-88655c70 2026-09-25 11:43:16 +00:00
parent c73b775b6d
commit 299adaa9e3
2 changed files with 118 additions and 2 deletions

View file

@ -0,0 +1,114 @@
/**
* check-gospel-english-leaks.mjs — a translated key must not render in English.
*
* THE METHOD, from rega by way of dx-shepherd. A string is a LEAK when the catalogue has a
* DIFFERENT value for the locale and the page still shows the English one. That is a render
* or wiring fault, and it is a different thing from an untranslated string, which is a
* catalogue gap. Only the first is this check's business.
*
* WHAT IT DOES NOT COUNT, so the number means something:
* - third-party strings, e.g. the Mapbox and geoBoundaries attributions, which are licence
* obligations and must never be translated;
* - DATA, the people-group and place names that come from the publishers;
* - PLACEHOLDER-ONLY strings;
* - the explanatory PROSE this bundle deliberately keeps in English, which is declared by
* being absent from the catalogue entirely — if there is no translated value, there is
* nothing to leak.
*
* SCOPE: THE LEAF, AND THAT BOUNDARY IS THE POINT. Six keys were reported leaking in the
* gallery. Measured here, the standalone leaf is clean in ar, zh, ru and hi — and the
* gallery's iframe src is built without a lang parameter, so the embedded leaf never learns
* the locale and correctly falls back to English. That is an embedder defect, not a bundle
* one, and a check that conflated them would have sent someone to translate strings that are
* already translated.
*/
import { chromium } from 'playwright'
import { readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { serveReaped } from './_serve-reaped.mjs'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '..')
const PORT = Number(process.env.PORT || 5399)
const BASE = process.env.BASE_URL || ('http://127.0.0.1:' + PORT)
const LOCALES = (process.env.LOCALES || 'ar,zh,ru,hi').split(',')
const LEAVES = ['resources-map', 'resources-list', 'data-tables', 'charts']
let srv = null
if (!process.env.BASE_URL) srv = serveReaped(resolve(REPO, 'public'), PORT)
const cat = (lc) => JSON.parse(readFileSync(resolve(REPO, 'library/i18n/locales/' + lc + '/gospel.json'), 'utf8'))
const flat = (o, p = []) => Object.entries(o).flatMap(([k, v]) =>
k.startsWith('_') ? [] : (v && typeof v === 'object' ? flat(v, [...p, k]) : [[[...p, k].join('.'), String(v)]]))
let browser
try {
browser = await chromium.launch()
const en = Object.fromEntries(flat(cat('en')))
const leaks = []
let compared = 0
for (const leaf of LEAVES) {
for (const lc of LOCALES) {
const loc = Object.fromEntries(flat(cat(lc)))
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
await page.goto(BASE + '/doxa-maps/gospel-resources/' + leaf + '/index.html?lang=' + lc,
{ waitUntil: 'networkidle', timeout: 90000 })
await page.waitForTimeout(8000)
/* ═══ READ ONLY THE CHROME, NOT THE DECLARED-ENGLISH PROSE ═══════════════════════
* The first version read the whole shadow root and reported 113 leaks, nearly all
* false: "JESUS Film" and "Scripture" appear inside the explanatory paragraphs this
* bundle deliberately keeps in English, and three-letter catalogue entries like "for"
* match inside ordinary English sentences. Both are noise, and a gate that cries 113
* when 0 are real is worse than none — someone would have gone and "fixed" strings
* that were already correct.
* Every English prose block in this bundle declares itself with dir="ltr". So the
* chrome is everything OUTSIDE those subtrees, which is exactly the population a
* translated key is supposed to reach. */
const text = await page.evaluate(() => {
const sr = document.querySelector('gospel-resources-map')?.shadowRoot
if (!sr) return ''
/* A ShadowRoot is not clonable, so walk it and skip declared-English subtrees. */
const parts = []
const walk = (node) => {
for (const child of node.childNodes) {
if (child.nodeType === 3) { parts.push(child.textContent); continue }
if (child.nodeType !== 1) continue
if (child.getAttribute && child.getAttribute('dir') === 'ltr') continue
if (child.tagName === 'STYLE' || child.tagName === 'SCRIPT') continue
walk(child)
}
}
walk(sr)
return parts.join(' ').replace(/\s+/g, ' ')
})
await page.close()
if (!text) { leaks.push(leaf + ' ' + lc + ': rendered nothing at all'); continue }
for (const [key, e] of Object.entries(en)) {
const t = loc[key]
/* Short entries match inside unrelated English words; six characters is the floor
at which a match is about the key rather than about the alphabet. */
if (!t || t === e || e.length < 6) continue
compared++
if (text.includes(e) && !text.includes(t)) leaks.push(leaf + ' ' + lc + ' ' + key + ': shows English "' + e.slice(0, 40) + '"')
}
}
}
console.log(' compared ' + compared + ' key/locale pair(s) across ' + LEAVES.length + ' leaves and ' + LOCALES.length + ' locales')
if (leaks.length) {
/* exitCode, NOT process.exit(). process.exit() terminates before pending stdout/stderr
writes flush, and this gate proved it on itself: its first real run returned 1 with
every explanatory line missing, so it failed without saying why. A gate that cannot
explain its own failure is barely better than no gate. */
console.error('GOSPEL-ENGLISH-LEAKS: FAIL — ' + leaks.length + ' translated key(s) rendering in English:')
for (const l of leaks.slice(0, 12)) console.error(' ' + l)
process.exitCode = 1
} else {
console.log('GOSPEL-ENGLISH-LEAKS: PASS — every key with a translation renders in it.')
}
} finally {
await browser?.close()
srv?.stop()
}

View file

@ -200,7 +200,8 @@
"check:leaves-mount:poison": "node internal/check-gospel-leaves-mount.mjs --poison",
"check:gospel-all": "node internal/check-gospel-all.mjs",
"check:no-conflict-markers": "node internal/check-no-conflict-markers.mjs",
"check:no-conflict-markers:poison": "node internal/check-no-conflict-markers.mjs --poison"
"check:no-conflict-markers:poison": "node internal/check-no-conflict-markers.mjs --poison",
"check:english-leaks": "node internal/check-gospel-english-leaks.mjs"
},
"//scripts": {
"_": "What each script does. These notes used to sit inside the command strings as trailing \"# …\" comments, which made the shell discard any arguments passed after \"--\" (so a gate given --url silently measured its hardcoded default port — possibly another agent's server). Keep notes here, never in a command.",
@ -302,7 +303,8 @@
"check:gallery-embed:live-cors": "the Research Gallery on a foreign page under the LIVE static layer's CORS rule (Access-Control-Allow-Origin on files inside the tree's folders, NOT on the root manifest.json, measured on nuxtbase): it must still boot every tab, by reading the CORS-readable manifest copy generate-tree writes beside its bundle.",
"check:leaves-mount": "DID THE ELEMENT ACTUALLY MOUNT, on all five gospel leaves. Exists because a ReferenceError thrown during setup dies INSIDE the custom-element boundary: the page loads, every asset returns 200, pageerror fires zero times, and the element sits there with an empty shadow root. A healthy-looking dead page. This asks the cheap unambiguous question the rich gates are too slow to ask on every leaf — is anything there — in five steps that each name their own failure: tag defined, host present, shadowRoot attached, shadow root non-empty, visible text rendered. It does NOT trust the absence of console errors; zero errors is not the same as working. --poison for the negative test.",
"check:gospel-all": "EVERYTHING gospel-resources owns, in one command. Its ten gates were all hand-run, which is how a dead bundle shipped: a duplicate module killed all five leaves and the one gate run that day was pointed at the map. check:no-orphan-gates was right that they were reachable — reachable is not run. Deliberately NOT in gates:pre/post: three need a browser, the shared wave holds browser gates under load, and a bundle owner should not be able to slow everyone else's build by adding to a list they do not own. Static gates run first and stop the run on failure, so a scarce browser slot is never spent confirming a table is inconsistent. Exit 75 = NOT MEASURED, never folded into a pass.",
"check:no-conflict-markers": "Nothing tracked may carry an unresolved merge. Exists because a merge from this worktree committed a BUILT bundle with 21 conflict markers - both sides concatenated, 1.13MB - and every gospel leaf rendered dead while the SRI hash matched and every request returned 200. The cause was a landing script that resolved one expected file then ran \"git add -A public\", staging every other file including the ones still holding markers. For a built file: take either side, REBUILD, commit the rebuild, then run this before pushing. --poison for the negative test."
"check:no-conflict-markers": "Nothing tracked may carry an unresolved merge. Exists because a merge from this worktree committed a BUILT bundle with 21 conflict markers - both sides concatenated, 1.13MB - and every gospel leaf rendered dead while the SRI hash matched and every request returned 200. The cause was a landing script that resolved one expected file then ran \"git add -A public\", staging every other file including the ones still holding markers. For a built file: take either side, REBUILD, commit the rebuild, then run this before pushing. --poison for the negative test.",
"check:english-leaks": "A translated key must not render in English. rega's method: a string is a LEAK when the catalogue has a DIFFERENT value for the locale and the page still shows the English one - a render/wiring fault, not a missing translation. Third-party attributions, data names, placeholder-only strings and the prose this bundle deliberately keeps in English are all excluded, the last by simply not being in the catalogue. Scoped to the LEAF on purpose: six keys were reported leaking in the gallery and the leaf is clean in ar/zh/ru/hi - the gallery builds its iframe src with no lang parameter, so the embed never learns the locale. Embedder defect, not a bundle one."
},
"devDependencies": {
"@capacitor/android": "^7.6.9",