Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/complete-locale-backfill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
"@object-ui/i18n": patch
---

feat(i18n): complete the locale backfill — all ten packs reach full key parity (objectui#2872)

Translates the remaining **275 keys × 8 packs = 2,200 strings**, closing
objectui#2872. The largest namespaces are `grid` (101, mostly the import
wizard), `gantt` (58) and `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 that
`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 — `outbound-agent-messages.test.ts` owns that invariant.

**P3 is now enforceable.** `high-frequency-namespace-parity.test.ts` was scoped
to four namespaces because full parity would have been a permanently red build.
That restriction is obsolete, so it is replaced by
`all-locales-key-parity.test.ts`, which asserts:

- every pack defines every `en` key;
- no pack defines a key `en` lacks (objectui#2872 part b was 74 keys of exactly
this, hidden behind a component-private fallback);
- **placeholders match `en` per string** — both `{{count}}` and the single-brace
`{count}` form, which two `gantt.autoScheduleDlg.*` keys use on purpose
because their call site does a literal `.replace('{count}', …)` rather than
i18next interpolation. A translation that drops a placeholder renders a
sentence with a hole in it and no error, so this is checked mechanically
rather than by eye.

All three assertions were mutation-tested, including the single-brace form.

### 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
simply wins, so the parity check saw a perfectly consistent object.

`tsc` caught it as TS1117 during `turbo build`. ESLint does not flag it, and a
runtime test *cannot* — the duplicate is already collapsed before JS sees the
object. The compiler is the only possible guard here, and CI runs it. The merge
script now filters per pack against what that pack actually defines.

### Translation quality

Model-generated, and dense domain terminology (Gantt dependency types, the
import wizard's upsert/match-field vocabulary) is exactly where that is
weakest. This was raised before starting and the work was requested anyway, so
it ships as a **reviewable first draft, not a finished localization** — native
review is still worthwhile. What *is* verified mechanically: key parity in both
directions, placeholder shape per string, and that no outbound agent message
was translated.
99 changes: 99 additions & 0 deletions packages/i18n/src/__tests__/all-locales-key-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Full key parity across all ten locale packs (objectui#2872 P3).
*
* This replaces the four-namespace ratchet added in objectui#2905. That one was
* deliberately scoped because the eight non-Chinese packs were still ~277 keys
* behind, and a full assertion would have been a permanently red build rather
* than a guard. With the backfill complete, the scope restriction is gone and
* the invariant is simply: **every pack defines every `en` key, and no pack
* defines a key `en` lacks.**
*
* Why this needs a test at all: `fallbackLng: 'en'` makes both failure modes
* invisible at runtime.
*
* - A key missing from `de` renders English. That reads as "not translated
* yet", not "we lost this" — and the missing-key handler is dev-only, so CI
* never sees it.
* - A key added to one pack but never to `en` cannot be translated by anyone
* else and drifts silently. objectui#2872 part (b) was exactly this, 74 keys
* deep, hidden behind a component-private fallback that made English
* "happen to" render.
*
* The only permitted exception is the outbound-message set below.
*/
import { describe, it, expect } from 'vitest';
import { builtInLocales } from '../locales';

/**
* Text the console SENDS to the agent rather than displays. These are absent
* from the eight non-gate packs ON PURPOSE, so `t()` falls through to its
* English default and the cloud confirm gate keeps recognising the message —
* see `outbound-agent-messages.test.ts`, which owns that invariant and asserts
* it in both directions. Excluded here so the two guards cannot contradict
* each other.
*/
const OUTBOUND_KEYS = new Set([
'console.ai.planApproveMessage',
'console.ai.planApproveDefaultsMessage',
'console.ai.planAnswerMessage',
'console.ai.changesConfirmMessage',
]);

function keyPaths(node: unknown, prefix = ''): string[] {
return node !== null && typeof node === 'object'
? Object.entries(node as Record<string, unknown>).flatMap(([k, v]) =>
keyPaths(v, prefix ? `${prefix}.${k}` : k),
)
: [prefix];
}

const keysOf = (pack: unknown) => new Set(keyPaths(pack).filter((k) => !OUTBOUND_KEYS.has(k)));

const EN = keysOf(builtInLocales.en);
const OTHER_LOCALES = Object.keys(builtInLocales).filter((l) => l !== 'en');

describe('all locale packs are at full key parity with en (objectui#2872)', () => {
it('the comparison covers the whole pack — not an empty assertion', () => {
// If a refactor breaks `keyPaths`, every diff below becomes trivially empty
// and the suite would pass while asserting nothing.
expect(EN.size).toBeGreaterThan(2000);
expect(OTHER_LOCALES).toHaveLength(9);
});

it.each(OTHER_LOCALES)('%s defines every en key', (lang) => {
const missing = [...EN].filter((k) => !keysOf(builtInLocales[lang]).has(k)).sort();
expect(missing, `${lang} is missing ${missing.length} key(s)`).toEqual([]);
});

it.each(OTHER_LOCALES)('%s defines no key that en lacks', (lang) => {
const extra = [...keysOf(builtInLocales[lang])].filter((k) => !EN.has(k)).sort();
expect(extra, `${lang} has ${extra.length} key(s) absent from en`).toEqual([]);
});

it('placeholders match en in every pack', () => {
// A translation that drops `{{count}}` renders a sentence with a hole in it
// and no error. Two gantt keys use SINGLE braces on purpose — their call
// site does a literal `.replace('{count}', …)` instead of i18next
// interpolation — so both forms are compared.
const DOUBLE = /\{\{\w+\}\}/g;
const SINGLE = /(?<!\{)\{\w+\}(?!\})/g;
const shape = (v: unknown) =>
typeof v === 'string'
? [...(v.match(DOUBLE) ?? []), ...(v.match(SINGLE) ?? [])].sort().join(',')
: null;
const at = (pack: unknown, dotted: string) =>
dotted.split('.').reduce<unknown>((n, p) => (n as Record<string, unknown>)?.[p], pack);

const mismatches: string[] = [];
for (const lang of OTHER_LOCALES) {
for (const key of EN) {
const a = shape(at(builtInLocales.en, key));
const b = shape(at(builtInLocales[lang], key));
if (a !== null && b !== null && a !== b) {
mismatches.push(`${lang} ${key}: en[${a}] vs ${lang}[${b}]`);
}
}
}
expect(mismatches).toEqual([]);
});
});

This file was deleted.

Loading
Loading