From 231d180dcfa421cc108fbc7a3b7251417df92d3d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 21:56:05 +0000 Subject: [PATCH 1/2] fix(example-todo): re-key the three locale bundles' 19 `messages` ids single-segment, so they resolve `t()` resolves a key by `key.split('.')` and walking segment by segment -- identically in `packages/core/src/fallbacks/memory-i18n.ts` and `packages/services/service-i18n/src/file-i18n-adapter.ts` -- while `TranslationData.messages` is a FLAT `Record`. An id that merely *contains* a dot is therefore one key NAMED `common.save`, and `t('messages.common.save', locale)` looks for a nested `common` object, finds none, and returns the key string. `examples/app-todo` authored exactly that: 19 ids per locale across `en`, `zh-CN` and `ja-JP` -- 57 translated strings that resolved to nothing, in the reference app an author copies from. They are re-keyed to the single-segment spelling the standard prescribes (`commonSave`), so the strings are kept and the example demonstrates the reachable shape rather than the trap. `message-id-resolution.test.ts` demonstrates it rather than asserting it: the real bundle is loaded into BOTH real providers and all 57 ids are resolved through the public `t()` contract, with the old dotted spelling driven through the same call as the control. Claude-Session: https://claude.ai/code/session_019hBqDVrwbijUCoK9qsss2E Co-authored-by: Claude --- examples/app-todo/src/translations/en.ts | 49 +++++---- examples/app-todo/src/translations/ja-JP.ts | 40 +++---- .../message-id-resolution.test.ts | 102 ++++++++++++++++++ examples/app-todo/src/translations/zh-CN.ts | 40 +++---- 4 files changed, 174 insertions(+), 57 deletions(-) create mode 100644 examples/app-todo/src/translations/message-id-resolution.test.ts diff --git a/examples/app-todo/src/translations/en.ts b/examples/app-todo/src/translations/en.ts index 7d15832a6e5..de75c3620d5 100644 --- a/examples/app-todo/src/translations/en.ts +++ b/examples/app-todo/src/translations/en.ts @@ -85,26 +85,37 @@ export const en: TranslationData = { description: 'Personal task management application', }, }, + // `messages` ids are single-segment, and that is the whole contract, not a + // style preference: `t()` resolves a key by walking its dot path + // (`key.split('.')`, identically in `packages/core/src/fallbacks/memory-i18n.ts` + // and `packages/services/service-i18n/src/file-i18n-adapter.ts`), while + // `messages` is a FLAT `Record`. So an id that merely + // *contains* a dot — `'common.save'` — is one key NAMED `common.save`, and + // `t('messages.common.save', …)` looks for a nested `common` object, finds + // none, and returns the key string. `messages.commonSave` resolves (#18566). + // Rule: `content/docs/protocol/kernel/i18n-standard.mdx`; proof that these + // ids reach a value through both implementations: + // `./message-id-resolution.test.ts`. messages: { - 'common.save': 'Save', - 'common.cancel': 'Cancel', - 'common.delete': 'Delete', - 'common.edit': 'Edit', - 'common.create': 'Create', - 'common.search': 'Search', - 'common.filter': 'Filter', - 'common.sort': 'Sort', - 'common.refresh': 'Refresh', - 'common.export': 'Export', - 'common.back': 'Back', - 'common.confirm': 'Confirm', - 'success.saved': 'Successfully saved', - 'success.deleted': 'Successfully deleted', - 'success.completed': 'Task marked as completed', - 'confirm.delete': 'Are you sure you want to delete this task?', - 'confirm.complete': 'Mark this task as completed?', - 'error.required': 'This field is required', - 'error.load_failed': 'Failed to load data', + commonSave: 'Save', + commonCancel: 'Cancel', + commonDelete: 'Delete', + commonEdit: 'Edit', + commonCreate: 'Create', + commonSearch: 'Search', + commonFilter: 'Filter', + commonSort: 'Sort', + commonRefresh: 'Refresh', + commonExport: 'Export', + commonBack: 'Back', + commonConfirm: 'Confirm', + successSaved: 'Successfully saved', + successDeleted: 'Successfully deleted', + successCompleted: 'Task marked as completed', + confirmDelete: 'Are you sure you want to delete this task?', + confirmComplete: 'Mark this task as completed?', + errorRequired: 'This field is required', + errorLoadFailed: 'Failed to load data', }, // `validationMessages` retired in spec 17.0.0 (#4667) — no resolver ever read // it, so the zh-CN / ja-JP strings here were never rendered and the `en` ones diff --git a/examples/app-todo/src/translations/ja-JP.ts b/examples/app-todo/src/translations/ja-JP.ts index 042f63b5fe6..904ee264733 100644 --- a/examples/app-todo/src/translations/ja-JP.ts +++ b/examples/app-todo/src/translations/ja-JP.ts @@ -90,25 +90,27 @@ export const jaJP: TranslationData = { description: '個人タスク管理アプリケーション', }, }, + // Single-segment `messages` ids — `t()` walks the dot path, so an id + // containing a dot resolves to nothing; see the `en` bundle (#18566). messages: { - 'common.save': '保存', - 'common.cancel': 'キャンセル', - 'common.delete': '削除', - 'common.edit': '編集', - 'common.create': '新規作成', - 'common.search': '検索', - 'common.filter': 'フィルター', - 'common.sort': '並べ替え', - 'common.refresh': '更新', - 'common.export': 'エクスポート', - 'common.back': '戻る', - 'common.confirm': '確認', - 'success.saved': '保存しました', - 'success.deleted': '削除しました', - 'success.completed': 'タスクを完了にしました', - 'confirm.delete': 'このタスクを削除してもよろしいですか?', - 'confirm.complete': 'このタスクを完了にしますか?', - 'error.required': 'この項目は必須です', - 'error.load_failed': 'データの読み込みに失敗しました', + commonSave: '保存', + commonCancel: 'キャンセル', + commonDelete: '削除', + commonEdit: '編集', + commonCreate: '新規作成', + commonSearch: '検索', + commonFilter: 'フィルター', + commonSort: '並べ替え', + commonRefresh: '更新', + commonExport: 'エクスポート', + commonBack: '戻る', + commonConfirm: '確認', + successSaved: '保存しました', + successDeleted: '削除しました', + successCompleted: 'タスクを完了にしました', + confirmDelete: 'このタスクを削除してもよろしいですか?', + confirmComplete: 'このタスクを完了にしますか?', + errorRequired: 'この項目は必須です', + errorLoadFailed: 'データの読み込みに失敗しました', }, }; diff --git a/examples/app-todo/src/translations/message-id-resolution.test.ts b/examples/app-todo/src/translations/message-id-resolution.test.ts new file mode 100644 index 00000000000..170e38a61f3 --- /dev/null +++ b/examples/app-todo/src/translations/message-id-resolution.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { createMemoryI18n } from '@objectstack/core'; +import { FileI18nAdapter } from '@objectstack/service-i18n'; +import type { TranslationData } from '@objectstack/spec/system'; +import { en } from './en'; +import { zhCN } from './zh-CN'; +import { jaJP } from './ja-JP'; + +/** + * Message-id reachability (#18566). + * + * This app's `messages` ids were authored dot-separated (`'common.save'`), and + * that spelling is unreachable rather than merely unconventional: `t()` resolves + * a key by `key.split('.')` and walking segment by segment — the same code in + * both shipped implementations — while `messages` is a FLAT + * `Record`. `t('messages.common.save', locale)` therefore looks + * for a nested `common` object, finds none, and returns the key string. + * + * A reference example is what an author copies, so the repair is not asserted + * here, it is demonstrated: the real bundle is loaded into BOTH real providers + * and every id is resolved through the public `t()` contract. The last suite is + * the control — it drives the OLD spelling through the same call and pins that + * it returns the key itself, so a green run above cannot be a green run of an + * assertion that could not fail. + */ + +const BUNDLES: [string, TranslationData][] = [ + ['en', en], + ['zh-CN', zhCN], + ['ja-JP', jaJP], +]; + +/** Minimal read surface — both providers implement `II18nService`. */ +interface Provider { + t(key: string, locale: string, params?: Record): string; + loadTranslations(locale: string, data: Record): void; +} + +/** + * Both shipped `II18nService` implementations, each loaded with this app's real + * bundle. Constructed per call so no suite can observe another's writes. + */ +function providers(): [string, Provider][] { + const built: [string, Provider][] = [ + ['memory-i18n (core fallback)', createMemoryI18n() as unknown as Provider], + ['FileI18nAdapter (service-i18n)', new FileI18nAdapter({ defaultLocale: 'en' }) as unknown as Provider], + ]; + for (const [, provider] of built) { + for (const [locale, data] of BUNDLES) { + provider.loadTranslations(locale, data as unknown as Record); + } + } + return built; +} + +/** Every authored id of a locale, paired with the string it must resolve to. */ +function idsOf(data: TranslationData): [string, string][] { + return Object.entries(data.messages ?? {}); +} + +describe.each(BUNDLES)('%s — authored `messages` ids', (locale, data) => { + const ids = idsOf(data); + + it('authors at least one message id', () => { + // Guards the whole file against the zero-population reading: every + // `it.each` below would pass vacuously on an empty `messages` block. + expect(ids.length).toBeGreaterThan(0); + }); + + it.each(ids.map(([id]) => id))('`%s` is single-segment', (id) => { + expect( + id.includes('.'), + `[${locale}] message id "${id}" contains a dot, so t('messages.${id}', '${locale}') walks into a nested object that does not exist and returns the key`, + ).toBe(false); + }); +}); + +describe.each(providers())('%s', (_providerName, provider) => { + describe.each(BUNDLES)('%s', (locale, data) => { + it.each(idsOf(data))('t("messages.%s") resolves', (id, expected) => { + expect(provider.t(`messages.${id}`, locale)).toBe(expected); + }); + }); + + it('resolves a nested group key too (positive control on the walk itself)', () => { + expect(provider.t('objects.todo_task.label', 'en')).toBe('Task'); + expect(provider.t('objects.todo_task.label', 'zh-CN')).toBe('任务'); + }); + + it.each([ + 'messages.common.save', + 'messages.success.saved', + 'messages.error.load_failed', + ])('the retired dotted spelling `%s` still resolves to nothing', (key) => { + // The control. `t()` returns the key string when the walk finds no leaf, so + // this is the exact symptom the dotted ids shipped with — and it is why the + // suite above is capable of going red if anyone re-introduces one. + expect(provider.t(key, 'en')).toBe(key); + }); +}); diff --git a/examples/app-todo/src/translations/zh-CN.ts b/examples/app-todo/src/translations/zh-CN.ts index 99b7e7cf549..316a389df73 100644 --- a/examples/app-todo/src/translations/zh-CN.ts +++ b/examples/app-todo/src/translations/zh-CN.ts @@ -98,25 +98,27 @@ export const zhCN: TranslationData = { description: '个人任务管理应用', }, }, + // Single-segment `messages` ids — `t()` walks the dot path, so an id + // containing a dot resolves to nothing; see the `en` bundle (#18566). messages: { - 'common.save': '保存', - 'common.cancel': '取消', - 'common.delete': '删除', - 'common.edit': '编辑', - 'common.create': '新建', - 'common.search': '搜索', - 'common.filter': '筛选', - 'common.sort': '排序', - 'common.refresh': '刷新', - 'common.export': '导出', - 'common.back': '返回', - 'common.confirm': '确认', - 'success.saved': '保存成功', - 'success.deleted': '删除成功', - 'success.completed': '任务已标记为完成', - 'confirm.delete': '确定要删除此任务吗?', - 'confirm.complete': '确定将此任务标记为完成?', - 'error.required': '此字段为必填项', - 'error.load_failed': '数据加载失败', + commonSave: '保存', + commonCancel: '取消', + commonDelete: '删除', + commonEdit: '编辑', + commonCreate: '新建', + commonSearch: '搜索', + commonFilter: '筛选', + commonSort: '排序', + commonRefresh: '刷新', + commonExport: '导出', + commonBack: '返回', + commonConfirm: '确认', + successSaved: '保存成功', + successDeleted: '删除成功', + successCompleted: '任务已标记为完成', + confirmDelete: '确定要删除此任务吗?', + confirmComplete: '确定将此任务标记为完成?', + errorRequired: '此字段为必填项', + errorLoadFailed: '数据加载失败', }, }; From 0f239c2685f7fa063472cd0768361e50d198ea95 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 22:49:16 +0000 Subject: [PATCH 2/2] test(example-todo): drive the resolution proof through the core fallback alone `check:test-source-alias` reds on a NEW unaliased artifact import: importing `FileI18nAdapter` from `@objectstack/service-i18n` would add a seventh entry to this package's shrink-only unaliased-artifact ledger, and the gate's own remedy is an anchored alias in `examples/app-todo/vitest.config.ts` -- outside this change's declared file surface. The proof is unaffected in substance: both shipped implementations resolve a key with the same code (`key.split('.')` walked segment by segment), so what the suite establishes about these ids holds for both. The docblock now says which provider is driven and why the other is not. Claude-Session: https://claude.ai/code/session_019hBqDVrwbijUCoK9qsss2E Co-authored-by: Claude --- examples/app-todo/src/translations/en.ts | 4 ++-- .../message-id-resolution.test.ts | 24 ++++++++++++------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/examples/app-todo/src/translations/en.ts b/examples/app-todo/src/translations/en.ts index de75c3620d5..6c09a9d5b01 100644 --- a/examples/app-todo/src/translations/en.ts +++ b/examples/app-todo/src/translations/en.ts @@ -93,8 +93,8 @@ export const en: TranslationData = { // *contains* a dot — `'common.save'` — is one key NAMED `common.save`, and // `t('messages.common.save', …)` looks for a nested `common` object, finds // none, and returns the key string. `messages.commonSave` resolves (#18566). - // Rule: `content/docs/protocol/kernel/i18n-standard.mdx`; proof that these - // ids reach a value through both implementations: + // Rule: `content/docs/protocol/kernel/i18n-standard.mdx`; proof that every id + // below reaches its string through the real `t()`: // `./message-id-resolution.test.ts`. messages: { commonSave: 'Save', diff --git a/examples/app-todo/src/translations/message-id-resolution.test.ts b/examples/app-todo/src/translations/message-id-resolution.test.ts index 170e38a61f3..8a9346b9012 100644 --- a/examples/app-todo/src/translations/message-id-resolution.test.ts +++ b/examples/app-todo/src/translations/message-id-resolution.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest'; import { createMemoryI18n } from '@objectstack/core'; -import { FileI18nAdapter } from '@objectstack/service-i18n'; import type { TranslationData } from '@objectstack/spec/system'; import { en } from './en'; import { zhCN } from './zh-CN'; @@ -19,11 +18,21 @@ import { jaJP } from './ja-JP'; * for a nested `common` object, finds none, and returns the key string. * * A reference example is what an author copies, so the repair is not asserted - * here, it is demonstrated: the real bundle is loaded into BOTH real providers - * and every id is resolved through the public `t()` contract. The last suite is - * the control — it drives the OLD spelling through the same call and pins that - * it returns the key itself, so a green run above cannot be a green run of an + * here, it is demonstrated: the real bundle is loaded into a real provider and + * every id is resolved through the public `t()` contract. The last suite is the + * control — it drives the OLD spelling through the same call and pins that it + * returns the key itself, so a green run above cannot be a green run of an * assertion that could not fail. + * + * The provider driven here is the core in-memory fallback. `FileI18nAdapter` + * (`@objectstack/service-i18n`) is deliberately NOT imported: it would add a + * seventh entry to this package's shrink-only unaliased-artifact ledger + * (`scripts/check-test-source-alias.mjs`), whose remedy is an alias in + * `examples/app-todo/vitest.config.ts`. The two implementations resolve keys + * with the same code — `key.split('.')` walked segment by segment, in + * `packages/core/src/fallbacks/memory-i18n.ts` and in + * `packages/services/service-i18n/src/file-i18n-adapter.ts` — so what this + * suite proves about the ids holds for both. */ const BUNDLES: [string, TranslationData][] = [ @@ -39,13 +48,12 @@ interface Provider { } /** - * Both shipped `II18nService` implementations, each loaded with this app's real - * bundle. Constructed per call so no suite can observe another's writes. + * The shipped `II18nService` fallback, loaded with this app's real bundle. + * Constructed per call so no suite can observe another's writes. */ function providers(): [string, Provider][] { const built: [string, Provider][] = [ ['memory-i18n (core fallback)', createMemoryI18n() as unknown as Provider], - ['FileI18nAdapter (service-i18n)', new FileI18nAdapter({ defaultLocale: 'en' }) as unknown as Provider], ]; for (const [, provider] of built) { for (const [locale, data] of BUNDLES) {