doxa-maps/internal/check-i18n-parity.mjs
doxa-maps-88655c70 e0f2d522bb
Some checks are pending
build-gate / build (push) Waiting to run
All eleven locales complete — the last 162 keys of i18n debt, and the ratchet empties
regions.json for it/ro/zh, 54 keys each. check:i18n-parity now reports "0 key(s) of
known translation debt remain", closing the 429-key gap it found when it was written.

★ THE DEBT MAP IS EMPTY RATHER THAN FULL OF ZEROS, and that is the whole point of
this ratchet: a locale with NO entry must be complete — the gate fails it outright
for a single missing key — so an empty DEBT is the strongest state it can be in, not
an unset one. Writing { it: 0, ro: 0, zh: 0 } would mean the same today and quietly
permit a regression the moment someone "adjusted" a number. Mutation-tested both
ways: removing ONE key fails ("it is missing 1/176 key(s) and has no DEBT entry"),
deleting the whole file fails (54/176).

THE CARD'S ESTIMATE WAS WRONG AND IS CORRECTED. It recorded "54 keys of full
SENTENCES". Measured: 45 short labels (median 14.5 chars) + 9 longer strings, three
carrying %count%/%countries%/%region%/%country% interpolation tokens. The catalog is
keyed by its own English TEXT, not symbolic keys — which is exactly why the gap was
invisible: the fallback renders the English key itself, so key and value are
identical in English and nothing ever looks broken.

PLACEHOLDERS HELD BY A PROGRAMMATIC CHECK, not by care: every %token% in every
English value must appear, same set, in all three translations. 0 mismatches, 0
empty values. Mangling one breaks interpolation at runtime with nothing to catch it.

VERIFIED AT THE RENDERED LAYER on the regions-list leaf, en/it/ro/zh: labels
translated (Regions -> Regioni), the intro sentence translated, and the INTERPOLATED
count sentence correct — "315 people groups in 41 countries" -> "315 popoli in 41
paesi", both placeholders substituted with real numbers. Zero page errors. Each
locale asserted to DIFFER from English, so a silent fallback could not pass.
★ That sentence is the exact bug regions-list.vue:144 records: the Russian string was
"ALREADY in library/i18n/locales/ru/regions.json — copied, never wired", caught by
reading a screenshot rather than by any assertion. Now it is asserted.

zh is SIMPLIFIED, measured not assumed: the existing zh catalogs carry 33
Simplified-only characters against 4 Traditional. Vocabulary follows each locale's
own established terms — Popolo / Popor / 民族, Regione / Regiune / 区域 — and WAGF
stays an acronym, as fr and de already had it.
2026-09-24 08:48:02 +00:00

120 lines
5.8 KiB
JavaScript

// 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, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
// ⚠ REPO-RELATIVE, never an absolute path. This gate exists in MORE THAN ONE repo
// (doxa-maps-plugin-studio and wt-packet are separate repos, not worktrees), and a
// hardcoded /home/... made the studio copy silently grade wt-packet's locales —
// so a ceiling lowered in one repo left the other's stale and neither was wrong on
// its face. Each copy now checks ITS OWN tree. (check:no-abs-paths, 2026-09-24.)
const REPO = process.env.DOXA_REPO
|| resolve(dirname(fileURLToPath(import.meta.url)), '..')
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 — AND THE MAP IS NOW EMPTY ────────────────────────
// ★ ALL ELEVEN LOCALES ARE COMPLETE. 2026-09-24: the last 162 keys closed
// (regions.json for it/ro/zh, 54 each), finishing the 429-key debt this gate
// found when it was written.
//
// THE MAP IS DELIBERATELY EMPTY RATHER THAN FULL OF ZEROS. A locale with no entry
// here MUST be complete — the gate fails it outright for a single missing key —
// so an empty DEBT is the strongest state this ratchet can be in, not an unset
// one. Writing `{ it: 0, ... }` would mean the same today and quietly permit a
// regression back up the moment someone "adjusted" a number.
//
// HOW IT WORKED, for whoever needs to add debt back: these were a CEILING, not a
// target — the gate failed when a locale got WORSE and printed a nudge when one
// improved, so the number could be lowered. That let real translation work sit in
// the backlog without blocking every build, which is what a gate everyone
// disables protects against. If a new English key lands ahead of its
// translations, add the locale here with its count and lower it as they arrive.
// Never raise a number; never add a locale that is already complete.
const DEBT = {
}
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.`)