Skip to content

fix(example-todo): re-key the three locale bundles' 19 dotted messages ids single-segment, so all 57 strings resolve - #19149

Queued
huangyiirene wants to merge 4 commits into
mainfrom
claude/issue-18566-app-todo-dotted-message-ids
Queued

huangyiirene wants to merge 4 commits into
mainfrom
claude/issue-18566-app-todo-dotted-message-ids

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

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:

// `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:

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     6c09a9d5b017 -> 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  6c09a9d5b017  ==  blob at HEAD  6c09a9d5b017
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:

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

… 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<string, string>`. 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 <noreply@anthropic.com>
…ack 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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

2 participants