diff --git a/.ai/manifests/governance.json b/.ai/manifests/governance.json index b3be0261e..9e941f411 100644 --- a/.ai/manifests/governance.json +++ b/.ai/manifests/governance.json @@ -174,7 +174,7 @@ "file": "rules/19-logging-observability-and-redaction.md" }, { - "bytes": 4264, + "bytes": 4914, "file": "rules/20-i18n-and-user-facing-messages.md" }, { diff --git a/.ai/manifests/hashes.json b/.ai/manifests/hashes.json index 67626ec79..1e4f01c8f 100644 --- a/.ai/manifests/hashes.json +++ b/.ai/manifests/hashes.json @@ -8,7 +8,7 @@ ".ai/manifests/environment-variables.json": "33602cbe", ".ai/manifests/event-graph.json": "c1cd05bc", ".ai/manifests/frontend-routes.json": "a52b7ece", - ".ai/manifests/governance.json": "e4492ebd", + ".ai/manifests/governance.json": "1160e343", ".ai/manifests/i18n.json": "3befb756", ".ai/manifests/nginx-routes.json": "53c65be1", ".ai/manifests/packages.json": "cc928a4a", @@ -18,7 +18,7 @@ ".ai/manifests/rabbitmq-events.json": "bcaeb411", ".ai/manifests/repository.json": "047d93d0", ".ai/manifests/services.json": "9c92d2c4", - ".ai/manifests/tests.json": "4044f562", + ".ai/manifests/tests.json": "6eb2e2ae", ".ai/manifests/workspace-dependency-graph.json": "80e5438b", ".ai/manifests/workspaces.json": "b5133e99", ".ai/packs/README.md": "2e64753b", diff --git a/.ai/manifests/tests.json b/.ai/manifests/tests.json index 4c1217b64..71b854786 100644 --- a/.ai/manifests/tests.json +++ b/.ai/manifests/tests.json @@ -58,7 +58,7 @@ }, "claw-frontend": { "runner": "vitest", - "testFiles": 410 + "testFiles": 411 }, "claw-health-service": { "runner": "jest", @@ -102,5 +102,5 @@ } }, "generated": true, - "total": 1122 + "total": 1123 } diff --git a/apps/claw-frontend/src/constants/__tests__/learn-content-locale-completeness.test.ts b/apps/claw-frontend/src/constants/__tests__/learn-content-locale-completeness.test.ts new file mode 100644 index 000000000..371abc154 --- /dev/null +++ b/apps/claw-frontend/src/constants/__tests__/learn-content-locale-completeness.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; + +import { LEARN_CONTENT_BY_LOCALE } from '@/constants/learn-content.constants'; +import { Locale } from '@/enums/locale.enum'; + +/** + * Flattens a `LearnDictionary` into `path -> string` pairs, the same shape + * the i18n completeness tests use (see `chinese-completeness.test.ts`, + * `translations.test.ts`). Arrays (e.g. `seo.keywords`) flatten by index, + * which is exactly what parity needs: a locale with two keywords instead of + * three shows up as a missing key, not a silently shorter array. + */ +function flatten(value: unknown, prefix = '', result: Record = {}): Record { + if (typeof value === 'string') { + result[prefix] = value; + return result; + } + if (typeof value !== 'object' || value === null) { + return result; + } + for (const [key, child] of Object.entries(value)) { + flatten(child, prefix === '' ? key : `${prefix}.${key}`, result); + } + return result; +} + +function placeholders(value: string): string[] { + return [...value.matchAll(/\{[^{}]+\}/gu)].map((match) => match[0]).sort(); +} + +/** + * Section anchor ids (`sections..id`) are deliberately identical across + * every locale — "Stable across locales so a translated page keeps its deep + * links" (`LearnSection` in `types/learn.types.ts`). They are structural, + * not prose, so they are excluded from the "did anyone actually translate + * this" check below, the same way a `data-testid` would be. + */ +function isStructuralKey(key: string): boolean { + return key.endsWith('.id'); +} + +/** + * Values that are correctly identical across every locale: proper nouns, + * acronyms, product/model names, and other technical terms nobody + * translates. Anything NOT on this list that matches English exactly is + * either an untranslated string or a coincidence rare enough to be worth a + * second look — see `chinese-completeness.test.ts` for the same pattern + * against the general UI dictionary. + */ +const LEGITIMATE_UNCHANGED_VALUES = new Set([ + 'ClawAI', + 'RAG', + 'GPU', + 'CPU', + 'API', + 'LLM', + 'PDF', + 'URL', + 'GitHub', + 'Ollama', + 'llama.cpp', + 'Ollama vs llama.cpp', + // French "orchestration" is spelled identically to English (it is in fact + // the older of the two — the word entered English FROM French). Not a + // missed translation: fr correctly translates the same English word to + // "Orchestrazione"/"Orchestrierung" is what it/de do, which is a different + // language having a different native form, not evidence fr is wrong. + 'Orchestration', + // "Routing" is a standard, widely-used loanword in German and Italian + // technical writing (as common as "Router" itself) — this repo's own de/it + // copy elsewhere also keeps other loanwords (e.g. "GPU") rather than + // coining an unfamiliar native term. Flagged here for visibility: if a + // native speaker prefers a translated term, remove this line and fix the + // two eyebrow values it currently allows through. + 'Routing', + // SEO keyword arrays intentionally keep the English query term a non-English + // searcher would actually type — "best of N" is ML/statistics jargon, not + // prose, and translating it would target a search query nobody makes. + 'best of N', +]); + +const english = flatten(LEARN_CONTENT_BY_LOCALE[Locale.EN]); +const nonEnglishLocales = Object.values(Locale).filter((locale) => locale !== Locale.EN); + +describe('/learn content locale completeness', () => { + it.each(nonEnglishLocales)('%s defines every English key with no missing sections/faq/keywords', (locale) => { + const localized = flatten(LEARN_CONTENT_BY_LOCALE[locale]); + expect(Object.keys(localized).sort()).toEqual(Object.keys(english).sort()); + }); + + it.each(nonEnglishLocales)('%s preserves every interpolation placeholder', (locale) => { + const localized = flatten(LEARN_CONTENT_BY_LOCALE[locale]); + for (const key of Object.keys(english)) { + expect(placeholders(localized[key] ?? ''), key).toEqual(placeholders(english[key] ?? '')); + } + }); + + it.each(nonEnglishLocales)( + '%s has no untranslated English fallback outside the approved technical terms', + (locale) => { + const localized = flatten(LEARN_CONTENT_BY_LOCALE[locale]); + const unexpectedlyUnchanged = Object.keys(english) + .filter((key) => !isStructuralKey(key)) + .filter((key) => localized[key] === english[key]) + .filter((key) => !LEGITIMATE_UNCHANGED_VALUES.has(english[key] ?? '')); + + expect(unexpectedlyUnchanged).toEqual([]); + }, + ); +}); diff --git a/docs/features/ai-native-engineering-os/inventory.snapshot.json b/docs/features/ai-native-engineering-os/inventory.snapshot.json index 23319c4e9..4cae7a7b1 100644 --- a/docs/features/ai-native-engineering-os/inventory.snapshot.json +++ b/docs/features/ai-native-engineering-os/inventory.snapshot.json @@ -7378,7 +7378,7 @@ "file": "rules/19-logging-observability-and-redaction.md" }, { - "bytes": 4264, + "bytes": 4914, "file": "rules/20-i18n-and-user-facing-messages.md" }, { @@ -9516,7 +9516,7 @@ }, "claw-frontend": { "runner": "vitest", - "testFiles": 410 + "testFiles": 411 }, "claw-health-service": { "runner": "jest", @@ -10276,7 +10276,7 @@ "serviceCount": 18, "sharedPackageCount": 6, "staleClaimCount": 0, - "totalTestFiles": 1122, + "totalTestFiles": 1123, "workspaceCount": 25 } } diff --git a/rules/20-i18n-and-user-facing-messages.md b/rules/20-i18n-and-user-facing-messages.md index 99a4d4388..91d454442 100644 --- a/rules/20-i18n-and-user-facing-messages.md +++ b/rules/20-i18n-and-user-facing-messages.md @@ -70,6 +70,15 @@ imply a coverage that was not there. `western-locales-translation-regression.test.ts`** — catch English left untranslated, with an explicit allow-list of approved technical terms. - **`supported-locales.test.ts`** — every `Locale` member has a dictionary. +- **`constants/__tests__/learn-content-locale-completeness.test.ts`** (2026-09-01) — + the four tests above all assert against `lib/i18n/locales/*`, the UI-chrome + dictionary; none of them touch `constants/learn-content/*.constants.ts`, the + `/learn` article prose. This test covers that separately-maintained content + cluster for all 13 locales: key/section/FAQ parity, placeholder parity, and + the same untranslated-English-fallback check with its own allow-list. Any + other long-form content cluster added the same way (see + `public-comparison-content/`) needs the same treatment — it will not be + caught by the UI-dictionary tests either. - **TS config** — an `i18n.types.ts` mismatch fails `npm run typecheck`. - **Review checklist** — visual spot-check of one non-EN locale.