gate: a key copied into all 11 catalogs is not a translated key
Some checks are pending
build-gate / build (push) Waiting to run
Some checks are pending
build-gate / build (push) Waiting to run
check:i18n-parity asserts every locale carries every key -- and a key can pass
that perfectly while holding the English string in all eleven files, which is
what "translated" was supposed to mean. Presence and translatedness are
different properties, and only the first was measured.
Swept every catalog: exactly THREE keys are byte-identical across all locales,
out of 210 compared. Finding three rather than three hundred is what makes this
worth gating -- it is signal, not nagging.
★ THE DEFECT IT FOUND. legend.columns.upgs is the literal string "UUPGs" in all
11 catalogs, while `population` sitting beside it IN THE SAME HEADER ROW
translates normally -- Población / 人口 / Население / السكان. The legend renders
a fully localized row with one English acronym wedged into it, and prayerLaps
carries the same string.
AND THE PRODUCT HAS ALREADY RULED ON THE CONCEPT, which is what makes this a
defect rather than a preference: packet-strings.json translates the same idea in
prose in every locale -- "unreached people groups" becomes grupos étnicos no
alcanzados / 未得之民 / неохваченных народов. The legend disagrees with the packet
the legend generates. Two honest surfaces, one vocabulary, no agreement.
⚠ IT DOES NOT PROPOSE TRANSLATIONS, and that restraint is deliberate. Drafting
candidates here produced invented initialisms -- PNA, UEVG, ННГ -- assembled
from translated phrases with no source establishing any of them as the field's
term. A wrong term looks AUTHORITATIVE to a reader who cannot check it, which is
worse than an honest English acronym. The gate names what is untranslated; a
human who knows the vocabulary supplies the words.
⚠ AND IT CARRIES AN ALLOWLIST, because some keys are correctly identical: pure
interpolation formats, brand names, ISO designations. The sweep found one --
legend.progress.segment is "{label} · {count} · {pct}%", all placeholders. Each
entry needs a REASON, so the list reads as a record of decisions rather than a
silencer.
MUTATION-TESTED THREE WAYS: making ONE locale match English is correctly NOT
flagged (it requires all of them, so a genuinely translated key cannot trip it);
making a key identical in ALL eleven IS flagged; and allowlisting a real finding
moves it from the failure list to the allowed list, 2 down to 1.
This commit is contained in:
parent
d1bbcf024d
commit
bc368dadda
2 changed files with 135 additions and 1 deletions
133
internal/check-i18n-never-translated.mjs
Normal file
133
internal/check-i18n-never-translated.mjs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-i18n-never-translated — a key present in all 11 catalogs but IDENTICAL in all of them is
|
||||
* probably not translated, only copied.
|
||||
*
|
||||
* THE GAP THIS CLOSES. check-i18n-parity asserts PRESENCE — every locale carries every key. A key
|
||||
* can pass that perfectly while holding the English string in all eleven files, which is what
|
||||
* "translated" was supposed to mean. Presence and translatedness are different properties, and
|
||||
* only the first one was measured.
|
||||
*
|
||||
* MEASURED 2026-09-25 across every catalog: exactly THREE keys are byte-identical in all locales.
|
||||
* That the sweep finds three rather than three hundred is what makes it worth gating — it is
|
||||
* signal, not nagging.
|
||||
*
|
||||
* ★ THE DEFECT IT FOUND, and why it is a defect rather than a preference: legend.columns.upgs is
|
||||
* the literal string "UUPGs" in all 11 catalogs, while `population` sitting beside it in the SAME
|
||||
* header row translates normally (Población / 人口 / Население / السكان). So the legend renders a
|
||||
* fully localized row with one English acronym wedged into it.
|
||||
* THE PRODUCT HAS ALREADY RULED ON THIS CONCEPT. packet-strings.json translates the same idea in
|
||||
* prose in every locale — "unreached people groups" becomes grupos étnicos no alcanzados / 未得之民 /
|
||||
* неохваченных народов / شعبًا غير مبلَغ. The legend disagrees with the packet the legend generates.
|
||||
* That is the "two honest surfaces that disagree" family: each is internally consistent, and
|
||||
* together they tell a reader the product does not know its own vocabulary.
|
||||
*
|
||||
* ⚠ IT IS A HEURISTIC, SO IT CARRIES AN ALLOWLIST. Some keys are CORRECTLY identical everywhere:
|
||||
* pure interpolation formats with no prose, brand names, ISO designations. The sweep found one —
|
||||
* legend.progress.segment is "{label} · {count} · {pct}%", all placeholders. Those are declared
|
||||
* below WITH a reason, so the list is a record of decisions rather than a silencer.
|
||||
*
|
||||
* ⚠ AND IT DOES NOT PROPOSE TRANSLATIONS. Inventing terminology across 11 languages is worse than
|
||||
* leaving an English acronym: a wrong term looks authoritative to a reader who cannot check it.
|
||||
* Drafting candidates here produced invented initialisms (PNA, UEVG, ННГ) with no source, which is
|
||||
* exactly the failure this comment exists to prevent. The gate names what is untranslated; a human
|
||||
* who knows the field's vocabulary supplies the words.
|
||||
*
|
||||
* Usage: node internal/check-i18n-never-translated.mjs
|
||||
*/
|
||||
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const REPO = process.env.DOXA_REPO || resolve(HERE, '..')
|
||||
const BASE = resolve(REPO, 'library/i18n/locales')
|
||||
|
||||
// Keys that are CORRECTLY the same in every language. Each needs a reason, so the next reader can
|
||||
// tell a decision from a suppression.
|
||||
const ALLOWED = new Map([
|
||||
['legend.json:progress.segment',
|
||||
'pure interpolation — "{label} · {count} · {pct}%" contains no prose to translate'],
|
||||
])
|
||||
|
||||
// Below this many locales the comparison is not meaningful — a key present in two files being the
|
||||
// same says nothing.
|
||||
const MIN_LOCALES = 8
|
||||
|
||||
if (!existsSync(BASE)) {
|
||||
console.log(`I18N-NEVER-TRANSLATED: SKIP — no locale tree at ${BASE}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const langs = readdirSync(BASE).filter((l) => {
|
||||
try { return statSync(join(BASE, l)).isDirectory() } catch { return false }
|
||||
}).sort()
|
||||
if (!langs.includes('en')) {
|
||||
console.log('I18N-NEVER-TRANSLATED: SKIP — no `en` catalog to compare against')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const flat = (obj, pre = '') => {
|
||||
const out = {}
|
||||
for (const [k, v] of Object.entries(obj || {})) {
|
||||
const p = `${pre}${k}`
|
||||
if (v && typeof v === 'object' && !Array.isArray(v)) Object.assign(out, flat(v, `${p}.`))
|
||||
else if (typeof v === 'string') out[p] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
const read = (lang, file) => {
|
||||
const p = join(BASE, lang, file)
|
||||
if (!existsSync(p)) return null
|
||||
try { return flat(JSON.parse(readFileSync(p, 'utf8'))) } catch { return null }
|
||||
}
|
||||
|
||||
const files = readdirSync(join(BASE, 'en')).filter((f) => f.endsWith('.json')).sort()
|
||||
const findings = []
|
||||
const allowedHits = []
|
||||
let compared = 0
|
||||
|
||||
for (const file of files) {
|
||||
const en = read('en', file)
|
||||
if (!en) continue
|
||||
for (const [key, enVal] of Object.entries(en)) {
|
||||
if (!enVal.trim()) continue
|
||||
const values = []
|
||||
for (const l of langs) {
|
||||
const cat = read(l, file)
|
||||
if (!cat) continue
|
||||
const v = cat[key]
|
||||
if (v !== undefined) values.push(v)
|
||||
}
|
||||
if (values.length < MIN_LOCALES) continue
|
||||
compared++
|
||||
if (new Set(values).size !== 1) continue
|
||||
const id = `${file}:${key}`
|
||||
if (ALLOWED.has(id)) { allowedHits.push({ id, why: ALLOWED.get(id), value: enVal }); continue }
|
||||
findings.push({ file, key, value: enVal, locales: values.length })
|
||||
}
|
||||
}
|
||||
|
||||
console.log('I18N-NEVER-TRANSLATED')
|
||||
console.log(` ${langs.length} locale(s), ${files.length} catalog(s), ${compared} key(s) compared`)
|
||||
for (const a of allowedHits) {
|
||||
console.log(` · allowed: ${a.id} = "${a.value.slice(0, 40)}"`)
|
||||
console.log(` ${a.why}`)
|
||||
}
|
||||
|
||||
if (findings.length) {
|
||||
console.error(`\nI18N-NEVER-TRANSLATED: FAIL — ${findings.length} key(s) identical in every locale.`)
|
||||
for (const f of findings) {
|
||||
console.error(` ✗ ${f.file}:${f.key} = "${f.value.slice(0, 50)}" (same in all ${f.locales})`)
|
||||
}
|
||||
console.error('\n check:i18n-parity asserts these keys are PRESENT everywhere, and they are —')
|
||||
console.error(' presence and translatedness are different properties. A reader in Arabic or')
|
||||
console.error(' Chinese meets an English string sitting inside an otherwise localized surface.')
|
||||
console.error('\n If a key is CORRECTLY the same everywhere (a pure format string, a brand name,')
|
||||
console.error(' an ISO designation), add it to ALLOWED in this file WITH A REASON. Do not invent')
|
||||
console.error(' translations to silence it: a wrong term looks authoritative to a reader who')
|
||||
console.error(' cannot check it, which is worse than an honest English acronym.')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\nI18N-NEVER-TRANSLATED: PASS — every compared key differs across locales, or is '
|
||||
+ 'declared identical on purpose.')
|
||||
|
|
@ -180,7 +180,8 @@
|
|||
"check:upg-map-data": "node internal/check-upg-map-data-painted.mjs # the UPG-100 map must carry OUR polygons/zones/pins, not just a basemap — reads the counter the profile itself writes. Pass live URLs to audit what is published.",
|
||||
"check:published": "node internal/check-published-matches-build.mjs # what readers are SERVED must be what we BUILT. Every other gate measures the local tree, so all of them stay green while a stale publish serves something else — and manufactures bug reports that cannot be reproduced. Compares the sha384 per bundle. SKIPs when the published host is unreachable (a network gate that goes red on flaky wifi gets ignored).",
|
||||
"check:shared-reached": "node internal/check-shared-component-reached-bundles.mjs # a shared library fix is only as deployed as the last rebuild of EVERY bundle that consumes it. Each bundle ships its own COMPILED copy, so a one-line fix in library/ is live in whichever bundles happened to be rebuilt after it and silently absent from the rest — and every other gate measures ONE leaf, which renders the old behaviour perfectly. Asserts a distinctive marker string per tracked change; consumers are DERIVED from imports, never hardcoded.",
|
||||
"check:tree-weight": "node internal/check-published-tree-weight.mjs # the tree we publish has a weight and nothing was watching it. A publish was ALREADY BLOCKED once at a 74MB asset tree (Forgejo rejected the pack); it has since grown to 132MB unnoticed. Reports the SPLIT, because a bare total invites deleting the wrong half: the 176 city posters are the heavy half AND the half that cannot be regenerated (they need OSM tiles). Raw bytes, not packed — see publish-html.mjs for why a raw saving is not a shipped one."
|
||||
"check:tree-weight": "node internal/check-published-tree-weight.mjs # the tree we publish has a weight and nothing was watching it. A publish was ALREADY BLOCKED once at a 74MB asset tree (Forgejo rejected the pack); it has since grown to 132MB unnoticed. Reports the SPLIT, because a bare total invites deleting the wrong half: the 176 city posters are the heavy half AND the half that cannot be regenerated (they need OSM tiles). Raw bytes, not packed — see publish-html.mjs for why a raw saving is not a shipped one.",
|
||||
"check:i18n-never-translated": "node internal/check-i18n-never-translated.mjs # a key PRESENT in all 11 catalogs but IDENTICAL in all of them is copied, not translated. check:i18n-parity asserts presence; presence and translatedness are different properties. Carries an allowlist for keys correctly identical everywhere (pure format strings, brand names, ISO designations) — each with a reason, so the list records decisions rather than silencing findings."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/android": "^7.6.9",
|
||||
|
|
|
|||
Loading…
Reference in a new issue