Skip to content

Commit 6819dcb

Browse files
huangyiireneclaude
andauthored
fix(example-todo): re-key the three locale bundles' 19 dotted messages ids single-segment, so all 57 strings resolve (#19149)
Fixes #18566 Clause-②: no `examples/app-todo` authored its `messages` ids dot-separated (`'common.save'`). That spelling is unreachable rather than merely unconventional: `t()` resolves a key by `key.split('.')` and walking segment by segment, while `TranslationData.messages` is a FLAT string-to-string record. `t('messages.common.save', locale)` therefore looks for a nested `common` object, finds none, and returns the key string. ## The fork: RE-KEY. Every string is kept. The card named two shapes and deliberately refused to pick. This PR **re-keys**. All 19 ids in each of the three locales become single-segment (`commonSave`), the treatment is identical in `en.ts`, `zh-CN.ts` and `ja-JP.ts`, and **no translated string is deleted** — all 57 values survive and are now reachable. ### Why not delete The delete arm rests on the file's own comment at `en.ts:111-112`, read as establishing that these messages are redundant. **That comment is about a different key.** In full: ```ts // `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 // merely duplicated the rule's own text. The live home for these messages is // `validations[].message` on the task object. ``` It is a tombstone for the retired `validationMessages` group, and "these messages" are validation-RULE messages. The `messages` block holds generic UI strings — Save, Cancel, Delete, Edit, Create, Search, Filter, Sort, Refresh, Export, Back, Confirm, three success strings, two confirm prompts, two error strings. `validations[].message` is not their home and never was, so the file does not say what the delete arm needs it to say. Nothing else in the tree says it either: no `validations[]` entry on the Task object carries any of these nineteen strings. Three further reasons point the same way: - the single-segment spelling is what `content/docs/protocol/kernel/i18n-standard.mdx:493-500` prescribes; - it is what both `TranslationDataSchema` and `TranslationItemSchema` docblocks demonstrate after #18190 (`"messages": { "commonSave": "Save" }`); - it is what `packages/plugins/plugin-audit/src/translations/messages.ts` already does for its own bundle, for the stated reason. And this is a **reference example**. Deleting the block would leave `messages` undemonstrated in the one app an author copies from — which is how the trap arrived here in the first place. Re-keying turns 57 dead strings into 57 working ones *and* leaves the correct shape on display. ## Count reconciliation — the card's 19/57 is RIGHT The card says 19 dotted ids per locale, 57 total. A PM reading with a cruder instrument said 18 per locale, 54 total, and asked for this to be settled. **Measured here: 19 per locale, 57 total. The card is right; the 18/54 reading is wrong.** Instrument: a brace-matched extraction of the top-level `messages` object literal (scan from `messages: {` to its matching close brace by brace depth), then a per-line key scan accepting single-quoted, double-quoted and bare keys. Deliberately not an `awk`/`grep` line range, which is what can clip a block's final entry. Output on `origin/main`, before the change: ``` en.ts: messages keys total=19 dotted=19 plain=0 last key = "error.load_failed" zh-CN.ts: messages keys total=19 dotted=19 plain=0 last key = "error.load_failed" ja-JP.ts: messages keys total=19 dotted=19 plain=0 last key = "error.load_failed" GRAND TOTAL dotted messages keys across 3 file(s) = 57 ``` **Where the missing one went.** It is not a clipped range. Exactly one id per locale has an underscore in its second segment — `error.load_failed` — so a character class that omits `_` while anchoring on the closing quote-colon drops exactly that one key and nothing else. Measured both ways on the same files: ``` grep -cE "^\s*'[a-z_]+\.[a-z_]+':" -> 19 / 19 / 19 (class includes _) grep -cE "^\s*'[a-z]+\.[a-z]+':" -> 18 / 18 / 18 (class omits _) dropped line: en.ts:107 'error.load_failed': 'Failed to load data', ``` ## Premise checks | premise | verdict on this tree | |---|---| | Both `t()` implementations resolve by `split('.')` and walk segment by segment | **Holds.** `packages/core/src/fallbacks/memory-i18n.ts` `resolveKey` and `packages/services/service-i18n/src/file-i18n-adapter.ts` `resolveKey` are the same four lines of logic. Not diverged. | | `git grep -nE 'messages\.common\.' origin/main -- examples packages` returns zero reads | **Holds** — exit 1, no matches. Control on the same instrument: `messages` resolves 2x in `en.ts`. Repo-wide, the only occurrences of these ids outside the bundles are the #18190 changeset and the i18n standard, both citing them as the anti-pattern. | | #18190 does not reach `examples/**` | **Holds** — this change is independent of it. | | `examples/app-todo/src/translations/` is quiet | **Holds** — newest commit touching that subtree is `dda969cd`; nothing in the recent window on `examples/app-todo/` touches it. | ## The repair is demonstrated, not asserted `examples/app-todo/src/translations/message-id-resolution.test.ts` loads the **real bundle** into the **real provider** and resolves every id through the public `t()` contract — 121 cases: - every authored id in all three locales is single-segment (a standing guard, so the trap cannot come back silently); - `t('messages.ID', locale)` returns the authored string, for all 19 ids x 3 locales; - positive control on the walk itself: `t('objects.todo_task.label', 'en')` is `Task`, `'zh-CN'` is the Chinese label; - negative control: the three retired dotted spellings driven through the same call return the key string, which is the exact symptom the shipped ids had. **Reverse verification** (run from the committed state, so the restore point really exists). `scripts/ablation-replace.mjs` put `commonSave` back to `'common.save'` in `en.ts` and ran the suite: ``` ablation-replace: anchor "commonSave: 'Save'," x1 -> x0 ablation-replace: replace "'common.save': 'Save'," x0 -> x1 ablation-replace: blob 6c09a9d -> fc9ef2abfa59 # the mutation is proven ON DISK x `common.save` is single-segment x memory-i18n (core fallback) > en > t("messages.common.save") resolves Tests 2 failed | 119 passed (121) ablation-replace: blob after restore 6c09a9d == blob at HEAD 6c09a9d ablation-replace: git diff HEAD empty ``` Direction observed: **turns red**, as predicted, on both the guard and the resolution assertion. The negative-control suite stayed green under the mutation, also as predicted — a flat key named `common.save` is unreachable whichever spelling introduces it. ## Verification Run on `19dda8c9` (this branch's head after merging `origin/main`). | what | result | |---|---| | `pnpm --filter '@objectstack/example-todo^...' build` | exit 0 | | `pnpm --filter @objectstack/example-todo test` | exit 0 — 5 files, 227 tests (121 of them new) | | `pnpm --filter @objectstack/example-todo typecheck` | exit 0; `tsc --listFiles` confirms the new test file is in the program | | `pnpm --filter @objectstack/spec check:generated` | exit 0 — 16/16 artifacts current after the `main` merge | | `node scripts/pm/dispatch-gates.mjs --commands` (no paths) | 37 families derived; **all 37 run, all exit 0**; `--ran` reconciles 37 derived / 37 run / 0 NOT-MEASURED / 0 UNRUN | | `pnpm lint` (repo-wide, `eslint . --no-inline-config`) | exit 0 | Two gates needed a second run for reasons outside this diff, and both then passed: `check:dual-build-cjs-loads` first reported `PREREQUISITE NOT MET` (packages outside this app's dependency closure had no `dist/` in this worktree), and `check:dts-closure` named `plugin-hono-server` and `service-cluster-redis` as missing declaration files that their own builds had emitted minutes earlier — the `#15042` shape the gate's own failure text describes. A rebuild of the named packages restored 153/153 declarations across 61 packages. ## Deviations, declared 1. **The suite drives one provider, not two.** Importing `FileI18nAdapter` from `@objectstack/service-i18n` would add a seventh entry to this package's shrink-only unaliased-artifact ledger, and `check:test-source-alias` reds on it by name. Its prescribed remedy is an anchored alias in `examples/app-todo/vitest.config.ts` — outside this change's declared file surface, which is `examples/app-todo/src/translations/` plus `.changeset/`. The proof is unaffected in substance: both implementations resolve keys with identical code, verified by reading both. The test docblock records this. 2. **No changeset; `skip-changeset` is owed and this PR could not apply it.** `@objectstack/example-todo` is `"private": true` with no `files[]` — nothing published moves, which is exactly the `skip-changeset` case, and no changeset in this repo has ever named an example package (their CHANGELOG entries are dependency bumps only). Applying labels was outside this run's declared write budget, so `Check Changeset` will sit red until a seat applies `skip-changeset`. That is the only expected red. 3. **No maintainer-summary section.** This diff touches no governed surface, so it is neither the facts layer nor the rules layer that clause addresses. ## Acceptance notes Observed while working here, out of scope, filed nowhere: - `packages/lint/src/lint-liveness-properties.test.ts` uses `{ messages: { 'common.save': ... } }` in four fixtures. Noted, not filed: they are fixtures for a different property's liveness, not an authoring doorway, and they are not drift anybody has to chase today. Named successor: the deferred half 2 of #18190 — narrowing `TranslationItemSchema.messages` to single-segment keys — cannot land without re-spelling them, so that card carries them. - `check:dts-closure` / declarations vanishing from `dist/` after a successful build, in this worktree, twice (`packages/mcp`, then `plugin-hono-server` + `service-cluster-redis`). Noted, not filed: the gate's own text names this as `#15042` reproducing, so the card exists. Successor: whoever works `#15042`. - #18584 (the `{currentTask.days_overdue}` interpolation defect in this same app) is **not addressed here** and remains open; it is held serial behind this change by design. ## What this deliberately does NOT do `TranslationItemSchema` is untouched, and `packages/spec` has zero changes. Restricting `messages` to single-segment keys is the deferred half 2 of #18190 and rides its own card. --- _Generated by [Claude Code](https://claude.ai/code/session_019hBqDVrwbijUCoK9qsss2E)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 87f776b commit 6819dcb

4 files changed

Lines changed: 182 additions & 57 deletions

File tree

examples/app-todo/src/translations/en.ts

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -85,26 +85,37 @@ export const en: TranslationData = {
8585
description: 'Personal task management application',
8686
},
8787
},
88+
// `messages` ids are single-segment, and that is the whole contract, not a
89+
// style preference: `t()` resolves a key by walking its dot path
90+
// (`key.split('.')`, identically in `packages/core/src/fallbacks/memory-i18n.ts`
91+
// and `packages/services/service-i18n/src/file-i18n-adapter.ts`), while
92+
// `messages` is a FLAT `Record<string, string>`. So an id that merely
93+
// *contains* a dot — `'common.save'` — is one key NAMED `common.save`, and
94+
// `t('messages.common.save', …)` looks for a nested `common` object, finds
95+
// none, and returns the key string. `messages.commonSave` resolves (#18566).
96+
// Rule: `content/docs/protocol/kernel/i18n-standard.mdx`; proof that every id
97+
// below reaches its string through the real `t()`:
98+
// `./message-id-resolution.test.ts`.
8899
messages: {
89-
'common.save': 'Save',
90-
'common.cancel': 'Cancel',
91-
'common.delete': 'Delete',
92-
'common.edit': 'Edit',
93-
'common.create': 'Create',
94-
'common.search': 'Search',
95-
'common.filter': 'Filter',
96-
'common.sort': 'Sort',
97-
'common.refresh': 'Refresh',
98-
'common.export': 'Export',
99-
'common.back': 'Back',
100-
'common.confirm': 'Confirm',
101-
'success.saved': 'Successfully saved',
102-
'success.deleted': 'Successfully deleted',
103-
'success.completed': 'Task marked as completed',
104-
'confirm.delete': 'Are you sure you want to delete this task?',
105-
'confirm.complete': 'Mark this task as completed?',
106-
'error.required': 'This field is required',
107-
'error.load_failed': 'Failed to load data',
100+
commonSave: 'Save',
101+
commonCancel: 'Cancel',
102+
commonDelete: 'Delete',
103+
commonEdit: 'Edit',
104+
commonCreate: 'Create',
105+
commonSearch: 'Search',
106+
commonFilter: 'Filter',
107+
commonSort: 'Sort',
108+
commonRefresh: 'Refresh',
109+
commonExport: 'Export',
110+
commonBack: 'Back',
111+
commonConfirm: 'Confirm',
112+
successSaved: 'Successfully saved',
113+
successDeleted: 'Successfully deleted',
114+
successCompleted: 'Task marked as completed',
115+
confirmDelete: 'Are you sure you want to delete this task?',
116+
confirmComplete: 'Mark this task as completed?',
117+
errorRequired: 'This field is required',
118+
errorLoadFailed: 'Failed to load data',
108119
},
109120
// `validationMessages` retired in spec 17.0.0 (#4667) — no resolver ever read
110121
// it, so the zh-CN / ja-JP strings here were never rendered and the `en` ones

examples/app-todo/src/translations/ja-JP.ts

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -90,25 +90,27 @@ export const jaJP: TranslationData = {
9090
description: '個人タスク管理アプリケーション',
9191
},
9292
},
93+
// Single-segment `messages` ids — `t()` walks the dot path, so an id
94+
// containing a dot resolves to nothing; see the `en` bundle (#18566).
9395
messages: {
94-
'common.save': '保存',
95-
'common.cancel': 'キャンセル',
96-
'common.delete': '削除',
97-
'common.edit': '編集',
98-
'common.create': '新規作成',
99-
'common.search': '検索',
100-
'common.filter': 'フィルター',
101-
'common.sort': '並べ替え',
102-
'common.refresh': '更新',
103-
'common.export': 'エクスポート',
104-
'common.back': '戻る',
105-
'common.confirm': '確認',
106-
'success.saved': '保存しました',
107-
'success.deleted': '削除しました',
108-
'success.completed': 'タスクを完了にしました',
109-
'confirm.delete': 'このタスクを削除してもよろしいですか?',
110-
'confirm.complete': 'このタスクを完了にしますか?',
111-
'error.required': 'この項目は必須です',
112-
'error.load_failed': 'データの読み込みに失敗しました',
96+
commonSave: '保存',
97+
commonCancel: 'キャンセル',
98+
commonDelete: '削除',
99+
commonEdit: '編集',
100+
commonCreate: '新規作成',
101+
commonSearch: '検索',
102+
commonFilter: 'フィルター',
103+
commonSort: '並べ替え',
104+
commonRefresh: '更新',
105+
commonExport: 'エクスポート',
106+
commonBack: '戻る',
107+
commonConfirm: '確認',
108+
successSaved: '保存しました',
109+
successDeleted: '削除しました',
110+
successCompleted: 'タスクを完了にしました',
111+
confirmDelete: 'このタスクを削除してもよろしいですか?',
112+
confirmComplete: 'このタスクを完了にしますか?',
113+
errorRequired: 'この項目は必須です',
114+
errorLoadFailed: 'データの読み込みに失敗しました',
113115
},
114116
};
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { createMemoryI18n } from '@objectstack/core';
5+
import type { TranslationData } from '@objectstack/spec/system';
6+
import { en } from './en';
7+
import { zhCN } from './zh-CN';
8+
import { jaJP } from './ja-JP';
9+
10+
/**
11+
* Message-id reachability (#18566).
12+
*
13+
* This app's `messages` ids were authored dot-separated (`'common.save'`), and
14+
* that spelling is unreachable rather than merely unconventional: `t()` resolves
15+
* a key by `key.split('.')` and walking segment by segment — the same code in
16+
* both shipped implementations — while `messages` is a FLAT
17+
* `Record<string, string>`. `t('messages.common.save', locale)` therefore looks
18+
* for a nested `common` object, finds none, and returns the key string.
19+
*
20+
* A reference example is what an author copies, so the repair is not asserted
21+
* here, it is demonstrated: the real bundle is loaded into a real provider and
22+
* every id is resolved through the public `t()` contract. The last suite is the
23+
* control — it drives the OLD spelling through the same call and pins that it
24+
* returns the key itself, so a green run above cannot be a green run of an
25+
* assertion that could not fail.
26+
*
27+
* The provider driven here is the core in-memory fallback. `FileI18nAdapter`
28+
* (`@objectstack/service-i18n`) is deliberately NOT imported: it would add a
29+
* seventh entry to this package's shrink-only unaliased-artifact ledger
30+
* (`scripts/check-test-source-alias.mjs`), whose remedy is an alias in
31+
* `examples/app-todo/vitest.config.ts`. The two implementations resolve keys
32+
* with the same code — `key.split('.')` walked segment by segment, in
33+
* `packages/core/src/fallbacks/memory-i18n.ts` and in
34+
* `packages/services/service-i18n/src/file-i18n-adapter.ts` — so what this
35+
* suite proves about the ids holds for both.
36+
*/
37+
38+
const BUNDLES: [string, TranslationData][] = [
39+
['en', en],
40+
['zh-CN', zhCN],
41+
['ja-JP', jaJP],
42+
];
43+
44+
/** Minimal read surface — both providers implement `II18nService`. */
45+
interface Provider {
46+
t(key: string, locale: string, params?: Record<string, unknown>): string;
47+
loadTranslations(locale: string, data: Record<string, unknown>): void;
48+
}
49+
50+
/**
51+
* The shipped `II18nService` fallback, loaded with this app's real bundle.
52+
* Constructed per call so no suite can observe another's writes.
53+
*/
54+
function providers(): [string, Provider][] {
55+
const built: [string, Provider][] = [
56+
['memory-i18n (core fallback)', createMemoryI18n() as unknown as Provider],
57+
];
58+
for (const [, provider] of built) {
59+
for (const [locale, data] of BUNDLES) {
60+
provider.loadTranslations(locale, data as unknown as Record<string, unknown>);
61+
}
62+
}
63+
return built;
64+
}
65+
66+
/** Every authored id of a locale, paired with the string it must resolve to. */
67+
function idsOf(data: TranslationData): [string, string][] {
68+
return Object.entries(data.messages ?? {});
69+
}
70+
71+
describe.each(BUNDLES)('%s — authored `messages` ids', (locale, data) => {
72+
const ids = idsOf(data);
73+
74+
it('authors at least one message id', () => {
75+
// Guards the whole file against the zero-population reading: every
76+
// `it.each` below would pass vacuously on an empty `messages` block.
77+
expect(ids.length).toBeGreaterThan(0);
78+
});
79+
80+
it.each(ids.map(([id]) => id))('`%s` is single-segment', (id) => {
81+
expect(
82+
id.includes('.'),
83+
`[${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`,
84+
).toBe(false);
85+
});
86+
});
87+
88+
describe.each(providers())('%s', (_providerName, provider) => {
89+
describe.each(BUNDLES)('%s', (locale, data) => {
90+
it.each(idsOf(data))('t("messages.%s") resolves', (id, expected) => {
91+
expect(provider.t(`messages.${id}`, locale)).toBe(expected);
92+
});
93+
});
94+
95+
it('resolves a nested group key too (positive control on the walk itself)', () => {
96+
expect(provider.t('objects.todo_task.label', 'en')).toBe('Task');
97+
expect(provider.t('objects.todo_task.label', 'zh-CN')).toBe('任务');
98+
});
99+
100+
it.each([
101+
'messages.common.save',
102+
'messages.success.saved',
103+
'messages.error.load_failed',
104+
])('the retired dotted spelling `%s` still resolves to nothing', (key) => {
105+
// The control. `t()` returns the key string when the walk finds no leaf, so
106+
// this is the exact symptom the dotted ids shipped with — and it is why the
107+
// suite above is capable of going red if anyone re-introduces one.
108+
expect(provider.t(key, 'en')).toBe(key);
109+
});
110+
});

examples/app-todo/src/translations/zh-CN.ts

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -98,25 +98,27 @@ export const zhCN: TranslationData = {
9898
description: '个人任务管理应用',
9999
},
100100
},
101+
// Single-segment `messages` ids — `t()` walks the dot path, so an id
102+
// containing a dot resolves to nothing; see the `en` bundle (#18566).
101103
messages: {
102-
'common.save': '保存',
103-
'common.cancel': '取消',
104-
'common.delete': '删除',
105-
'common.edit': '编辑',
106-
'common.create': '新建',
107-
'common.search': '搜索',
108-
'common.filter': '筛选',
109-
'common.sort': '排序',
110-
'common.refresh': '刷新',
111-
'common.export': '导出',
112-
'common.back': '返回',
113-
'common.confirm': '确认',
114-
'success.saved': '保存成功',
115-
'success.deleted': '删除成功',
116-
'success.completed': '任务已标记为完成',
117-
'confirm.delete': '确定要删除此任务吗?',
118-
'confirm.complete': '确定将此任务标记为完成?',
119-
'error.required': '此字段为必填项',
120-
'error.load_failed': '数据加载失败',
104+
commonSave: '保存',
105+
commonCancel: '取消',
106+
commonDelete: '删除',
107+
commonEdit: '编辑',
108+
commonCreate: '新建',
109+
commonSearch: '搜索',
110+
commonFilter: '筛选',
111+
commonSort: '排序',
112+
commonRefresh: '刷新',
113+
commonExport: '导出',
114+
commonBack: '返回',
115+
commonConfirm: '确认',
116+
successSaved: '保存成功',
117+
successDeleted: '删除成功',
118+
successCompleted: '任务已标记为完成',
119+
confirmDelete: '确定要删除此任务吗?',
120+
confirmComplete: '确定将此任务标记为完成?',
121+
errorRequired: '此字段为必填项',
122+
errorLoadFailed: '数据加载失败',
121123
},
122124
};

0 commit comments

Comments
 (0)