Files
TREK/shared/scripts/i18n-parity.mjs
T
Maurice de128cc4ae wip(i18n): plural forms through Intl.PluralRules
Unfinished, continued in the next session: t() resolves key.one/.few/...
by the language's own plural rule, the parity check knows which forms a
language needs, 16 singular/plural key pairs are merged in every locale
and every count === 1 branch in the client uses the plural form. Still
open: the forms each locale needs for the remaining count strings, the
two new labels in every locale, and 35 tests that expect the old English
wording.
2026-10-07 22:57:51 +02:00

226 lines
8.6 KiB
JavaScript

#!/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 } 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.
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;
}
const VARIANT_CATEGORIES = ['zero', 'one', 'two', 'few', 'many'];
/** The plural groups en defines in one domain file: their base keys. */
function pluralGroups(enKeys) {
const groups = new Set();
for (const key of enKeys) {
const dot = key.lastIndexOf('.');
if (dot < 0 || !VARIANT_CATEGORIES.includes(key.slice(dot + 1))) continue;
const base = key.slice(0, dot);
if (enKeys.has(base) || enKeys.has(`${base}.other`)) groups.add(base);
}
return groups;
}
/** `{ base, category }` when `key` is a category form of one of `groups`. */
function asVariant(key, groups) {
const dot = key.lastIndexOf('.');
if (dot < 0) return null;
const category = key.slice(dot + 1);
const base = key.slice(0, dot);
return VARIANT_CATEGORIES.includes(category) && groups.has(base) ? { base, category } : null;
}
function withoutVariants(keys, groups) {
return new Set([...keys].filter((k) => !asVariant(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 v = asVariant(key, groups);
if (v && !allowed.has(v.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);
}
}