From d6e8008911aebaf544609180ac681e95fd3e0e27 Mon Sep 17 00:00:00 2001 From: Serhii Kucherenko Date: Fri, 30 Oct 2026 14:34:19 -0700 Subject: [PATCH 1/3] test(i18n): catch locale dict dumps and check explicit ids against English #8953 shipped 11 Devs strings whose msgstr in all 16 catalogs was a Python dict of every locale, and no check noticed; #9017 fixed the text. The integrity test now flags braces a translation adds that its source does not have, so the same slip fails CI. It was red on all 176 dumps before that fix. It also checks explicit-id messages (serverError.*) against their English text instead of the id, so a dropped {plugin} or {entryPath} there no longer passes. A small example test pins the guard itself: a dict dump and a dropped placeholder are rejected, and a nested plural still passes. --- packages/i18n/test/catalog-integrity.test.ts | 64 ++++++++++++++++++-- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/packages/i18n/test/catalog-integrity.test.ts b/packages/i18n/test/catalog-integrity.test.ts index 8fe4f4ee590..f37af292b9f 100644 --- a/packages/i18n/test/catalog-integrity.test.ts +++ b/packages/i18n/test/catalog-integrity.test.ts @@ -55,25 +55,63 @@ const icu = (t: string) => .sort() .join(); +const ICU_HEAD = /\{\s*\w+\s*,\s*(plural|select|selectordinal)\b/y; + +// Balanced, unlike stripIcu: a branch ending in `{name}}` must not end the block. +function outsideIcu(t: string): string { + let out = ""; + let i = 0; + while (i < t.length) { + ICU_HEAD.lastIndex = i; + if (!ICU_HEAD.test(t)) { + out += t[i++]; + continue; + } + for (let depth = 0; i < t.length; i++) { + if (t[i] === "{") depth++; + else if (t[i] === "}" && --depth === 0) break; + } + i++; + } + return out; +} + +const strayBraces = (t: string) => + /[{}]/.test(outsideIcu(t).replace(/\{[A-Za-z0-9_]+\}/g, "")); + function integrityError(source: string, translation: string): string | null { for (const name of placeholders(source)) { if (!placeholders(translation).has(name)) return `missing {${name}}`; } if (tags(source) !== tags(translation)) return "tag markers differ"; if (icu(source) !== icu(translation)) return "ICU structure differs"; + if (strayBraces(translation) && !strayBraces(source)) + return "braces the source does not have"; return null; } +const readCatalog = (locale: string) => + parsePo(readFileSync(join(LOCALES_DIR, locale, "messages.po"), "utf8")); + +// An explicit-id message keeps its English in the source catalog's msgstr. +const english = new Map( + readCatalog(SOURCE_LOCALE).map((entry) => [ + entry.msgid, + entry.msgstr || entry.msgid, + ]), +); + describe("catalog integrity", () => { for (const locale of SUPPORTED_LOCALES) { if (locale === SOURCE_LOCALE) continue; test(`${locale} keeps placeholders, tags, and ICU shape`, () => { - const broken = parsePo( - readFileSync(join(LOCALES_DIR, locale, "messages.po"), "utf8"), - ) + const broken = readCatalog(locale) .filter((entry) => entry.msgstr !== "") .flatMap((entry) => { - const problem = integrityError(entry.msgid, entry.msgstr); + const problem = integrityError( + english.get(entry.msgid) ?? entry.msgid, + entry.msgstr, + ); return problem ? [`${JSON.stringify(entry.msgid.slice(0, 70))}: ${problem}`] : []; @@ -81,4 +119,22 @@ describe("catalog integrity", () => { expect(broken).toEqual([]); }); } + + test("rejects a locale dict dump and a dropped placeholder, keeps a nested plural", () => { + expect( + integrityError( + "Hello {name}", + '{"en": "Hello {name}", "fr": "Bonjour {name}"}', + ), + ).toBe("braces the source does not have"); + expect(integrityError("Saved to {path}", "Збережено")).toBe( + "missing {path}", + ); + expect( + integrityError( + "{count, plural, one {# file in {name}} other {# files in {name}}}", + "{count, plural, one {# файл у {name}} few {# файли у {name}} many {# файлів у {name}} other {# файлу у {name}}}", + ), + ).toBeNull(); + }); }); From b9b7f9ae12b762bbfe24fdc3bdd1d54fbec153b1 Mon Sep 17 00:00:00 2001 From: Serhii Kucherenko Date: Fri, 30 Oct 2026 22:25:24 -0700 Subject: [PATCH 2/3] test(i18n): make the guard examples catch both new checks The examples now go through the English lookup for an explicit id and assert that the balanced scan drops a whole nested plural. Each fails if its check regresses: a lookup that returns the id passes the dropped {name}, and the old stripIcu scan leaves "other {# in {name}}}" behind. --- packages/i18n/test/catalog-integrity.test.ts | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/i18n/test/catalog-integrity.test.ts b/packages/i18n/test/catalog-integrity.test.ts index f37af292b9f..13b80235fec 100644 --- a/packages/i18n/test/catalog-integrity.test.ts +++ b/packages/i18n/test/catalog-integrity.test.ts @@ -100,6 +100,7 @@ const english = new Map( entry.msgstr || entry.msgid, ]), ); +const sourceText = (msgid: string) => english.get(msgid) ?? msgid; describe("catalog integrity", () => { for (const locale of SUPPORTED_LOCALES) { @@ -108,10 +109,7 @@ describe("catalog integrity", () => { const broken = readCatalog(locale) .filter((entry) => entry.msgstr !== "") .flatMap((entry) => { - const problem = integrityError( - english.get(entry.msgid) ?? entry.msgid, - entry.msgstr, - ); + const problem = integrityError(sourceText(entry.msgid), entry.msgstr); return problem ? [`${JSON.stringify(entry.msgid.slice(0, 70))}: ${problem}`] : []; @@ -120,21 +118,21 @@ describe("catalog integrity", () => { }); } - test("rejects a locale dict dump and a dropped placeholder, keeps a nested plural", () => { + test("rejects a dict dump and a dropped placeholder behind an explicit id", () => { expect( integrityError( "Hello {name}", '{"en": "Hello {name}", "fr": "Bonjour {name}"}', ), ).toBe("braces the source does not have"); - expect(integrityError("Saved to {path}", "Збережено")).toBe( - "missing {path}", - ); expect( integrityError( - "{count, plural, one {# file in {name}} other {# files in {name}}}", - "{count, plural, one {# файл у {name}} few {# файли у {name}} many {# файлів у {name}} other {# файлу у {name}}}", + sourceText("serverError.plugins.marketplaceBuiltinRemove"), + "Вбудований, тому його не можна видалити", ), - ).toBeNull(); + ).toBe("missing {name}"); + expect( + outsideIcu("a {count, plural, one {# in {name}} other {# in {name}}} b"), + ).toBe("a b"); }); }); From 5b27dc0baba4e40402859aba90511f2c4d2a2740 Mon Sep 17 00:00:00 2001 From: Serhii Kucherenko Date: Fri, 30 Oct 2026 22:33:25 -0700 Subject: [PATCH 3/3] test(i18n): find stray braces with the Lingui parser The hand-written scan skipped whole ICU blocks, so a dict dump inside one plural branch passed. compileMessageOrThrow hands every plain-text piece, at every depth, to a hook; a brace there means broken text. A translation that does not parse counts as broken too, so the test names its id. --- packages/i18n/test/catalog-integrity.test.ts | 45 ++++++++++---------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/packages/i18n/test/catalog-integrity.test.ts b/packages/i18n/test/catalog-integrity.test.ts index 13b80235fec..e5302a42ec3 100644 --- a/packages/i18n/test/catalog-integrity.test.ts +++ b/packages/i18n/test/catalog-integrity.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { compileMessageOrThrow } from "@lingui/message-utils/compileMessage"; import { SUPPORTED_LOCALES } from "../src/locales"; // A translation may reword anything except the parts the runtime substitutes: @@ -55,30 +56,19 @@ const icu = (t: string) => .sort() .join(); -const ICU_HEAD = /\{\s*\w+\s*,\s*(plural|select|selectordinal)\b/y; - -// Balanced, unlike stripIcu: a branch ending in `{name}}` must not end the block. -function outsideIcu(t: string): string { - let out = ""; - let i = 0; - while (i < t.length) { - ICU_HEAD.lastIndex = i; - if (!ICU_HEAD.test(t)) { - out += t[i++]; - continue; - } - for (let depth = 0; i < t.length; i++) { - if (t[i] === "{") depth++; - else if (t[i] === "}" && --depth === 0) break; - } - i++; +function strayBraces(t: string): boolean { + let found = false; + try { + compileMessageOrThrow(t, (text) => { + found ||= /[{}]/.test(text); + return text; + }); + } catch { + return true; } - return out; + return found; } -const strayBraces = (t: string) => - /[{}]/.test(outsideIcu(t).replace(/\{[A-Za-z0-9_]+\}/g, "")); - function integrityError(source: string, translation: string): string | null { for (const name of placeholders(source)) { if (!placeholders(translation).has(name)) return `missing {${name}}`; @@ -132,7 +122,16 @@ describe("catalog integrity", () => { ), ).toBe("missing {name}"); expect( - outsideIcu("a {count, plural, one {# in {name}} other {# in {name}}} b"), - ).toBe("a b"); + integrityError( + "{count, plural, one {# in {name}} other {# in {name}}}", + '{count, plural, one {{"en": "# in {name}"}} other {# у {name}}}', + ), + ).toBe("braces the source does not have"); + expect( + integrityError( + "{count, plural, one {# file} other {# files}}", + "{count, plural, one {# файл} other {# файлів}", + ), + ).toBe("braces the source does not have"); }); });