#!/usr/bin/env node // i18n parity check — keeps Julien's "every locale = same files + keys as en" // DoD honest over time. // // What it checks per non-en locale: // 1. File set parity: every domain file that exists in en/ must exist in this // locale's dir; no extra domain files allowed. // 2. Key set parity: for each shared domain file, the top-level translation // keys must match exactly (no missing, no extra). // // Output: structured report grouped by locale, plus a `--strict` flag that // returns exit-code 1 when any drift is present (intended for CI). Without // `--strict` the script exits 0 and prints, so it can also run as a non-blocking // audit during translation work. // 3. Plural forms: a count-bearing string (`key` or `key.other`, plus at // least one of `key.zero|one|two|few|many` in en) is a plural group. Its // category keys are not compared with en's, since Russian needs `.few` // and `.many` and Japanese needs none. Instead every locale must spell // out each category whole counts reach in its language, and may not // carry one its language never selects. The rules come from // `Intl.PluralRules`, through the same helpers the client resolves with. // // Limitations: we only parse *top-level* string keys (those declared as the // first column of the file, matching the regex below). Nested objects, function // bodies, and inline comments are ignored. This matches how `t(key)` calls // resolve at runtime in TranslationContext. import { readdirSync, readFileSync, statSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { getIntlLanguage } from '../src/i18n/languages.ts'; import { allPluralCategories, integerPluralCategories, pluralFormOf, pluralGroups } from '../src/i18n/plural.ts'; const here = dirname(fileURLToPath(import.meta.url)); const i18nRoot = join(here, '..', 'src', 'i18n'); // Match a top-level translation key declaration: leading whitespace, then a // quoted key (must start with a lowercase letter), then a colon. This is the // exact pattern every domain file uses. Keys may hold a colon themselves // (admin.plugins.perm.db:read:trips); without it in the class those keys were // never compared, and two locales lacked dozens of them unnoticed. const TOP_LEVEL_KEY_RE = /^\s*'([a-z][a-zA-Z0-9.\-_:]*)'\s*:/gm; function listLocales() { return readdirSync(i18nRoot) .filter((name) => statSync(join(i18nRoot, name)).isDirectory()) // externalNotifications is a barrel module, not a locale. .filter((name) => name !== 'externalNotifications'); } function listDomainFiles(locale) { return readdirSync(join(i18nRoot, locale)) .filter((f) => f.endsWith('.ts') && f !== 'index.ts') .sort(); } function extractKeys(locale, file) { const content = readFileSync(join(i18nRoot, locale, file), 'utf8'); const keys = new Set(); for (const match of content.matchAll(TOP_LEVEL_KEY_RE)) { keys.add(match[1]); } return keys; } function withoutVariants(keys, groups) { return new Set([...keys].filter((k) => !pluralFormOf(k, groups))); } /** What one locale owes the plural groups of one file. */ function checkPluralForms(locale, keys, groups) { const intl = getIntlLanguage(locale); const required = integerPluralCategories(intl).filter((c) => c !== 'other'); const allowed = new Set(allPluralCategories(intl)); const missing = []; const invalid = []; for (const base of groups) { for (const c of required) if (!keys.has(`${base}.${c}`)) missing.push(`${base}.${c}`); } for (const key of keys) { const form = pluralFormOf(key, groups); if (form && !allowed.has(form.category)) invalid.push(key); } return { missing, invalid }; } function diffSets(reference, candidate) { const missing = []; const extra = []; for (const k of reference) if (!candidate.has(k)) missing.push(k); for (const k of candidate) if (!reference.has(k)) extra.push(k); return { missing, extra }; } function checkParity() { const locales = listLocales(); if (!locales.includes('en')) { throw new Error('shared/src/i18n/en is required as the reference locale'); } const enFiles = listDomainFiles('en'); const enKeysByDomain = new Map(); for (const f of enFiles) enKeysByDomain.set(f, extractKeys('en', f)); const report = { fileDrift: [], keyDrift: [], pluralDrift: [] }; const groupsByDomain = new Map(); for (const f of enFiles) { const groups = pluralGroups(enKeysByDomain.get(f)); groupsByDomain.set(f, groups); const { missing, invalid } = checkPluralForms('en', enKeysByDomain.get(f), groups); if (missing.length || invalid.length) report.pluralDrift.push({ locale: 'en', file: f, missing, invalid }); } for (const locale of locales) { if (locale === 'en') continue; const localeFiles = listDomainFiles(locale); const { missing: missingFiles, extra: extraFiles } = diffSets( new Set(enFiles), new Set(localeFiles), ); if (missingFiles.length || extraFiles.length) { report.fileDrift.push({ locale, missing: missingFiles, extra: extraFiles }); } for (const file of enFiles) { if (!localeFiles.includes(file)) continue; const localeKeys = extractKeys(locale, file); const groups = groupsByDomain.get(file); const { missing, extra } = diffSets( withoutVariants(enKeysByDomain.get(file), groups), withoutVariants(localeKeys, groups), ); if (missing.length || extra.length) { report.keyDrift.push({ locale, file, missing, extra }); } const plural = checkPluralForms(locale, localeKeys, groups); if (plural.missing.length || plural.invalid.length) { report.pluralDrift.push({ locale, file, ...plural }); } } } return report; } function formatReport(report) { const lines = []; if (report.fileDrift.length === 0) { lines.push('File parity: OK'); } else { lines.push(`File parity: ${report.fileDrift.length} locale(s) with file drift`); for (const { locale, missing, extra } of report.fileDrift) { if (missing.length) lines.push(` ${locale}: missing ${missing.join(', ')}`); if (extra.length) lines.push(` ${locale}: extra ${extra.join(', ')}`); } } if (report.keyDrift.length === 0) { lines.push('Key parity: OK'); } else { lines.push(`Key parity: ${report.keyDrift.length} domain file(s) with key drift`); for (const { locale, file, missing, extra } of report.keyDrift) { const parts = []; if (missing.length) parts.push(`missing ${missing.length} (e.g. ${missing.slice(0, 3).join(', ')})`); if (extra.length) parts.push(`extra ${extra.length} (e.g. ${extra.slice(0, 3).join(', ')})`); lines.push(` ${locale}/${file}: ${parts.join('; ')}`); } } if (report.pluralDrift.length === 0) { lines.push('Plural forms: OK'); } else { lines.push(`Plural forms: ${report.pluralDrift.length} domain file(s) with missing or impossible forms`); for (const { locale, file, missing, invalid } of report.pluralDrift) { const parts = []; if (missing.length) parts.push(`missing ${missing.join(', ')}`); if (invalid.length) parts.push(`never selected ${invalid.join(', ')}`); lines.push(` ${locale}/${file}: ${parts.join('; ')}`); } } return lines.join('\n'); } // Export a structured API for vitest. The CLI entry point only runs when // executed directly (`node scripts/i18n-parity.mjs`), so importing this file // from a spec does not produce side effects. export { checkParity, formatReport }; const isCli = process.argv[1] && process.argv[1].endsWith('i18n-parity.mjs'); if (isCli) { const strict = process.argv.includes('--strict'); const filesOnly = process.argv.includes('--files-only'); const report = checkParity(); process.stdout.write(formatReport(filesOnly ? { ...report, keyDrift: [], pluralDrift: [] } : report) + '\n'); if (strict) { const hasFileDrift = report.fileDrift.length > 0; const hasKeyDrift = filesOnly ? false : report.keyDrift.length > 0 || report.pluralDrift.length > 0; if (hasFileDrift || hasKeyDrift) process.exit(1); } }