Skip to content

Commit af705b9

Browse files
os-zhuangclaude
andauthored
feat(i18n): complete the locale backfill — all ten packs reach full key parity (#2872) (#2909)
Translates the remaining 275 keys x 8 packs = 2,200 strings, closing #2872. Largest namespaces: `grid` (101, mostly the import wizard), `gantt` (58), `dashboard` (25), plus a long tail across list/auth/fields/marketplace/ capability and nine others. Every pack is now at parity with en: 2,495 of 2,499 keys, zero keys en lacks. The four-key remainder is the outbound-message set, absent by design so t() falls through to English and the cloud confirm gate keeps recognising it. P3 is now enforceable. The four-namespace ratchet from #2905 is replaced by all-locales-key-parity.test.ts, asserting: every pack defines every en key; no pack defines a key en lacks (#2872 part b was 74 keys of exactly this); and placeholders match en per string — both `{{count}}` and the single-brace `{count}` form that two gantt.autoScheduleDlg keys use because their call site does a literal .replace('{count}', …) instead of i18next interpolation. All three assertions were mutation-tested, single-brace form included. A bug the test suite could not have caught: the first merge pass produced DUPLICATE keys in four packs. The key list is the union of what is missing across all eight, but the insert ran unconditionally, so packs that already had detail.created/detail.updated got a second copy. Every test still passed — at runtime the later property wins, so the parity check saw a consistent object. tsc caught it as TS1117 during `turbo build`; ESLint does not flag it and a runtime test cannot, since the duplicate is collapsed before JS sees the object. The merge script now filters per pack. Translations are model-generated, and dense domain terminology (Gantt dependency types, the import wizard's upsert/match-field vocabulary) is where that is weakest. Shipping as a reviewable first draft, not a finished localization. Mechanically verified: parity both directions, placeholder shape per string, and that no outbound agent message was translated. Claude-Session: https://claude.ai/code/session_01TubWYdWquVkS9dj733sDmC Co-authored-by: Claude <noreply@anthropic.com>
1 parent 503d3f6 commit af705b9

11 files changed

Lines changed: 2810 additions & 83 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@object-ui/i18n": patch
3+
---
4+
5+
feat(i18n): complete the locale backfill — all ten packs reach full key parity (objectui#2872)
6+
7+
Translates the remaining **275 keys × 8 packs = 2,200 strings**, closing
8+
objectui#2872. The largest namespaces are `grid` (101, mostly the import
9+
wizard), `gantt` (58) and `dashboard` (25), plus a long tail across `list`,
10+
`auth`, `fields`, `marketplace`, `capability` and nine others.
11+
12+
Every pack is now at parity with `en`: **2,495 of 2,499 keys**, zero keys that
13+
`en` lacks. The four-key remainder is the outbound-message set, absent by
14+
design so `t()` falls through to English and the cloud confirm gate keeps
15+
recognising it — `outbound-agent-messages.test.ts` owns that invariant.
16+
17+
**P3 is now enforceable.** `high-frequency-namespace-parity.test.ts` was scoped
18+
to four namespaces because full parity would have been a permanently red build.
19+
That restriction is obsolete, so it is replaced by
20+
`all-locales-key-parity.test.ts`, which asserts:
21+
22+
- every pack defines every `en` key;
23+
- no pack defines a key `en` lacks (objectui#2872 part b was 74 keys of exactly
24+
this, hidden behind a component-private fallback);
25+
- **placeholders match `en` per string** — both `{{count}}` and the single-brace
26+
`{count}` form, which two `gantt.autoScheduleDlg.*` keys use on purpose
27+
because their call site does a literal `.replace('{count}', …)` rather than
28+
i18next interpolation. A translation that drops a placeholder renders a
29+
sentence with a hole in it and no error, so this is checked mechanically
30+
rather than by eye.
31+
32+
All three assertions were mutation-tested, including the single-brace form.
33+
34+
### A bug the test suite could not have caught
35+
36+
The first merge pass produced **duplicate keys** in four packs: the key list is
37+
the union of what is missing across all eight, but the insert ran
38+
unconditionally, so packs that already had `detail.created` / `detail.updated`
39+
got a second copy. Every test still passed — at runtime the later property
40+
simply wins, so the parity check saw a perfectly consistent object.
41+
42+
`tsc` caught it as TS1117 during `turbo build`. ESLint does not flag it, and a
43+
runtime test *cannot* — the duplicate is already collapsed before JS sees the
44+
object. The compiler is the only possible guard here, and CI runs it. The merge
45+
script now filters per pack against what that pack actually defines.
46+
47+
### Translation quality
48+
49+
Model-generated, and dense domain terminology (Gantt dependency types, the
50+
import wizard's upsert/match-field vocabulary) is exactly where that is
51+
weakest. This was raised before starting and the work was requested anyway, so
52+
it ships as a **reviewable first draft, not a finished localization** — native
53+
review is still worthwhile. What *is* verified mechanically: key parity in both
54+
directions, placeholder shape per string, and that no outbound agent message
55+
was translated.
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* Full key parity across all ten locale packs (objectui#2872 P3).
3+
*
4+
* This replaces the four-namespace ratchet added in objectui#2905. That one was
5+
* deliberately scoped because the eight non-Chinese packs were still ~277 keys
6+
* behind, and a full assertion would have been a permanently red build rather
7+
* than a guard. With the backfill complete, the scope restriction is gone and
8+
* the invariant is simply: **every pack defines every `en` key, and no pack
9+
* defines a key `en` lacks.**
10+
*
11+
* Why this needs a test at all: `fallbackLng: 'en'` makes both failure modes
12+
* invisible at runtime.
13+
*
14+
* - A key missing from `de` renders English. That reads as "not translated
15+
* yet", not "we lost this" — and the missing-key handler is dev-only, so CI
16+
* never sees it.
17+
* - A key added to one pack but never to `en` cannot be translated by anyone
18+
* else and drifts silently. objectui#2872 part (b) was exactly this, 74 keys
19+
* deep, hidden behind a component-private fallback that made English
20+
* "happen to" render.
21+
*
22+
* The only permitted exception is the outbound-message set below.
23+
*/
24+
import { describe, it, expect } from 'vitest';
25+
import { builtInLocales } from '../locales';
26+
27+
/**
28+
* Text the console SENDS to the agent rather than displays. These are absent
29+
* from the eight non-gate packs ON PURPOSE, so `t()` falls through to its
30+
* English default and the cloud confirm gate keeps recognising the message —
31+
* see `outbound-agent-messages.test.ts`, which owns that invariant and asserts
32+
* it in both directions. Excluded here so the two guards cannot contradict
33+
* each other.
34+
*/
35+
const OUTBOUND_KEYS = new Set([
36+
'console.ai.planApproveMessage',
37+
'console.ai.planApproveDefaultsMessage',
38+
'console.ai.planAnswerMessage',
39+
'console.ai.changesConfirmMessage',
40+
]);
41+
42+
function keyPaths(node: unknown, prefix = ''): string[] {
43+
return node !== null && typeof node === 'object'
44+
? Object.entries(node as Record<string, unknown>).flatMap(([k, v]) =>
45+
keyPaths(v, prefix ? `${prefix}.${k}` : k),
46+
)
47+
: [prefix];
48+
}
49+
50+
const keysOf = (pack: unknown) => new Set(keyPaths(pack).filter((k) => !OUTBOUND_KEYS.has(k)));
51+
52+
const EN = keysOf(builtInLocales.en);
53+
const OTHER_LOCALES = Object.keys(builtInLocales).filter((l) => l !== 'en');
54+
55+
describe('all locale packs are at full key parity with en (objectui#2872)', () => {
56+
it('the comparison covers the whole pack — not an empty assertion', () => {
57+
// If a refactor breaks `keyPaths`, every diff below becomes trivially empty
58+
// and the suite would pass while asserting nothing.
59+
expect(EN.size).toBeGreaterThan(2000);
60+
expect(OTHER_LOCALES).toHaveLength(9);
61+
});
62+
63+
it.each(OTHER_LOCALES)('%s defines every en key', (lang) => {
64+
const missing = [...EN].filter((k) => !keysOf(builtInLocales[lang]).has(k)).sort();
65+
expect(missing, `${lang} is missing ${missing.length} key(s)`).toEqual([]);
66+
});
67+
68+
it.each(OTHER_LOCALES)('%s defines no key that en lacks', (lang) => {
69+
const extra = [...keysOf(builtInLocales[lang])].filter((k) => !EN.has(k)).sort();
70+
expect(extra, `${lang} has ${extra.length} key(s) absent from en`).toEqual([]);
71+
});
72+
73+
it('placeholders match en in every pack', () => {
74+
// A translation that drops `{{count}}` renders a sentence with a hole in it
75+
// and no error. Two gantt keys use SINGLE braces on purpose — their call
76+
// site does a literal `.replace('{count}', …)` instead of i18next
77+
// interpolation — so both forms are compared.
78+
const DOUBLE = /\{\{\w+\}\}/g;
79+
const SINGLE = /(?<!\{)\{\w+\}(?!\})/g;
80+
const shape = (v: unknown) =>
81+
typeof v === 'string'
82+
? [...(v.match(DOUBLE) ?? []), ...(v.match(SINGLE) ?? [])].sort().join(',')
83+
: null;
84+
const at = (pack: unknown, dotted: string) =>
85+
dotted.split('.').reduce<unknown>((n, p) => (n as Record<string, unknown>)?.[p], pack);
86+
87+
const mismatches: string[] = [];
88+
for (const lang of OTHER_LOCALES) {
89+
for (const key of EN) {
90+
const a = shape(at(builtInLocales.en, key));
91+
const b = shape(at(builtInLocales[lang], key));
92+
if (a !== null && b !== null && a !== b) {
93+
mismatches.push(`${lang} ${key}: en[${a}] vs ${lang}[${b}]`);
94+
}
95+
}
96+
}
97+
expect(mismatches).toEqual([]);
98+
});
99+
});

packages/i18n/src/__tests__/high-frequency-namespace-parity.test.ts

Lines changed: 0 additions & 83 deletions
This file was deleted.

0 commit comments

Comments
 (0)