i18n: a generated en bundle, a hand-written zh-CN, and a coverage gate that discovers - #103
Merged
Merged
Conversation
…rage gate `dulyTranslations` was empty while the config advertised `zh-CN`, so every authored label reached a Chinese deployment in English. - `src/translations/authored-text.ts` walks every string leaf in the metadata and demands a verdict per normalised path. A path with no verdict is a finding, so a new key is a red test rather than an unchecked string. - `src/translations/en.ts` derives the English bundle from that walk. There is no English literal to hand-edit, so `en` cannot drift from the source. - `src/translations/zh-CN.ts` is hand-written, 231 keys, with the terminology decisions (duty/task/period/standing/governed/caliber) recorded in the file. - `test/i18n-coverage.test.ts` fails on a declared label with no bundle key, a bundle key with no source, an unclassified path, a stale exemption, prose inside an opaque subtree, and an email template with no sibling row per supported locale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Merging main made the gate red, which is the gate working: - The `untranslatable` exemptions for the three `notify` nodes' inline `title` / `message` failed as STALE — #69 removed those strings, so the exemption list described metadata that no longer exists. - Nine string paths #69 introduced (`config.template`, `config.templateData.*`) had no verdict and failed the discovery tripwire. Classified them: `template` is a template NAME resolved by `(name, locale)`, `templateData` is a render payload. The inline-notification count is pinned at zero so the gap cannot silently reopen as inline copy. Email templates are NOT bundle-keyed and are deliberately not walked — they localize by row. The gate now asserts the row-shaped equivalent against real rows: one row per (name, locale) for every supported locale, and each locale row carrying its own words rather than a copy of the source row's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
os-warren
marked this pull request as ready for review
September 1, 2026 16:25
os-warren
added a commit
that referenced
this pull request
Sep 1, 2026
#103's coverage gate went red on merge, exactly as it is built to: the two new field labels and the new widget had no bundle key, three dataset slots had no verdict, and the zh-CN dashboard description still explained why lateness was missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #18
objectstack.config.tsadvertisedsupportedLocales: ['en', 'zh-CN']whiledulyTranslationswas[]. The app claimed Chinese and shipped none of it: every authored label reached a Chinese deployment in English. This closes that.Verified end to end in a browser: 231 bundle keys, four gates green at
0a70856, and the screens below are a realzh-CNsession againstpnpm demo.The constraint that decided the design:
enis generatedA hand-edited
enentry wins at render time, so the screen and the code disagree while every gate stays green. The usual answer — generate a file, add a staleness check — is only as good as whoever remembers to re-run the generator.So there is no
enliteral anywhere in this diff.src/translations/en.tsderives the bundle at config load from the same walk the gate uses. Nothing is stored, so nothing can go stale, and there is nothing a person could usefully hand-edit: changing an English string means changing the label insrc/objects/,src/views/orsrc/apps/, which is the only place it was ever true.That property is pinned rather than asserted in a comment —
buildEnglishBundleis run over synthetic metadata and the output has to follow the input, which a hand-written bundle could not do. It also goes throughdefineTranslationBundle, so the strictTranslationDataSchemarefuses a mis-built key at load rather than shipping it unread.The coverage gate discovers; it does not enumerate
src/translations/authored-text.tsvisits every string leaf in the metadata and demands a verdict per normalised path ([]for an array index,{}for a record key). A path with no verdict is a finding, and the gate fails naming it. The default is "unknown → red"; the tables are the exception list.Three verdicts:
translateen,zh-CNmust carry ituntranslatablemachineWhat is translatable at all is the platform's answer, not ours:
TRANSLATABLE_METADATA_TYPESis imported from@objectstack/spec/system({ view, action, object, app, dashboard, page }) and pinned, so the day datasets become translatable the pin goes red and the walk gets extended instead of quietly staying behind. View keys come from the platform's ownexpandViewContainer, so_views.defaultcannot drift from the registry name the resolver looks up.Beyond the two directions the card asks for, the gate fails on: an unclassified path, a stale exemption (an
untranslatableverdict that stopped matching anything), prose hiding inside a subtree declared opaque, two strings claiming one key, and a metadata collection inobjectstack.config.tsthat has never been classified.It caught #69 on merge, which is the argument for building it this way. Merging
mainturned it red twice over: the exemptions for the threenotifynodes' inlinetitle/messagefailed as stale because #69 removed those strings, and nine paths #69 introduced (config.template,config.templateData.*) had no verdict. Neither is a string this gate could have been told about in advance. The inline-notification count is now pinned at zero so that gap cannot silently reopen.Email templates are deliberately not walked
They are not bundle-keyed.
translation.zod.tsdoes not mention them,EmailTemplateDefinitionSchema.translationsisz.ZodNever, and the platform resolves a template by(name, locale)— so a translation is a sibling row, not a key. Demanding a bundle key for a template subject would make this gate permanently and unfixably red.That skip is named with its mechanism in
COLLECTION_HANDLINGrather than being implied by omission — a type that localizes differently must not look like a type the walk forgot. The equivalent question is asked of the row shape instead, against #69's real rows: one row per(name, locale)for everysupportedLocalesentry, and each locale row carrying its own words rather than a copy of the source row's. That second check compares literal text with{{holes}}stripped, becausesubject: '{{{subject}}}'is identical in both rows on purpose — it renders the task's own subject line, and flagging it would be flagging the data.Terminology
Six words carry product meaning a literal translation loses. The decisions live in the bundle, with their reasoning, because they have to be consistent across 231 keys and are not recoverable from the English string alone.
职责is the ordinary word for role-attached duty (岗位职责). ⛔ not任务(that is a task), ⛔ not义务(legal/moral)所属周期period_keyis "the period it belongs to"长期/持续, which read as an ongoing task — exactly the misreading that sends people looking for the tick box the product refuses to have纳入考核("counted towards assessment") — accurate about the metrics, wrong about a product that scores nobodydue_offset_days.labeltook the judgement the issue's round-2 comment flagged: the parenthetical had to survive as an explanation, so it is 「偏移天数(0 = 锚点当天)」 rather than a word-for-word rendering of "anchor day", and its help text keeps both worked examples per anchor, translated for sense.Verified in a browser, in Chinese
pnpm demo, a Chromium context withlocale: 'zh-CN'andAccept-Language: zh-CN.The console chrome does not stay English. This was the expected finding and it is not what happens — the platform ships its own zh-CN console bundle and picks it up from the browser locale.
新建/导入/行内编辑/筛选/分组/排序/搜索…/5 条记录/已选择 1 项/清除are all the platform's. Nothing to file againstobjectui.Object label, description, view tabs (
全部任务/我的本周/逾期/停滞), column headers, option badges (待办/进行中,岗位职责库/自行申报), nav groups and every nav item are ours and all render. The English strings visible are seed data (Emissions return — Northgate), correctly untranslated.Screenshots are on a throwaway branch (
claude/issue-18-screenshots) so no binaries enter this diff.Two gaps the walk predicted, confirmed on screen
Dashboard title, description and all five widget titles/descriptions are Chinese — and directly under each metric tile the dataset measure label renders
Untouched > 14 days,Untouched > 30 days,Oldest touch.The selection bar reads
已选择 1 项·Complete·Skip·清除— two English words between two Chinese ones, which is exactly the failure mode that reads as a styling quirk rather than a missing translation.Display text no bundle can reach — filed, not worked around
Per AGENTS.md rule 9 these are filed upstream rather than papered over. Each is a declared verdict with a stated reason, and the exact set is asserted, so a new one fails the gate and one that becomes translatable fails as stale.
bulkActionDefslabel / confirm / paramsmessageThe validation-message one is worth calling out against the card's scope line ("… validation message … gets a key"): measured on
objectql17.2.0, a rule'smessageis emitted verbatim ({ field, code: 'rule_violation', message: rule.message }).messages['validation.field.*']overrides the platform's built-in field catalog — which already shipszh-CN— not an authored rule. So a deployment gets platform refusals in Chinese and author-written refusals in English, and no bundle key can currently change that.Separately filed: objectstack-ai/objectstack#14254 —
translateActionignores_actions.ACTION.descriptionand_actions.ACTION.params.*, whichTranslationItemSchemadeclares andvalidateTranslationReferenceslints. Those keys are authored here anyway, for the same reason the gantt'sviewModestays authored insrc/views/task.view.ts: the key is the spec's own, it is served to REST/OpenAPI/MCP callers today, and it starts rendering the moment the resolver is fixed.The platform already owns half of this — as a warning
@objectstack/restshipsvalidateTranslationReferences(translation-target-unknown/translation-option-key-unknown), which covers the reverse direction over objects, fields, option values, views, sections, actions, params, apps, nav ids, dashboards and widgets. Measured by renaming one bundle key toduly_task_undoo:A warning, exit 0. This gate makes it an error and adds the forward direction, which nothing upstream checks.
Tests
test/i18n-coverage.test.ts— 29 tests. Reverse-verified rather than assumed; each mutation was confirmed on disk by an anchored count before the reading was taken, and restored by anEXIT/INT/TERMtrap:objects.duly_task.fields.status.labelfromzh-CNduly_task_undo→duly_task_undooinzh-CNplaceholder:toduly_task.note(a slot the walk had never seen)object.fields{}.placeholderPlus self-tests on synthetic metadata for every rule, including that a stack with no authored text makes every exemption stale, that prose inside an opaque subtree is caught, and both email-template rules (missing sibling, and a locale row that copies the source row's wording).
Gates
All four green on
0a70856, the final commit, after mergingmain(which had taken #69):Bundle confirmed in the artifact and served live:
GET /api/v1/i18n/locales→en,zh-CN;GET /api/v1/i18n/translations/zh-CNreturns the tree.No changeset — this repo has no changeset mechanism (no
.changeset/, no dependency, no script, no mention inAGENTS.md). The four gates are the whole contract.Recorded gap
Filed as #102 rather than done here: the bundle half of the gate compares against
zh-CNby name, so a third locale added later would satisfy the locale-set assertion and have its key coverage checked by nothing. The email-template half already iteratessupportedLocalesgenerically and does not have this problem. Left out deliberately — #18's scope is the first Chinese deployment — and filed so it is a decision on the record rather than a surprise.Generated by Claude Code