chore(i18n): extend parity gate to all locales with strict/info tiers

Previously the script only inspected en/zh-CN/zh-TW, leaving de/fr/it/ja/pt-BR
  drift invisible. Now locales are auto-discovered from src/i18n/locales/, and a
  STRICT list (de, zh-CN, zh-TW — currently in parity) gates CI while the rest
  report informationally until their drift is caught up. ja notably has 27 real
  placeholder bugs worth fixing before promotion to strict.
This commit is contained in:
maziggy 2026-04-19 08:36:13 +02:00
parent 946ebb6307
commit 8f9eb0d433

View file

@ -161,30 +161,63 @@ export function compareLocales(locales) {
return { failed: reports.length > 0, reports };
}
// Strict locales fail CI when they drift from en. Everything else discovered
// in the locales directory is reported informationally — promote a locale to
// STRICT once its drift is caught up. en is implicitly the reference.
const STRICT = ['de', 'zh-CN', 'zh-TW'];
// Skip file IO / process.exit when imported as a library (e.g. from tests).
const isMainModule = import.meta.url === url.pathToFileURL(process.argv[1] ?? '').href;
if (isMainModule) {
const locales = {
en: loadLocale(path.join(localesDir, 'en.ts')),
'zh-CN': loadLocale(path.join(localesDir, 'zh-CN.ts')),
'zh-TW': loadLocale(path.join(localesDir, 'zh-TW.ts')),
};
const MAX_REPORT = 20;
const { failed, reports } = compareLocales(locales);
for (const { label, items } of reports) {
console.error(`\n[${label}] (${items.length})`);
items.slice(0, MAX_REPORT).forEach((i) => console.error(` ${i}`));
if (items.length > MAX_REPORT) console.error(` ... and ${items.length - MAX_REPORT} more`);
}
if (failed) {
console.error('\n❌ i18n parity check failed.');
const discovered = fs
.readdirSync(localesDir)
.filter((f) => f.endsWith('.ts'))
.map((f) => f.slice(0, -3))
.sort();
if (!discovered.includes('en')) {
console.error(`No en.ts found in ${localesDir} — cannot run parity check without a reference locale.`);
process.exit(1);
}
console.log(`All locales in parity (en / zh-CN / zh-TW):`);
for (const [code, map] of Object.entries(locales)) {
console.log(` ${code}: ${map.size} leaves`);
const missingStrict = STRICT.filter((c) => !discovered.includes(c));
if (missingStrict.length) {
console.error(`STRICT locales declared but not found on disk: ${missingStrict.join(', ')}`);
process.exit(1);
}
const codes = ['en', ...discovered.filter((c) => c !== 'en')];
const locales = Object.fromEntries(
codes.map((c) => [c, loadLocale(path.join(localesDir, `${c}.ts`))]),
);
const MAX_REPORT = 20;
const strictSet = new Set(STRICT);
const printReports = (reports, header) => {
if (!reports.length) return;
console.error(`\n${header}`);
for (const { label, items } of reports) {
console.error(`\n[${label}] (${items.length})`);
items.slice(0, MAX_REPORT).forEach((i) => console.error(` ${i}`));
if (items.length > MAX_REPORT) console.error(` ... and ${items.length - MAX_REPORT} more`);
}
};
// Label prefix is "${code}:" — route reports to strict vs informational.
const { reports } = compareLocales(locales);
const codeOf = (label) => label.split(':', 1)[0];
const strictReports = reports.filter((r) => strictSet.has(codeOf(r.label)));
const infoReports = reports.filter((r) => !strictSet.has(codeOf(r.label)));
printReports(strictReports, '=== STRICT locales (failures below fail CI) ===');
printReports(infoReports, '=== INFORMATIONAL locales (drift shown, does not fail CI) ===');
console.log('\nLocale leaf counts:');
for (const [code, map] of Object.entries(locales)) {
const tier = code === 'en' ? 'ref' : strictSet.has(code) ? 'strict' : 'info';
console.log(` ${code.padEnd(6)} ${String(map.size).padEnd(6)} [${tier}]`);
}
if (strictReports.length > 0) {
console.error(`\n❌ i18n parity check failed (strict: ${STRICT.join(', ')}).`);
process.exit(1);
}
console.log(`\n✓ Strict locales in parity (en / ${STRICT.join(' / ')}).`);
}