test(i18n): a key in English must exist in every locale — ratcheted

The failure here is silent BY DESIGN. vue-i18n falls back to English for a
missing key, which is the right runtime behaviour and exactly why catalogs drift
unnoticed: the app looks fine, nothing errors, and a reader who chose Spanish
just gets English words with no signal anything is missing.

Measured when this was written: buttons.json had 21 keys in English and 19 in
every other locale. The orphans were `share` and `shareSteps` — the Share
button's title and its explainer, rendered on EVERY map. Ten locales had been
shipping an English Share button since those keys landed.

★ IT FOUND MUCH MORE THAN THE BUG THAT PROMPTED IT: 429 missing keys across ten
locales, with popup.json existing only in English and it/ro/zh also lacking
regions.json and tabs.json entirely — 101 of 176 keys each.

So it RATCHETS rather than blocks. Each locale has a ceiling; the gate fails
when one gets WORSE and prints a nudge when one gets better so the ceiling can
be lowered. Failing outright on 429 keys of debt this gate did not create would
block every build, and a gate everyone disables protects nothing. A locale with
no ceiling entry must be complete, so a NEW locale cannot be added half-done.

Keys are compared FLATTENED, so a nested object or an array is checked
key-by-key rather than counted as one present key.

Confirmed to fail before acceptance: deleting two French keys reports
"fr regressed: 8 missing key(s), was 6" and names them.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 05:43:32 +00:00
parent d0ecd2f780
commit 986b83ee77
2 changed files with 108 additions and 0 deletions

View file

@ -0,0 +1,107 @@
// check-i18n-parity — a key that exists in English must exist in every locale.
//
// ★ THE FAILURE IS SILENT BY DESIGN. vue-i18n falls back to English for a missing
// key, which is the right runtime behaviour and the reason this drifts unnoticed:
// the app looks fine, nothing errors, and a reader who chose Spanish simply gets
// English words with no indication anything is missing.
//
// Measured when this was written: buttons.json had 21 keys in English and 19 in
// every other locale. The two orphans were `share` and `shareSteps` — the Share
// button's own title and its five-step explainer, rendered on EVERY map. Ten
// locales had been shipping an English Share button since the key was added.
//
// It reports per-locale so a partial translation is visible as a number rather
// than as a vague sense that "some things aren't translated".
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { resolve, join } from 'node:path'
const REPO = process.env.DOXA_REPO
|| resolve('/home/lk10x/Workspace/eco-dx/root-doxa-maps/wt-packet')
const ROOT = join(REPO, 'library/i18n/locales')
if (!existsSync(ROOT)) { console.error(`I18N-PARITY: FAIL — no locales at ${ROOT}`); process.exit(1) }
// Flatten so nested objects and arrays are compared key-by-key, not shallowly.
const flat = (obj, prefix = '', out = {}) => {
for (const [k, v] of Object.entries(obj || {})) {
const key = prefix ? `${prefix}.${k}` : k
if (v && typeof v === 'object' && !Array.isArray(v)) flat(v, key, out)
else out[key] = v
}
return out
}
const read = (loc, area) => {
const p = join(ROOT, loc, area)
if (!existsSync(p)) return null
try { return flat(JSON.parse(readFileSync(p, 'utf8'))) } catch { return undefined }
}
const locales = readdirSync(ROOT).filter((d) => existsSync(join(ROOT, d)))
if (!locales.includes('en')) { console.error('I18N-PARITY: FAIL — no English catalog to compare against'); process.exit(1) }
const areas = readdirSync(join(ROOT, 'en')).filter((f) => f.endsWith('.json'))
// ── KNOWN DEBT, RATCHETED ────────────────────────────────────────────────────
// Every locale already carries pre-existing gaps — popup.json exists only in
// English, and it/ro/zh also lack regions.json and tabs.json. Failing on all of
// that would block every build over debt this gate did not create, and a gate
// everyone disables protects nothing.
//
// So the numbers below are a CEILING, not a target: the gate fails when a locale
// gets WORSE, and prints a nudge when one gets better so the ceiling can be
// lowered. That makes drift impossible while leaving the backlog as backlog.
// Lower a number whenever you translate something; never raise one.
const DEBT = {
ar: 10, de: 30, es: 10, fr: 6, hi: 50, it: 101, pt: 10, ro: 101, ru: 10, zh: 101,
}
const fails = []
const improved = []
const rows = []
for (const loc of locales.filter((l) => l !== 'en').sort()) {
let missing = 0
let total = 0
const examples = []
for (const area of areas) {
const en = read('en', area)
const other = read(loc, area)
total += Object.keys(en).length
if (other === null) { missing += Object.keys(en).length; examples.push(`${area} (whole file)`); continue }
if (other === undefined) { fails.push(`${loc}/${area} is not valid JSON`); continue }
for (const k of Object.keys(en)) {
if (!(k in other)) { missing += 1; if (examples.length < 3) examples.push(`${area}:${k}`) }
}
}
rows.push({ loc, missing, total, examples })
const ceiling = DEBT[loc]
if (ceiling === undefined) {
if (missing) fails.push(`${loc} is missing ${missing}/${total} key(s) and has no DEBT entry — `
+ `e.g. ${examples.join(', ')}`)
} else if (missing > ceiling) {
fails.push(`${loc} regressed: ${missing} missing key(s), was ${ceiling} — `
+ `e.g. ${examples.join(', ')}`)
} else if (missing < ceiling) {
improved.push(`${loc}: ${missing} missing, ceiling still says ${ceiling} — lower it`)
}
}
console.log(` locales : ${locales.length} (${locales.sort().join(' ')})`)
console.log(` areas : ${areas.length} catalog file(s), ${Object.keys(read('en', areas[0]) || {}).length}+ keys each`)
for (const r of rows) {
const c = DEBT[r.loc]
const tag = r.missing === 0 ? 'complete'
: (c !== undefined && r.missing <= c ? `${r.missing} missing (known debt, ceiling ${c})`
: `${r.missing} missing`)
console.log(` ${r.loc.padEnd(4)} ${tag}`)
}
if (fails.length) {
console.error(`\nI18N-PARITY: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(' - ' + f)
process.exit(1)
}
if (improved.length) {
console.log('\n ↓ improved since the ceiling was set — lower DEBT in this file:')
for (const i of improved) console.log(' ' + i)
}
const debt = rows.reduce((a, r) => a + r.missing, 0)
console.log(`\nI18N-PARITY: PASS — no locale regressed. ${debt} key(s) of known translation debt remain.`)

View file

@ -58,6 +58,7 @@
"check:card-deck": "node internal/check-card-deck.mjs",
"check:poster-raster": "node internal/check-poster-raster.mjs",
"check:deck-click": "node internal/check-deck-click.mjs",
"check:i18n-parity": "node internal/check-i18n-parity.mjs",
"check:locale-reachable": "node internal/check-locale-reachable.mjs",
"check:poster-prints": "node internal/check-poster-prints.mjs",
"check:provider-switch": "node internal/check-provider-switch.mjs",