mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
ci(i18n): fail on new English-identical translations (#1793)
* ci(i18n): fail on new English-identical translations The drift check only warned about locale values identical to English, so untranslated strings kept landing. It now fails on any such value that tools/i18n/identical-en-baseline.json does not record for that locale and key. The baseline captures today's 2,015 entries: legitimately identical values (brand and technical names, language autonyms, PIN) and the existing debt. An entry only covers the English text it recorded, so copying reworded English into a locale fails too. Baseline entries that are no longer identical are reported, not fatal. `pnpm run i18n:baseline:update` rewrites the baseline deliberately; CI runs `pnpm run i18n:validate` (node tests, then the check) and never rewrites it. `--fail-on-identical` remains as a strict audit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(i18n): keep the baseline intact on failed updates and strict audits `--update-baseline` now writes nothing while any locale is unreadable or has missing or extra keys, so an incomplete translation cannot reshape the baseline. `--fail-on-identical` no longer reads the baseline it ignores, so a damaged file cannot block a strict audit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4adc3ba20f
commit
0768ae5ea2
7 files changed
+2671
-83
No files matched your search
@@ -390,8 +390,12 @@ jobs:
|
|||||||
- name: Typecheck Jest spec programs
|
- name: Typecheck Jest spec programs
|
||||||
run: pnpm run typecheck:spec:test && pnpm run typecheck:spec
|
run: pnpm run typecheck:spec:test && pnpm run typecheck:spec
|
||||||
|
|
||||||
- name: Check i18n drift
|
# Fails on missing or extra keys and on values identical to
|
||||||
run: pnpm run i18n:check
|
# English that tools/i18n/identical-en-baseline.json does not list.
|
||||||
|
# The baseline only changes through a reviewed
|
||||||
|
# `pnpm run i18n:baseline:update`; CI never rewrites it.
|
||||||
|
- name: Check i18n drift and untranslated values
|
||||||
|
run: pnpm run i18n:validate
|
||||||
|
|
||||||
# Node specs for the benchmark and journey harness (about ten
|
# Node specs for the benchmark and journey harness (about ten
|
||||||
# seconds). They live in the E2E app, which the unit coverage scope
|
# seconds). They live in the E2E app, which the unit coverage scope
|
||||||
|
|||||||
@@ -242,14 +242,32 @@ pnpm run coverage:e2e:v8:web
|
|||||||
## I18n
|
## I18n
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm run i18n:check
|
pnpm run i18n:validate # checker unit tests, then the check (CI)
|
||||||
|
pnpm run i18n:check # the check only
|
||||||
|
pnpm run i18n:baseline:update # deliberate: rewrite the English-identical baseline
|
||||||
```
|
```
|
||||||
|
|
||||||
The i18n check is non-mutating. It compares every locale file in
|
The i18n check is non-mutating. It compares every locale file in
|
||||||
`apps/web/src/assets/i18n/` against `en.json` and fails on missing or extra keys.
|
`apps/web/src/assets/i18n/` against `en.json` and fails on missing or extra keys.
|
||||||
Identical English fallback values are reported as warnings by default; use
|
It also fails on a locale value that is identical to English unless
|
||||||
`node tools/i18n/check-drift.mjs --fail-on-identical` for a stricter translation
|
`tools/i18n/identical-en-baseline.json` records that exact English text for the
|
||||||
audit.
|
same locale and key. The baseline holds values that are legitimately the same
|
||||||
|
in a language (brand and technical names, language autonyms, loanwords such as
|
||||||
|
"PIN") and the untranslated debt that existed when the guard was added, so new
|
||||||
|
keys must ship translated. An entry stops covering its key once the English
|
||||||
|
text changes, so copying reworded English into a locale fails as well.
|
||||||
|
|
||||||
|
Baseline entries that are no longer English-identical (translated, removed, or
|
||||||
|
reworded) are reported but do not fail. `pnpm run i18n:baseline:update`
|
||||||
|
rewrites the baseline from the current locale files, dropping those entries
|
||||||
|
and printing every added one; it writes nothing while any locale is unreadable
|
||||||
|
or has missing or extra keys. Run it only after translating, or for a value
|
||||||
|
that is legitimately identical in that language; review the diff, and never
|
||||||
|
run it in CI. A new locale starts with no baseline entries, so it has to
|
||||||
|
record its legitimate identical values the same way.
|
||||||
|
`node tools/i18n/check-drift.mjs --fail-on-identical` ignores the baseline for
|
||||||
|
a full translation audit. CI runs `pnpm run i18n:validate` in the unit test
|
||||||
|
job.
|
||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,9 @@
|
|||||||
"serve:website": "nx serve website",
|
"serve:website": "nx serve website",
|
||||||
"build:website": "nx build website",
|
"build:website": "nx build website",
|
||||||
"i18n:check": "node tools/i18n/check-drift.mjs",
|
"i18n:check": "node tools/i18n/check-drift.mjs",
|
||||||
|
"i18n:test": "node --test tools/i18n/check-drift.test.mjs",
|
||||||
|
"i18n:validate": "pnpm run i18n:test && pnpm run i18n:check",
|
||||||
|
"i18n:baseline:update": "node tools/i18n/check-drift.mjs --update-baseline",
|
||||||
"perf:initial-bytes": "node tools/performance/measure-initial-bytes.mjs",
|
"perf:initial-bytes": "node tools/performance/measure-initial-bytes.mjs",
|
||||||
"perf:initial-bytes:check": "node tools/performance/measure-initial-bytes.mjs --summary dist/performance/initial-bytes.summary.json && node tools/performance/check-journey-ratchet.mjs --summary dist/performance/initial-bytes.summary.json --only launch/renderer.initialBytes",
|
"perf:initial-bytes:check": "node tools/performance/measure-initial-bytes.mjs --summary dist/performance/initial-bytes.summary.json && node tools/performance/check-journey-ratchet.mjs --summary dist/performance/initial-bytes.summary.json --only launch/renderer.initialBytes",
|
||||||
"perf:journeys": "nx run electron-backend-e2e:journeys",
|
"perf:journeys": "nx run electron-backend-e2e:journeys",
|
||||||
|
|||||||
+18
-4
@@ -49,10 +49,24 @@ For a non-mutating CI/agent check, run:
|
|||||||
pnpm run i18n:check
|
pnpm run i18n:check
|
||||||
```
|
```
|
||||||
|
|
||||||
The check fails on missing or extra keys against `en.json`. It reports values
|
The check fails on missing or extra keys against `en.json`, and on values that
|
||||||
that are still identical to English as warnings so untranslated fallback strings
|
are identical to English unless `identical-en-baseline.json` lists that exact
|
||||||
remain visible without blocking key-parity validation. For stricter translation
|
English text for the locale and key. Do not copy English into a locale as a
|
||||||
audits, run:
|
placeholder: translate it. The contract, including when an entry stops
|
||||||
|
covering a key, is in the
|
||||||
|
[validation map](../../docs/architecture/validation-map.md#i18n).
|
||||||
|
|
||||||
|
Baseline entries that are no longer identical are reported, not fatal. After
|
||||||
|
translating baselined values, or for a value that is legitimately the same in a
|
||||||
|
language (a brand, a technical name, a loanword), rewrite the baseline and
|
||||||
|
review every added entry in the diff:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run i18n:baseline:update
|
||||||
|
```
|
||||||
|
|
||||||
|
The checker's own tests run with `pnpm run i18n:test`; CI runs both through
|
||||||
|
`pnpm run i18n:validate`. For a full audit that ignores the baseline, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/i18n/check-drift.mjs --fail-on-identical
|
node tools/i18n/check-drift.mjs --fail-on-identical
|
||||||
|
|||||||
+291
-73
@@ -1,27 +1,47 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { readdirSync, readFileSync } from 'node:fs';
|
/**
|
||||||
|
* Compares every locale in apps/web/src/assets/i18n/ with en.json.
|
||||||
|
*
|
||||||
|
* Missing or extra keys fail. A value identical to English fails too, unless
|
||||||
|
* tools/i18n/identical-en-baseline.json records that exact English text for
|
||||||
|
* that locale and key. The baseline holds values that are legitimately the
|
||||||
|
* same in a language (brands, technical terms, loanwords such as "PIN") and
|
||||||
|
* the untranslated debt that existed when the guard was introduced, so new
|
||||||
|
* keys must ship translated while old debt can be paid down.
|
||||||
|
*
|
||||||
|
* Baseline entries that no longer match an English-identical value are only
|
||||||
|
* reported. `--update-baseline` rewrites the baseline from the current files;
|
||||||
|
* run it deliberately and review the diff. CI never runs it.
|
||||||
|
* `--fail-on-identical` ignores the baseline for a strict audit.
|
||||||
|
*/
|
||||||
|
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const REPO_ROOT = resolve(__dirname, '..', '..');
|
export const I18N_DIR = resolve(__dirname, '../../apps/web/src/assets/i18n');
|
||||||
const I18N_DIR = resolve(REPO_ROOT, 'apps/web/src/assets/i18n');
|
export const BASELINE_PATH = resolve(__dirname, 'identical-en-baseline.json');
|
||||||
const EN_LOCALE = 'en.json';
|
const EN_LOCALE = 'en';
|
||||||
const MAX_EXAMPLES = 5;
|
const MAX_EXAMPLES = 5;
|
||||||
const FAIL_ON_IDENTICAL = process.argv.includes('--fail-on-identical');
|
const UPDATE_COMMAND = 'pnpm run i18n:baseline:update';
|
||||||
|
const FLAGS = new Set(['--fail-on-identical', '--update-baseline', '--']);
|
||||||
|
|
||||||
function readJson(filePath) {
|
function readJson(filePath) {
|
||||||
return JSON.parse(readFileSync(filePath, 'utf8'));
|
return JSON.parse(readFileSync(filePath, 'utf8'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectLeaves(value, prefix = '', leaves = new Map()) {
|
function isPlainObject(value) {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectLeaves(value, prefix = '', leaves = new Map()) {
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
throw new Error(`Expected object at ${prefix || '<root>'}`);
|
throw new Error(`Expected object at ${prefix || '<root>'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [key, entry] of Object.entries(value)) {
|
for (const [key, entry] of Object.entries(value)) {
|
||||||
const path = prefix ? `${prefix}.${key}` : key;
|
const path = prefix ? `${prefix}.${key}` : key;
|
||||||
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
if (isPlainObject(entry)) {
|
||||||
collectLeaves(entry, path, leaves);
|
collectLeaves(entry, path, leaves);
|
||||||
} else {
|
} else {
|
||||||
leaves.set(path, entry);
|
leaves.set(path, entry);
|
||||||
@@ -31,10 +51,87 @@ function collectLeaves(value, prefix = '', leaves = new Map()) {
|
|||||||
return leaves;
|
return leaves;
|
||||||
}
|
}
|
||||||
|
|
||||||
function listLocaleFiles() {
|
/** Keys whose non-empty locale string equals the English string. */
|
||||||
return readdirSync(I18N_DIR)
|
export function findIdenticalToEnglish(enLeaves, localeLeaves) {
|
||||||
.filter((file) => file.endsWith('.json'))
|
const identical = new Map();
|
||||||
.sort();
|
for (const [key, enValue] of enLeaves) {
|
||||||
|
const localeValue = localeLeaves.get(key);
|
||||||
|
if (
|
||||||
|
typeof enValue === 'string' &&
|
||||||
|
localeValue === enValue &&
|
||||||
|
enValue.trim().length > 0
|
||||||
|
) {
|
||||||
|
identical.set(key, enValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return identical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a locale's English-identical keys into baselined and new ones. An
|
||||||
|
* entry covers a key only while its recorded text is still the English text,
|
||||||
|
* so rewording English and copying the new text into a locale is new debt.
|
||||||
|
* Stale entries no longer describe an English-identical value: the key was
|
||||||
|
* translated or removed, or its English text changed.
|
||||||
|
*/
|
||||||
|
export function compareWithBaseline(identical, localeBaseline = new Map()) {
|
||||||
|
const baselined = [];
|
||||||
|
const unbaselined = [];
|
||||||
|
for (const [key, value] of identical) {
|
||||||
|
(localeBaseline.get(key) === value ? baselined : unbaselined).push(key);
|
||||||
|
}
|
||||||
|
const stale = [...localeBaseline]
|
||||||
|
.filter(([key, value]) => identical.get(key) !== value)
|
||||||
|
.map(([key]) => key);
|
||||||
|
return { baselined, unbaselined, stale };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates the baseline JSON into Map<locale, Map<key, englishText>>. */
|
||||||
|
export function parseBaseline(raw) {
|
||||||
|
if (!isPlainObject(raw)) {
|
||||||
|
throw new Error('expected an object of locales');
|
||||||
|
}
|
||||||
|
const baseline = new Map();
|
||||||
|
for (const [locale, entries] of Object.entries(raw)) {
|
||||||
|
if (!isPlainObject(entries)) {
|
||||||
|
throw new Error(`expected an object of keys for ${locale}`);
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(entries)) {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new Error(`expected English text at ${locale} ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
baseline.set(locale, new Map(Object.entries(entries)));
|
||||||
|
}
|
||||||
|
return baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialises Map<locale, Map<key, text>> with locales and keys sorted. */
|
||||||
|
export function serializeBaseline(identicalByLocale) {
|
||||||
|
const byCodeUnit = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||||||
|
const output = {};
|
||||||
|
for (const locale of [...identicalByLocale.keys()].sort(byCodeUnit)) {
|
||||||
|
const entries = identicalByLocale.get(locale);
|
||||||
|
if (entries.size > 0) {
|
||||||
|
output[locale] = Object.fromEntries(
|
||||||
|
[...entries].sort(([a], [b]) => byCodeUnit(a, b))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${JSON.stringify(output, null, 4)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countChanges(from, to, onChange = () => undefined) {
|
||||||
|
let changes = 0;
|
||||||
|
for (const [locale, entries] of from) {
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
if (to.get(locale)?.get(key) !== value) {
|
||||||
|
changes += 1;
|
||||||
|
onChange(locale, key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatExamples(values) {
|
function formatExamples(values) {
|
||||||
@@ -50,73 +147,194 @@ function formatError(error) {
|
|||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
let enLeaves;
|
function readBaseline(baselinePath) {
|
||||||
try {
|
let raw;
|
||||||
enLeaves = collectLeaves(readJson(resolve(I18N_DIR, EN_LOCALE)));
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`FAIL ${EN_LOCALE}: ${formatError(error)}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const enKeys = [...enLeaves.keys()];
|
|
||||||
let failed = false;
|
|
||||||
let identicalTotal = 0;
|
|
||||||
|
|
||||||
for (const localeFile of listLocaleFiles()) {
|
|
||||||
if (localeFile === EN_LOCALE) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const localeLeaves = collectLeaves(
|
raw = readJson(baselinePath);
|
||||||
readJson(resolve(I18N_DIR, localeFile))
|
|
||||||
);
|
|
||||||
const localeKeys = [...localeLeaves.keys()];
|
|
||||||
const missing = enKeys.filter((key) => !localeLeaves.has(key));
|
|
||||||
const extra = localeKeys.filter((key) => !enLeaves.has(key));
|
|
||||||
const identical = enKeys.filter((key) => {
|
|
||||||
if (!localeLeaves.has(key)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const localeValue = localeLeaves.get(key);
|
|
||||||
const enValue = enLeaves.get(key);
|
|
||||||
|
|
||||||
return (
|
|
||||||
typeof localeValue === 'string' &&
|
|
||||||
typeof enValue === 'string' &&
|
|
||||||
localeValue.trim().length > 0 &&
|
|
||||||
localeValue === enValue
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
identicalTotal += identical.length;
|
|
||||||
const localeFailed =
|
|
||||||
missing.length > 0 ||
|
|
||||||
extra.length > 0 ||
|
|
||||||
(FAIL_ON_IDENTICAL && identical.length > 0);
|
|
||||||
failed = failed || localeFailed;
|
|
||||||
|
|
||||||
const status = localeFailed ? 'FAIL' : 'ok';
|
|
||||||
console.log(
|
|
||||||
`${status} ${localeFile}: missing=${missing.length}${formatExamples(
|
|
||||||
missing
|
|
||||||
)} extra=${extra.length}${formatExamples(extra)} identical_en=${
|
|
||||||
identical.length
|
|
||||||
}${formatExamples(identical)}`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed = true;
|
if (error?.code === 'ENOENT') {
|
||||||
console.log(`FAIL ${localeFile}: ${formatError(error)}`);
|
return new Map();
|
||||||
|
}
|
||||||
|
throw new Error(`${baselinePath}: ${formatError(error)}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return parseBaseline(raw);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`${baselinePath}: ${formatError(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (identicalTotal > 0 && !FAIL_ON_IDENTICAL) {
|
function listLocales(i18nDir) {
|
||||||
console.log(
|
return readdirSync(i18nDir)
|
||||||
`warn identical_en=${identicalTotal}; pass --fail-on-identical to make English fallback values fatal.`
|
.filter((file) => file.endsWith('.json'))
|
||||||
|
.map((file) => file.slice(0, -'.json'.length))
|
||||||
|
.filter((locale) => locale !== EN_LOCALE)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkLocale({ i18nDir, locale, enLeaves, baseline, mode }) {
|
||||||
|
const localeLeaves = collectLeaves(
|
||||||
|
readJson(resolve(i18nDir, `${locale}.json`))
|
||||||
|
);
|
||||||
|
const missing = [...enLeaves.keys()].filter(
|
||||||
|
(key) => !localeLeaves.has(key)
|
||||||
|
);
|
||||||
|
const extra = [...localeLeaves.keys()].filter((key) => !enLeaves.has(key));
|
||||||
|
const identical = findIdenticalToEnglish(enLeaves, localeLeaves);
|
||||||
|
const { baselined, unbaselined, stale } = compareWithBaseline(
|
||||||
|
identical,
|
||||||
|
baseline.get(locale)
|
||||||
|
);
|
||||||
|
const rejected =
|
||||||
|
mode === 'strict'
|
||||||
|
? [...identical.keys()]
|
||||||
|
: mode === 'update'
|
||||||
|
? []
|
||||||
|
: unbaselined;
|
||||||
|
return { missing, extra, identical, baselined, rejected, stale };
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportLocale(log, file, result, mode) {
|
||||||
|
const { missing, extra, identical, baselined, rejected, stale } = result;
|
||||||
|
const failed = missing.length + extra.length + rejected.length > 0;
|
||||||
|
const baselineNote =
|
||||||
|
mode === 'baseline' ? ` baselined=${baselined.length}` : '';
|
||||||
|
log(
|
||||||
|
`${failed ? 'FAIL' : 'ok'} ${file}: missing=${
|
||||||
|
missing.length
|
||||||
|
}${formatExamples(missing)} extra=${extra.length}${formatExamples(
|
||||||
|
extra
|
||||||
|
)} identical_en=${identical.size}${baselineNote}`
|
||||||
|
);
|
||||||
|
if (mode === 'baseline' && stale.length > 0) {
|
||||||
|
log(
|
||||||
|
`note ${file}: ${stale.length} baseline entries are no longer English-identical${formatExamples(
|
||||||
|
stale
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportRejected(log, rejected, enLeaves, mode) {
|
||||||
|
if (rejected.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scope = mode === 'strict' ? '' : ' and are not in the baseline';
|
||||||
|
log(`${rejected.length} value(s) equal English${scope}:`);
|
||||||
|
for (const [file, key] of rejected) {
|
||||||
|
log(` ${file} ${key}: ${JSON.stringify(enLeaves.get(key))}`);
|
||||||
|
}
|
||||||
|
if (mode === 'baseline') {
|
||||||
|
log(
|
||||||
|
`Translate them (see the i18n-fill skill). Only if a value is legitimately the same in that language, run \`${UPDATE_COMMAND}\` and commit the reviewed baseline.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeBaseline(log, baselinePath, previous, identicalByLocale) {
|
||||||
|
const added = countChanges(
|
||||||
|
identicalByLocale,
|
||||||
|
previous,
|
||||||
|
(locale, key, value) =>
|
||||||
|
log(` + ${locale} ${key}: ${JSON.stringify(value)}`)
|
||||||
|
);
|
||||||
|
const removed = countChanges(previous, identicalByLocale);
|
||||||
|
writeFileSync(baselinePath, serializeBaseline(identicalByLocale));
|
||||||
|
log(
|
||||||
|
`Baseline written: ${added} added, ${removed} removed. Review every added entry before committing.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (failed) {
|
/** Runs the check and returns the process exit code. */
|
||||||
process.exitCode = 1;
|
export function run({
|
||||||
|
i18nDir = I18N_DIR,
|
||||||
|
baselinePath = BASELINE_PATH,
|
||||||
|
argv = [],
|
||||||
|
log = console.log,
|
||||||
|
} = {}) {
|
||||||
|
const strict = argv.includes('--fail-on-identical');
|
||||||
|
const update = argv.includes('--update-baseline');
|
||||||
|
if (argv.some((arg) => !FLAGS.has(arg)) || (strict && update)) {
|
||||||
|
log(
|
||||||
|
`FAIL unsupported arguments: ${argv.join(' ')}; pass at most one of --fail-on-identical and --update-baseline.`
|
||||||
|
);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const mode = strict ? 'strict' : update ? 'update' : 'baseline';
|
||||||
|
|
||||||
|
let enLeaves;
|
||||||
|
let baseline;
|
||||||
|
try {
|
||||||
|
enLeaves = collectLeaves(
|
||||||
|
readJson(resolve(i18nDir, `${EN_LOCALE}.json`))
|
||||||
|
);
|
||||||
|
// A strict audit ignores the baseline, so a damaged file cannot block it.
|
||||||
|
baseline = strict ? new Map() : readBaseline(baselinePath);
|
||||||
|
} catch (error) {
|
||||||
|
if (!enLeaves || !update) {
|
||||||
|
log(`FAIL ${formatError(error)}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
log(`note replacing unreadable baseline: ${formatError(error)}`);
|
||||||
|
baseline = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const locales = listLocales(i18nDir);
|
||||||
|
const identicalByLocale = new Map();
|
||||||
|
const rejected = [];
|
||||||
|
let failed = false;
|
||||||
|
let staleTotal = 0;
|
||||||
|
|
||||||
|
for (const locale of locales) {
|
||||||
|
const file = `${locale}.json`;
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = checkLocale({ i18nDir, locale, enLeaves, baseline, mode });
|
||||||
|
} catch (error) {
|
||||||
|
failed = true;
|
||||||
|
log(`FAIL ${file}: ${formatError(error)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
identicalByLocale.set(locale, result.identical);
|
||||||
|
rejected.push(...result.rejected.map((key) => [file, key]));
|
||||||
|
staleTotal += result.stale.length;
|
||||||
|
failed = reportLocale(log, file, result, mode) || failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'baseline') {
|
||||||
|
for (const [locale, entries] of baseline) {
|
||||||
|
if (!locales.includes(locale)) {
|
||||||
|
staleTotal += entries.size;
|
||||||
|
log(`note ${locale}: baseline lists a locale with no file`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (staleTotal > 0) {
|
||||||
|
log(
|
||||||
|
`note ${staleTotal} stale baseline entries; run \`${UPDATE_COMMAND}\` to shrink the baseline.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reportRejected(log, rejected, enLeaves, mode);
|
||||||
|
|
||||||
|
if (update) {
|
||||||
|
// Only a complete, readable set of locales is a state worth recording.
|
||||||
|
if (failed) {
|
||||||
|
log(
|
||||||
|
'FAIL baseline not written: fix the unreadable, missing or extra keys above first.'
|
||||||
|
);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
writeBaseline(log, baselinePath, baseline, identicalByLocale);
|
||||||
|
}
|
||||||
|
|
||||||
|
return failed ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMain =
|
||||||
|
process.argv[1] &&
|
||||||
|
resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
if (isMain) {
|
||||||
|
process.exitCode = run({ argv: process.argv.slice(2) });
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterEach, beforeEach, test } from 'node:test';
|
||||||
|
|
||||||
|
import { parseBaseline, run, serializeBaseline } from './check-drift.mjs';
|
||||||
|
|
||||||
|
const EN = {
|
||||||
|
APP: { TITLE: 'IPTVnator', SAVE: 'Save' },
|
||||||
|
PIN: 'PIN',
|
||||||
|
};
|
||||||
|
|
||||||
|
let root;
|
||||||
|
let dir;
|
||||||
|
let baselinePath;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
root = mkdtempSync(path.join(os.tmpdir(), 'i18n-drift-'));
|
||||||
|
dir = path.join(root, 'i18n');
|
||||||
|
mkdirSync(dir);
|
||||||
|
baselinePath = path.join(root, 'identical-en-baseline.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function writeLocale(locale, content) {
|
||||||
|
writeFileSync(path.join(dir, `${locale}.json`), JSON.stringify(content));
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeBaseline(content) {
|
||||||
|
writeFileSync(baselinePath, JSON.stringify(content));
|
||||||
|
}
|
||||||
|
|
||||||
|
function check(...argv) {
|
||||||
|
const lines = [];
|
||||||
|
const code = run({
|
||||||
|
i18nDir: dir,
|
||||||
|
baselinePath,
|
||||||
|
argv,
|
||||||
|
log: (line) => lines.push(line),
|
||||||
|
});
|
||||||
|
return { code, output: lines.join('\n') };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBaselineFile() {
|
||||||
|
return JSON.parse(readFileSync(baselinePath, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('fails on a new English-identical key that the baseline does not list', () => {
|
||||||
|
writeLocale('en', { ...EN, APP: { ...EN.APP, NEW: 'New label' } });
|
||||||
|
writeLocale('de', {
|
||||||
|
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern', NEW: 'New label' },
|
||||||
|
PIN: 'PIN',
|
||||||
|
});
|
||||||
|
writeBaseline({ de: { 'APP.TITLE': 'IPTVnator', PIN: 'PIN' } });
|
||||||
|
|
||||||
|
const { code, output } = check();
|
||||||
|
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.match(output, /^FAIL de\.json: .*identical_en=3 baselined=2$/m);
|
||||||
|
assert.match(output, /de\.json APP\.NEW: "New label"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('passes a translated key', () => {
|
||||||
|
writeLocale('en', EN);
|
||||||
|
writeLocale('de', {
|
||||||
|
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern' },
|
||||||
|
PIN: 'PIN-Code',
|
||||||
|
});
|
||||||
|
writeBaseline({ de: { 'APP.TITLE': 'IPTVnator' } });
|
||||||
|
|
||||||
|
const { code, output } = check();
|
||||||
|
|
||||||
|
assert.equal(code, 0);
|
||||||
|
assert.match(output, /^ok de\.json: .*identical_en=1 baselined=1$/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('passes an English-identical key listed in the baseline', () => {
|
||||||
|
writeLocale('en', EN);
|
||||||
|
writeLocale('de', {
|
||||||
|
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern' },
|
||||||
|
PIN: 'PIN',
|
||||||
|
});
|
||||||
|
writeBaseline({ de: { 'APP.TITLE': 'IPTVnator', PIN: 'PIN' } });
|
||||||
|
|
||||||
|
assert.equal(check().code, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a baseline entry only covers the English text it recorded', () => {
|
||||||
|
// English was reworded and the new text copied into the locale: that is
|
||||||
|
// new untranslated text, not the value the baseline accepted.
|
||||||
|
writeLocale('en', { ...EN, APP: { ...EN.APP, SAVE: 'Save changes' } });
|
||||||
|
writeLocale('de', {
|
||||||
|
APP: { TITLE: 'IPTVnator', SAVE: 'Save changes' },
|
||||||
|
PIN: 'PIN-Code',
|
||||||
|
});
|
||||||
|
writeBaseline({ de: { 'APP.TITLE': 'IPTVnator', 'APP.SAVE': 'Save' } });
|
||||||
|
|
||||||
|
const { code, output } = check();
|
||||||
|
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.match(output, /de\.json APP\.SAVE: "Save changes"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports baseline entries that are no longer identical without failing', () => {
|
||||||
|
writeLocale('en', EN);
|
||||||
|
writeLocale('de', {
|
||||||
|
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern' },
|
||||||
|
PIN: 'PIN-Code',
|
||||||
|
});
|
||||||
|
writeBaseline({
|
||||||
|
de: { 'APP.TITLE': 'IPTVnator', 'APP.SAVE': 'Save', PIN: 'PIN' },
|
||||||
|
fr: { PIN: 'PIN' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { code, output } = check();
|
||||||
|
|
||||||
|
assert.equal(code, 0);
|
||||||
|
assert.match(
|
||||||
|
output,
|
||||||
|
/^note de\.json: 2 baseline entries .*\(APP\.SAVE, PIN\)$/m
|
||||||
|
);
|
||||||
|
assert.match(output, /^note fr: baseline lists a locale with no file$/m);
|
||||||
|
assert.match(
|
||||||
|
output,
|
||||||
|
/^note 3 stale baseline entries; run .*i18n:baseline:update/m
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a locale without baseline entries fails on every identical value', () => {
|
||||||
|
writeLocale('en', EN);
|
||||||
|
writeLocale('uk', {
|
||||||
|
APP: { TITLE: 'IPTVnator', SAVE: 'Зберегти' },
|
||||||
|
PIN: 'PIN-код',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { code, output } = check();
|
||||||
|
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.match(output, /uk\.json APP\.TITLE: "IPTVnator"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still fails on missing and extra keys', () => {
|
||||||
|
writeLocale('en', EN);
|
||||||
|
writeLocale('de', { APP: { TITLE: 'IPTVnator', OLD: 'Alt' }, PIN: 'PIN' });
|
||||||
|
writeBaseline({ de: { 'APP.TITLE': 'IPTVnator', PIN: 'PIN' } });
|
||||||
|
|
||||||
|
const { code, output } = check();
|
||||||
|
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.match(
|
||||||
|
output,
|
||||||
|
/^FAIL de\.json: missing=1 \(APP\.SAVE\) extra=1 \(APP\.OLD\)/m
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--fail-on-identical ignores the baseline', () => {
|
||||||
|
writeLocale('en', EN);
|
||||||
|
writeLocale('de', {
|
||||||
|
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern' },
|
||||||
|
PIN: 'PIN',
|
||||||
|
});
|
||||||
|
writeBaseline({ de: { 'APP.TITLE': 'IPTVnator', PIN: 'PIN' } });
|
||||||
|
|
||||||
|
const { code, output } = check('--fail-on-identical');
|
||||||
|
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.match(output, /de\.json APP\.TITLE: "IPTVnator"/);
|
||||||
|
assert.match(output, /de\.json PIN: "PIN"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--update-baseline records current identical values and drops resolved ones', () => {
|
||||||
|
writeLocale('en', EN);
|
||||||
|
writeLocale('de', {
|
||||||
|
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern' },
|
||||||
|
PIN: 'PIN',
|
||||||
|
});
|
||||||
|
writeLocale('fr', {
|
||||||
|
APP: { TITLE: 'IPTVnator', SAVE: 'Enregistrer' },
|
||||||
|
PIN: 'Code PIN',
|
||||||
|
});
|
||||||
|
writeBaseline({ de: { 'APP.SAVE': 'Save' }, fr: { PIN: 'PIN' } });
|
||||||
|
|
||||||
|
const { code, output } = check('--update-baseline');
|
||||||
|
|
||||||
|
assert.equal(code, 0);
|
||||||
|
assert.match(output, /^ {2}\+ de PIN: "PIN"$/m);
|
||||||
|
assert.match(output, /Baseline written: 3 added, 2 removed\./);
|
||||||
|
assert.deepEqual(readBaselineFile(), {
|
||||||
|
de: { 'APP.TITLE': 'IPTVnator', PIN: 'PIN' },
|
||||||
|
fr: { 'APP.TITLE': 'IPTVnator' },
|
||||||
|
});
|
||||||
|
assert.equal(check().code, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--update-baseline keeps the old baseline when a locale is unreadable', () => {
|
||||||
|
writeLocale('en', EN);
|
||||||
|
writeFileSync(path.join(dir, 'de.json'), '{ broken');
|
||||||
|
writeBaseline({ de: { PIN: 'PIN' } });
|
||||||
|
|
||||||
|
const { code, output } = check('--update-baseline');
|
||||||
|
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.match(output, /baseline not written/);
|
||||||
|
assert.deepEqual(readBaselineFile(), { de: { PIN: 'PIN' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--update-baseline keeps the old baseline while keys are missing or extra', () => {
|
||||||
|
writeLocale('en', EN);
|
||||||
|
writeLocale('de', { APP: { TITLE: 'IPTVnator', OLD: 'Alt' }, PIN: 'PIN' });
|
||||||
|
writeBaseline({ de: { 'APP.SAVE': 'Save', PIN: 'PIN' } });
|
||||||
|
|
||||||
|
const { code, output } = check('--update-baseline');
|
||||||
|
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.match(output, /baseline not written/);
|
||||||
|
assert.deepEqual(readBaselineFile(), {
|
||||||
|
de: { 'APP.SAVE': 'Save', PIN: 'PIN' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--fail-on-identical audits even when the baseline is damaged', () => {
|
||||||
|
writeLocale('en', EN);
|
||||||
|
writeLocale('de', {
|
||||||
|
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern' },
|
||||||
|
PIN: 'PIN-Code',
|
||||||
|
});
|
||||||
|
writeFileSync(baselinePath, '{ broken');
|
||||||
|
|
||||||
|
const { code, output } = check('--fail-on-identical');
|
||||||
|
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.match(output, /^FAIL de\.json: missing=0 extra=0 identical_en=1$/m);
|
||||||
|
assert.match(output, /de\.json APP\.TITLE: "IPTVnator"/);
|
||||||
|
assert.equal(check().code, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects unknown and conflicting flags', () => {
|
||||||
|
writeLocale('en', EN);
|
||||||
|
|
||||||
|
assert.equal(check('--update').code, 1);
|
||||||
|
assert.equal(check('--fail-on-identical', '--update-baseline').code, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a malformed baseline', () => {
|
||||||
|
assert.throws(() => parseBaseline({ de: ['PIN'] }), /object of keys/);
|
||||||
|
assert.throws(() => parseBaseline({ de: { PIN: 1 } }), /English text/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('serialises locales and keys in a stable order', () => {
|
||||||
|
const serialized = serializeBaseline(
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
'fr',
|
||||||
|
new Map([
|
||||||
|
['B', 'b'],
|
||||||
|
['A', 'a'],
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
['de', new Map()],
|
||||||
|
['ar', new Map([['Z', 'z']])],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
serialized,
|
||||||
|
'{\n "ar": {\n "Z": "z"\n },\n "fr": {\n "A": "a",\n "B": "b"\n }\n}\n'
|
||||||
|
);
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user