Files
TREK/shared/scripts/i18n-parity.mjs
T
Maurice 8f691bff5a feat(i18n): untranslated strings per locale may only shrink
Parity proves a locale has every key, not that the key holds the
locale's language: copying the en string passes it, so a feature could
add 26 English copies and stay green. Nothing counted them either. Today
the locales hold 3157 strings marked `// en-fallback` and 1252 unmarked
values identical to en.

scripts/i18n-untranslated.mjs counts both per locale and file and holds
them at scripts/i18n-untranslated-baseline.json, recorded from today's
tree. i18n-parity --strict runs it, so the existing CI step enforces it.
The two counts are separate on purpose: dropping a marker without
translating moves the string from one count to the other and still
fails. --update lowers the baseline to the current counts and never
raises an entry. A missing or malformed baseline, a locale lacking one
of en's files or a value the reader cannot parse is an error, not a
pass.

An identical value is not counted when it reads the same in every
language: after dropping placeholders, markup, URLs, e-mail addresses,
paths and a short list of multi-word names (Google Maps, Home
Assistant), at most one plain word may be left, where acronyms, single
letters and names with an inner capital are not plain words. That
excuses "{count} km", "GPX", "OAuth" and "Budget" and keeps "Source
code" counted.
2026-10-08 13:04:41 +02:00

283 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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.
// 4. Count strings: an en string carrying {count} or {n} (the two params
// `t()` picks a plural form by) must be a plural group, so every
// language gets the forms it needs. The exceptions are NOT_PLURAL: a
// number that is never a quantity ("Day {n}", a step, a multiplier).
// 5. Untranslated strings: the marked `// en-fallback` and unmarked English
// copies per locale and file may only shrink (i18n-untranslated.mjs,
// which also lowers the baseline with --update).
//
// 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 { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { getIntlLanguage } from '../src/i18n/languages.ts';
import { allPluralCategories, integerPluralCategories, pluralFormOf, pluralGroups } from '../src/i18n/plural.ts';
import { I18N_ROOT, listDomainFiles, listLocales, readCatalog } from './i18n-catalog.mjs';
import { compare, countUntranslated, formatGrown, readBaseline } from './i18n-untranslated.mjs';
// 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 extractKeys(locale, file) {
const content = readFileSync(join(I18N_ROOT, 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 };
}
/**
* en strings that carry {count} or {n} without being a quantity of
* something, so no word in any language agrees with the number. Everything
* else with a count is a plural group. An entry that stops matching an
* ungrouped count string fails the check, so the list cannot go stale.
*/
export const NOT_PLURAL = [
{ key: 'collab.polls.optionPlaceholder', because: 'the position of a poll option ("Option 3")' },
{ key: 'collections.importOnDay', because: 'the number of the day a place is planned on' },
{ key: 'dashboard.atlas.aroundEquator', because: 'a decimal multiplier ("≈ 1.37×"), not a count of things' },
{ key: 'dayplan.dayN', because: 'the number of a day in the trip' },
{ key: 'planner.dayN', because: 'the number of a day in the trip' },
{ key: 'help.center.step', because: 'the position of a step in a guide' },
{ key: 'help.center.stepOf', because: 'the position of a step in a guide' },
{ key: 'help.center.imageAlt', because: 'the position of a step in a guide' },
{ key: 'system_notice.pager.goto', because: 'the position of a notice in the pager' },
{ key: 'tours.addedToDay', because: 'the number of the day a tour was added to' },
{ key: 'tours.planner.waypointLabel', because: 'the position of a waypoint on the tour' },
];
const COUNT_PARAM_RE = /\{(?:count|n)\}/;
/**
* en strings carrying a count param outside any plural group, and NOT_PLURAL
* entries that no longer name such a string.
*/
function checkCountStrings(enFiles, notPlural = NOT_PLURAL, root = I18N_ROOT) {
const allowed = new Set(notPlural.map((e) => e.key));
const ungrouped = [];
const seen = new Set();
for (const file of enFiles) {
const entries = readCatalog('en', file, root);
const groups = pluralGroups(entries.map((e) => e.key));
for (const { key, value } of entries) {
if (!COUNT_PARAM_RE.test(value) || groups.has(key) || pluralFormOf(key, groups)) continue;
if (key.endsWith('.other') && groups.has(key.slice(0, -'.other'.length))) continue;
if (allowed.has(key)) seen.add(key);
else ungrouped.push({ file, key });
}
}
return { ungrouped, stale: [...allowed].filter((key) => !seen.has(key)) };
}
/** The untranslated ratchet's verdict, with a reading error reported rather than thrown. */
function checkUntranslated() {
try {
const { grown, lowerable } = compare(countUntranslated(), readBaseline());
return { error: null, grown, lowerable };
} catch (err) {
return { error: err.message, grown: [], lowerable: 0 };
}
}
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 });
}
}
}
report.countDrift = checkCountStrings(enFiles);
report.untranslated = checkUntranslated();
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('; ')}`);
}
}
if (report.countDrift) {
const { ungrouped, stale } = report.countDrift;
if (ungrouped.length === 0 && stale.length === 0) {
lines.push('Count strings: OK');
} else {
lines.push('Count strings: en strings with {count} or {n} that are no plural group');
for (const { file, key } of ungrouped) {
lines.push(` en/${file}: ${key} (add ${key}.one and the other forms, see shared/CLAUDE.md)`);
}
for (const key of stale) lines.push(` NOT_PLURAL lists ${key}, which is no ungrouped count string: remove it`);
}
}
if (report.untranslated) {
const { error, grown, lowerable } = report.untranslated;
if (error) {
lines.push(`Untranslated strings: cannot check, ${error}`);
} else if (grown.length === 0) {
lines.push(
'Untranslated strings: OK' +
(lowerable ? ` (${lowerable} baseline entries can come down: node scripts/i18n-untranslated.mjs --update)` : ''),
);
} else {
lines.push('Untranslated strings: grew past scripts/i18n-untranslated-baseline.json');
lines.push(...formatGrown(grown));
}
}
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 { checkCountStrings, 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();
const shown = filesOnly ? { ...report, keyDrift: [], pluralDrift: [], countDrift: null, untranslated: null } : report;
process.stdout.write(formatReport(shown) + '\n');
if (strict) {
const hasFileDrift = report.fileDrift.length > 0;
const hasKeyDrift = filesOnly ? false : report.keyDrift.length > 0 || report.pluralDrift.length > 0;
const hasCountDrift = !filesOnly && (report.countDrift.ungrouped.length > 0 || report.countDrift.stale.length > 0);
const hasUntranslated = !filesOnly && (report.untranslated.error !== null || report.untranslated.grown.length > 0);
if (hasFileDrift || hasKeyDrift || hasCountDrift || hasUntranslated) process.exit(1);
}
}