From 288d8c3682a448fbdf9e9d7480101e6673bb7e7b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 18:47:16 +0000 Subject: [PATCH 1/2] fix(showcase): put the remaining eight authored validation messages on the translation channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `showcase_account` declares seven author-written `validations[].message` and `showcase_task` one, and none had an `objects.._validations..message` entry. An authored message is emitted VERBATIM without one, so on a zh-CN session those refusals arrived in English beside the platform's own, which have shipped zh-CN since #3957 — two languages inside one `400 VALIDATION_FAILED` envelope. Both nested `conditional` branches get their own entry. `checkConditional` delegates to the matching branch and renders THAT branch's message, addressed by the branch's own `name`, so `churn_reason_consistency`'s own sentence is structurally unreachable; translating only the wrapper would have translated the one sentence nobody reads. Its entry is kept so the bundle mirrors the declared rule set 1:1 and the pin can ask for every declared name rather than re-deriving objectql's dispatch. The pin is now BUNDLE-WIDE rather than per-object. The per-object version this replaces covered `showcase_project` only, which is exactly how these eight were left behind; the new one walks the composed stack's objects and object extensions, descends into conditional branches, and asks the question for every locale `i18n.supportedLocales` claims — so a newly declared rule without a translation fails instead of rotting. It also pins the default-locale entry to the authored sentence verbatim: the bundle wins in every locale, `en` included, so a drifted `en` entry turns the object's own message into dead text. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- .../src/data/objects/account.object.ts | 14 ++ .../src/data/objects/task.object.ts | 5 + .../src/system/translations/index.ts | 65 +++++++ .../new-project-wizard-initial-status.test.ts | 160 +++++++++++++++--- 4 files changed, 216 insertions(+), 28 deletions(-) diff --git a/examples/app-showcase/src/data/objects/account.object.ts b/examples/app-showcase/src/data/objects/account.object.ts index 13347c4d01..93268bbf25 100644 --- a/examples/app-showcase/src/data/objects/account.object.ts +++ b/examples/app-showcase/src/data/objects/account.object.ts @@ -115,6 +115,13 @@ export const Account = ObjectSchema.create({ // Task/Project: a re-entrant lifecycle (a churned account can be won // back). Demonstrates the guardrail is just a per-field validation rule // on the object — no separate metadata type, no separate file. + // + // #14518 — every `message` below is emitted VERBATIM unless the bundle + // carries `objects.showcase_account._validations..message` (#14253), + // which is the one way a refusal escapes the caller's language while the + // platform's own refusals arrive translated. All seven are in + // `src/system/translations/index.ts`; the bundle WINS, so rewording a + // sentence here without rewording it there makes this text dead. validations: [ { type: 'state_machine' as const, @@ -180,6 +187,13 @@ export const Account = ObjectSchema.create({ // non-churned account must NOT carry a stale churn reason. The // `otherwise` branch only flags an explicitly-set reason (it `has()`- // guards the absent case), so ordinary non-churned writes are untouched. + // + // The message on THIS rule never reaches a caller: `checkConditional` + // either returns nothing or delegates to the branch, and the branch's + // own `name` is what `objects.._validations..message` is keyed + // by. So the two branches below each need their own bundle entry — + // translating `churn_reason_consistency` alone would translate the one + // sentence nobody reads (#14518). type: 'conditional' as const, name: 'churn_reason_consistency', label: 'Churn Reason Consistency', diff --git a/examples/app-showcase/src/data/objects/task.object.ts b/examples/app-showcase/src/data/objects/task.object.ts index e9af404cbf..e8f8b78802 100644 --- a/examples/app-showcase/src/data/objects/task.object.ts +++ b/examples/app-showcase/src/data/objects/task.object.ts @@ -105,6 +105,11 @@ export const Task = ObjectSchema.create({ field: 'status', // Transitions are validated on update; insert sets the initial state. events: ['update'] as const, + // Update-only, so 'transition' is honest for the single refusal code + // this rule can raise. Translated at + // `objects.showcase_task._validations.task_status_flow.message` (#14253) + // — an authored message is emitted verbatim unless the bundle carries + // that key, and the bundle wins once it does (#14518). message: 'Invalid task status transition.', transitions: { backlog: ['todo'], diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index 86af31d1eb..8e9a838829 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -94,6 +94,17 @@ export const ShowcaseTranslationBundle = { }, sync_error: { label: 'Sync Error' }, }, + // The object's ONE authored rule message, on the #14253 channel for the + // same reason `showcase_project`'s four are (see the note there). The + // `en` entry is the authored sentence VERBATIM: the bundle WINS over + // `rule.message` in every locale, so a bundle entry that has drifted + // from the object turns the object's own sentence into dead text no + // reader ever sees. The pin asserts that equality rather than trusting it. + _validations: { + task_status_flow: { + message: 'Invalid task status transition.', + }, + }, // The FIRST `_views` block on the `en` side of this bundle, and // deliberately not a mirror of the zh-CN one below: view LABELS are // already English in `ui/views/task.view.ts`, so restating all fifteen @@ -129,6 +140,40 @@ export const ShowcaseTranslationBundle = { support_config: { label: 'Support Config' }, churn_reason: { label: 'Churn Reason' }, }, + // An author-written `validations[].message` is emitted VERBATIM unless + // the bundle carries it here (#14253) — see the note on + // `showcase_project` above. All SEVEN names this object declares, and + // the two NESTED ones are the point: `checkConditional` dispatches to + // the matching branch and renders that BRANCH's message, addressed by + // the branch's own `name`, so `churn_reason_consistency`'s own sentence + // is structurally unreachable and translating only it would leave both + // refusals a caller can actually see in English. Its entry is here + // anyway so the bundle mirrors the DECLARED rule set 1:1 — the pin in + // `test/new-project-wizard-initial-status.test.ts` asks for every + // declared name rather than re-deriving objectql's dispatch. + _validations: { + account_lifecycle: { + message: 'Invalid account lifecycle transition.', + }, + tax_id_format: { + message: 'Tax ID must look like 12-3456789.', + }, + billing_email_format: { + message: 'Billing Email must be a valid email address.', + }, + support_config_shape: { + message: 'Support Config must be { tier: standard|premium|enterprise, seats?: >=1 }.', + }, + churn_reason_consistency: { + message: 'Churn reason consistency.', + }, + churn_reason_present: { + message: 'A churn reason is required when an account is marked churned.', + }, + churn_reason_absent: { + message: 'A churn reason should only be set when the account is churned.', + }, + }, }, showcase_contact: { label: 'Contact', @@ -368,6 +413,11 @@ export const ShowcaseTranslationBundle = { }, sync_error: { label: '同步错误' }, }, + // 状态 is the field label above and 状态流转 the same idea + // `showcase_project`'s entry uses — one word per idea across the bundle. + _validations: { + task_status_flow: { message: '任务状态流转无效。' }, + }, _views: { // The default list — keyed `default`, see showcase_project above. default: { label: '全部任务' }, @@ -466,6 +516,21 @@ export const ShowcaseTranslationBundle = { support_config: { label: '支持配置' }, churn_reason: { label: '流失原因' }, }, + // The zh-CN mirror of the `en` `_validations` block — see the note + // there. Vocabulary is the one this bundle already established: + // 生命周期 / 税号 / 账单邮箱 / 支持配置 / 流失原因 are the field labels + // right above, so a refusal names the field with the same word the form + // does. The `support_config_shape` shape stays in its source spelling — + // it is a machine contract the author must type back, not prose. + _validations: { + account_lifecycle: { message: '客户生命周期的状态流转无效。' }, + tax_id_format: { message: '税号格式应为 12-3456789。' }, + billing_email_format: { message: '账单邮箱必须是有效的邮箱地址。' }, + support_config_shape: { message: '支持配置必须为 { tier: standard|premium|enterprise, seats?: >=1 }。' }, + churn_reason_consistency: { message: '流失原因一致性。' }, + churn_reason_present: { message: '客户标记为流失时必须填写流失原因。' }, + churn_reason_absent: { message: '只有客户已流失时才能填写流失原因。' }, + }, }, showcase_contact: { label: '联系人', diff --git a/examples/app-showcase/test/new-project-wizard-initial-status.test.ts b/examples/app-showcase/test/new-project-wizard-initial-status.test.ts index d6901a1c0a..7e43d07552 100644 --- a/examples/app-showcase/test/new-project-wizard-initial-status.test.ts +++ b/examples/app-showcase/test/new-project-wizard-initial-status.test.ts @@ -23,15 +23,20 @@ * on it. Asserting only "it throws" would pass against a rejection for any * other reason — including the `required` check, which is what a naive "just * drop the field" fix would have tripped. + * + * The second `describe` is #14518 and is deliberately NOT wizard-scoped: the + * per-object translation pin that used to live in the first one is replaced by + * a bundle-wide one, because an instance-scoped pin is what left eight authored + * messages behind when #14311 fixed four. */ import { describe, it, expect, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; +import stack from '../objectstack.config.js'; import { Account, Project } from '../src/data/objects/index.js'; import { NewProjectWizardPage } from '../src/ui/pages/new-project-wizard.page.js'; -import { ShowcaseTranslationBundle } from '../src/system/translations/index.js'; type Rule = { type?: string; @@ -39,6 +44,8 @@ type Rule = { field?: string; initialStates?: string[]; message?: string; + then?: Rule; + otherwise?: Rule; }; const APP_ID = 'com.objectstack.showcase'; @@ -137,33 +144,6 @@ describe('#14311 — the New Project wizard and the status state machine', () => expect(defaulted).toEqual(statusRule.initialStates); }); - it('EVERY rule on the object is on the translation channel in both shipped locales', () => { - // An authored `validations[].message` is emitted VERBATIM unless the bundle - // carries `objects.._validations..message` (#14253). Scoped to the - // whole object rather than to the status rule on purpose: this one wizard - // can also trip `end_after_start` and `spent_within_budget` from its - // budget/schedule step, so pinning only the status rule would let the single - // English sentence move one step later instead of disappearing. - const rules = ((Project as unknown as { validations?: Rule[] }).validations ?? []) - .filter((r) => typeof r?.name === 'string'); - expect(rules.length).toBeGreaterThan(1); - - for (const rule of rules) { - const name = rule.name!; - for (const locale of ['en', 'zh-CN'] as const) { - const entry = (ShowcaseTranslationBundle as any)[locale] - ?.objects?.showcase_project?._validations?.[name]; - expect(entry?.message, `${locale} is missing a message for ${name}`).toBeTruthy(); - } - // The zh-CN entry must actually BE Chinese — an English copy satisfies - // "a key exists" while reproducing the defect exactly. - const zh = (ShowcaseTranslationBundle as any)['zh-CN'] - .objects.showcase_project._validations[name].message as string; - expect(zh, `${name}'s zh-CN message is not Chinese`).toMatch(/[一-龥]/); - expect(zh, `${name}'s zh-CN message is a copy of the authored one`).not.toBe(rule.message); - } - }); - it('creates with the wizard payload and refuses the status it used to offer', async () => { const engine = await bootShowcase(); const account: any = await engine.insert( @@ -201,3 +181,127 @@ describe('#14311 — the New Project wizard and the status state machine', () => expect(field.value).toBe('active'); }, 30000); }); + +/** + * [#14518] EVERY authored `validations[].message` the showcase declares is on + * the #14253 translation channel, in every locale the app claims to support. + * + * Bundle-wide on purpose. #14311 put `showcase_project`'s four rules on the + * channel and stopped there, because its scope was one wizard — which left + * eight (seven on `showcase_account`, one on `showcase_task`) refusing in + * English inside an otherwise zh-CN error envelope. A pin scoped to one object + * polices that object; the NEXT rule to be declared rots the same way. This + * asks the question of the whole registered surface instead, so a new rule + * without a translation fails here rather than shipping. + * + * Read on the COMPOSED stack — `stack.objects`, `stack.objectExtensions`, + * `stack.translations`, `stack.i18n` — the reachability principle `seed.test.ts` + * documents: what the resolver and the lint gates see is the composed stack, + * not the imported modules. The locale list is the app's OWN claim + * (`i18n.supportedLocales`) rather than a literal, so adding a locale to the + * config puts every authored sentence in scope for it instead of silently + * declaring coverage nobody wrote. + */ +describe('#14518 — every authored validation message in the showcase is translated', () => { + interface AuthoredRule { object: string; name: string; message: string } + + /** + * Every named rule an object declares, DESCENDING into `conditional` + * branches. + * + * A `then` / `otherwise` branch is a full rule carrying its own `name`, and + * `checkConditional` renders THAT branch's message — the wrapping rule's + * sentence never reaches a caller. So a flat walk of `validations[]` misses + * exactly the messages a user actually reads, which is what the premise test + * below pins by name. + */ + function authoredRules(objectName: string, validations: unknown): AuthoredRule[] { + const out: AuthoredRule[] = []; + const visit = (rule: Rule | undefined): void => { + if (!rule || typeof rule !== 'object') return; + if (typeof rule.name === 'string' && typeof rule.message === 'string' && rule.message !== '') { + out.push({ object: objectName, name: rule.name, message: rule.message }); + } + visit(rule.then); + visit(rule.otherwise); + }; + for (const rule of Array.isArray(validations) ? validations : []) visit(rule as Rule); + return out; + } + + const declaredRules: AuthoredRule[] = [ + ...((stack.objects ?? []) as Array<{ name?: string; validations?: unknown }>) + .flatMap((o) => (typeof o?.name === 'string' ? authoredRules(o.name, o.validations) : [])), + // An extension's `validations` MERGE into the target object at + // registration (`ObjectExtensionSchema` carries them), so such a rule is + // addressed under `extend` — not under the extension. None declares one + // today; the walk is here so the first one is not a silent hole. + ...((stack.objectExtensions ?? []) as Array<{ extend?: string; validations?: unknown }>) + .flatMap((e) => (typeof e?.extend === 'string' ? authoredRules(e.extend, e.validations) : [])), + ]; + + const locales = (stack.i18n?.supportedLocales ?? []) as string[]; + const defaultLocale = (stack.i18n?.defaultLocale ?? 'en') as string; + + /** What the resolver would find at `objects.._validations..message`. */ + function bundleMessage(locale: string, objectName: string, ruleName: string): unknown { + for (const bundle of (stack.translations ?? []) as Array>) { + const found = bundle?.[locale]?.objects?.[objectName]?._validations?.[ruleName]?.message; + if (found !== undefined) return found; + } + return undefined; + } + + it('the premise: the walk sees the registered surface, nested branches included', () => { + // Without these the assertions below pass vacuously — over no locales, no + // objects, or a rule set that stops at the top level of `validations[]`. + expect(locales).toContain(defaultLocale); + expect(locales.filter((l) => l !== defaultLocale).length).toBeGreaterThan(0); + expect(new Set(declaredRules.map((r) => r.object)).size).toBeGreaterThanOrEqual(3); + expect(declaredRules.map((r) => r.name)).toContain('churn_reason_present'); + }); + + it('every authored rule message has a bundle entry in every supported locale', () => { + // Reported as a LIST rather than one failing assertion per rule: the whole + // population is the finding, and #14311 stopping at four is precisely the + // shape a first-failure-only report encourages. + const missing: string[] = []; + for (const rule of declaredRules) { + for (const locale of locales) { + const message = bundleMessage(locale, rule.object, rule.name); + if (typeof message !== 'string' || message.length === 0) { + missing.push(`${locale}: objects.${rule.object}._validations.${rule.name}.message`); + } + } + } + expect(missing, 'authored messages with no bundle entry refuse in the source language').toEqual([]); + }); + + it('the default-locale entry is the authored sentence verbatim', () => { + // The bundle WINS over `rule.message` in every locale, `en` included, so an + // entry that has drifted from the object turns the sentence authored beside + // the rule into text no reader ever sees — the object file then documents a + // refusal the app does not give. + for (const rule of declaredRules) { + expect( + bundleMessage(defaultLocale, rule.object, rule.name), + `objects.${rule.object}._validations.${rule.name} (${defaultLocale}) has drifted from the authored message`, + ).toBe(rule.message); + } + }); + + it('a non-default locale is actually translated, not a copy of the source', () => { + for (const locale of locales.filter((l) => l !== defaultLocale)) { + for (const rule of declaredRules) { + const message = bundleMessage(locale, rule.object, rule.name) as string; + // A copy of the English satisfies "a key exists" while reproducing the + // defect exactly — which is the failure mode this whole file is about. + expect(message, `${rule.object}.${rule.name} in ${locale} is a copy of the source`) + .not.toBe(rule.message); + if (locale.startsWith('zh')) { + expect(message, `${rule.object}.${rule.name} in ${locale} is not Chinese`).toMatch(/[一-龥]/); + } + } + } + }); +}); From a721d8efc1450bfa051af0fa8d151d1009263767 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 19:11:20 +0000 Subject: [PATCH 2/2] chore(changeset): patch @objectstack/example-showcase for the validation-message translations Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- ...owcase-authored-validation-message-i18n.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .changeset/showcase-authored-validation-message-i18n.md diff --git a/.changeset/showcase-authored-validation-message-i18n.md b/.changeset/showcase-authored-validation-message-i18n.md new file mode 100644 index 0000000000..3cbc7d3ff7 --- /dev/null +++ b/.changeset/showcase-authored-validation-message-i18n.md @@ -0,0 +1,28 @@ +--- +"@objectstack/example-showcase": patch +--- + +fix(showcase): put the remaining eight authored validation messages on the translation channel (#14518) + +`showcase_account` declares seven author-written `validations[].message` and +`showcase_task` one, and none of them had an +`objects.OBJECT._validations.RULE.message` entry. An authored message is emitted +VERBATIM without one, so on a `zh-CN` session those refusals arrived in English +beside the platform's own — which have shipped `zh-CN` since #3957 — two +languages inside one `400 VALIDATION_FAILED` envelope. #14311 fixed the same +defect for `showcase_project`; its scope was one wizard, so these were left. + +Both nested `conditional` branches get their own entry. `checkConditional` +delegates to the matching branch and renders THAT branch's message, addressed by +the branch's own `name`, so `churn_reason_consistency`'s own sentence is +structurally unreachable — translating only the wrapper would have translated +the one sentence nobody reads. Its entry is kept anyway so the bundle mirrors +the declared rule set 1:1. + +The pin is now BUNDLE-WIDE rather than per-object: it walks the composed stack's +objects and object extensions, descends into conditional branches, and asks the +question for every locale `i18n.supportedLocales` claims, so a newly declared +rule without a translation fails instead of rotting. It also pins the +default-locale entry to the authored sentence verbatim — the bundle wins in +every locale, `en` included, so a drifted `en` entry turns the object's own +message into dead text no reader ever sees.