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:
parent
d0ecd2f780
commit
986b83ee77
2 changed files with 108 additions and 0 deletions
107
internal/check-i18n-parity.mjs
Normal file
107
internal/check-i18n-parity.mjs
Normal 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.`)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue