From 986b83ee77a65fad2571806e7fa9d0df27467174 Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Thu, 24 Sep 2026 05:43:32 +0000 Subject: [PATCH] =?UTF-8?q?test(i18n):=20a=20key=20in=20English=20must=20e?= =?UTF-8?q?xist=20in=20every=20locale=20=E2=80=94=20ratcheted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/check-i18n-parity.mjs | 107 +++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 108 insertions(+) create mode 100644 internal/check-i18n-parity.mjs diff --git a/internal/check-i18n-parity.mjs b/internal/check-i18n-parity.mjs new file mode 100644 index 000000000..4f9d4dd06 --- /dev/null +++ b/internal/check-i18n-parity.mjs @@ -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.`) diff --git a/package.json b/package.json index a384b1efc..ee044a60c 100644 --- a/package.json +++ b/package.json @@ -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",