From f02ef3554b62bb5eae9f52d7815c7e3a2c77bc86 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 05:19:37 +0000 Subject: [PATCH 1/2] fix(spec): serve a field's translated help on `description`, never on an undeclared `help` translateObject overlaid the bundle's `objects.OBJECT.fields.FIELD.help` entry onto a `help` key FieldSchema does not declare. The entry is the translation of the field's `description` (the i18n extractor writes it from that key), so it is now served there, under ADR-0029 D9.2a: the catalog applies only while the served description equals the packaged field's (valueOverridesPackagedBase), and with no packaged base it applies. ObjectFieldLike drops its `help` member. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- .../spec/src/system/i18n-resolver.test.ts | 169 ++++++++++++++++++ packages/spec/src/system/i18n-resolver.ts | 86 +++++++-- 2 files changed, 244 insertions(+), 11 deletions(-) diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index fbcc161979f..5c06f5a49f4 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -3548,6 +3548,175 @@ describe('translateObject — catalog vs explicit override (#8284)', () => { }); }); +// ──────────────────────────────────────────────────────────────────────────── +// translateObject — a field's translated help is served on `description` (#21948) +// ──────────────────────────────────────────────────────────────────────────── + +import { FieldSchema } from '../data/field.zod'; + +/** + * #21948 — the bundle's `objects..fields..help` entry is the + * translation of the field's `description` (the i18n extractor writes it from + * that key), and it used to be served on a `help` key `FieldSchema` does not + * declare. Every consumer that reads only declared keys rendered the English + * `description`, and the console warned once per such field on every load. + * + * The entry is served on `description` now, under ADR-0029 D9.2a: the catalog + * applies only while the served `description` still equals the packaged + * field's (`valueOverridesPackagedBase`), and with no packaged base it applies. + */ +describe('translateObject — a field\'s translated help is served on `description`', () => { + const TWO_FACTOR_EN = + 'Whether two-factor authentication is enabled for this user. Maintained by the better-auth `twoFactor` plugin.'; + const TWO_FACTOR_ZH = '该用户是否已启用双因素认证。由 better-auth 的 `twoFactor` 插件维护。'; + + /** The packaged declaration, as the owner contributor holds it. */ + const PACKAGED = { + name: 'sys_user', + label: 'User', + fields: { + two_factor_enabled: { + name: 'two_factor_enabled', + type: 'boolean', + label: 'Two-Factor Enabled', + description: TWO_FACTOR_EN, + }, + phone: { + name: 'phone', + type: 'text', + label: 'Phone', + description: 'Contact number.', + inlineHelpText: 'Include the country code.', + }, + nickname: { name: 'nickname', type: 'text', label: 'Nickname' }, + }, + }; + + /** + * What the extractor writes: `en` repeats the packaged `description` under + * `help`, `zh-CN` is its translation. `nickname` has no entry at all. + */ + const BUNDLE: TranslationBundle = { + en: { + objects: { + sys_user: { + fields: { + two_factor_enabled: { label: 'Two-Factor Enabled', help: TWO_FACTOR_EN }, + phone: { label: 'Phone', help: 'Contact number.' }, + }, + }, + }, + } as any, + 'zh-CN': { + objects: { + sys_user: { + fields: { + two_factor_enabled: { label: '已启用双因素认证', help: TWO_FACTOR_ZH }, + phone: { label: '电话', help: '联系电话。' }, + }, + }, + }, + } as any, + }; + + const clone = (value: V): V => JSON.parse(JSON.stringify(value)); + const fieldsOf = (out: unknown) => (out as { fields: Record> }).fields; + const unrecognizedKeys = (def: unknown) => { + const parsed = FieldSchema.safeParse(def); + return parsed.success ? [] : parsed.error.issues.filter((issue) => issue.code === 'unrecognized_keys'); + }; + + it('serves the zh-CN translation on `description`, carries no `help` key, and parses with no unrecognized key', () => { + const field = fieldsOf(translateObject(clone(PACKAGED), BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED })) + .two_factor_enabled; + expect(field.description).toBe(TWO_FACTOR_ZH); + expect('help' in field).toBe(false); + expect(field.label).toBe('已启用双因素认证'); + expect(unrecognizedKeys(field)).toEqual([]); + }); + + it('leaves a field with no bundle entry exactly as it came — the control', () => { + const doc = clone(PACKAGED); + const field = fieldsOf(translateObject(doc, BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED })).nickname; + expect(field).toEqual(PACKAGED.fields.nickname); + expect('description' in field).toBe(false); + expect('help' in field).toBe(false); + }); + + it('a field declaring both `description` and `inlineHelpText` gets the translation on `description`; `inlineHelpText` is untouched', () => { + const field = fieldsOf(translateObject(clone(PACKAGED), BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED })).phone; + expect(field.description).toBe('联系电话。'); + expect(field.inlineHelpText).toBe('Include the country code.'); + expect('help' in field).toBe(false); + expect(unrecognizedKeys(field)).toEqual([]); + }); + + it('keeps a `description` that diverged from the packaged field, in every locale, through the type dispatch', () => { + // The REST boundary reaches `translateObject` through the dispatcher, so the + // packaged base has to arrive there for the comparison to be made at all. + const served = clone(PACKAGED); + served.fields.two_factor_enabled.description = 'Edited by the tenant.'; + for (const locale of ['zh-CN', 'en']) { + const fields = fieldsOf(translateMetadataDocument('object', clone(served), BUNDLE, { locale, packagedBase: PACKAGED })); + expect(fields.two_factor_enabled.description).toBe('Edited by the tenant.'); + expect('help' in fields.two_factor_enabled).toBe(false); + } + // Judged per field: the undiverged sibling is still translated… + const zh = fieldsOf(translateMetadataDocument('object', clone(served), BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED })); + expect(zh.phone.description).toBe('联系电话。'); + // …and the field `label` stays a flat `catalog ?? document`, as before. + expect(zh.two_factor_enabled.label).toBe('已启用双因素认证'); + }); + + it('a field the packaged base does not declare counts as diverged', () => { + // An `objectExtensions` field is folded on after the owner's declaration, + // which is all the base carries. + const served = clone(PACKAGED) as any; + served.fields.extra = { name: 'extra', type: 'text', label: 'Extra', description: 'Added by an extension.' }; + const bundle = clone(BUNDLE) as any; + bundle['zh-CN'].objects.sys_user.fields.extra = { help: '扩展添加。' }; + const field = fieldsOf(translateObject(served, bundle, { locale: 'zh-CN', packagedBase: PACKAGED })).extra; + expect(field.description).toBe('Added by an extension.'); + expect('help' in field).toBe(false); + }); + + it('with no packaged base, the catalog applies — "unknown" is not "authored"', () => { + const served = clone(PACKAGED); + served.fields.two_factor_enabled.description = 'Edited by the tenant.'; + for (const packagedBase of [undefined, null]) { + const field = fieldsOf(translateObject(clone(served), BUNDLE, { locale: 'zh-CN', packagedBase })).two_factor_enabled; + expect(field.description).toBe(TWO_FACTOR_ZH); + expect('help' in field).toBe(false); + } + expect(fieldsOf(translateObject(clone(served), BUNDLE, { locale: 'zh-CN' })).two_factor_enabled.description) + .toBe(TWO_FACTOR_ZH); + }); + + it('an absent served `description` is not an override — the catalog fills it', () => { + const served = clone(PACKAGED) as any; + delete served.fields.two_factor_enabled.description; + const field = fieldsOf(translateObject(served, BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED })).two_factor_enabled; + expect(field.description).toBe(TWO_FACTOR_ZH); + }); + + it('judges an array-shaped field list against an array-shaped base by field name', () => { + const asArray = (doc: typeof PACKAGED) => ({ ...doc, fields: Object.values(doc.fields) }); + const served = clone(PACKAGED); + served.fields.two_factor_enabled.description = 'Edited by the tenant.'; + const out = translateObject(asArray(served) as any, BUNDLE, { locale: 'zh-CN', packagedBase: asArray(PACKAGED) }); + const byName = Object.fromEntries((out.fields as any[]).map((f) => [f.name, f])); + expect(byName.two_factor_enabled.description).toBe('Edited by the tenant.'); + expect(byName.phone.description).toBe('联系电话。'); + expect((out.fields as any[]).some((f) => 'help' in f)).toBe(false); + }); + + it('does not mutate the input document', () => { + const doc = clone(PACKAGED); + translateObject(doc, BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED }); + expect(doc).toEqual(PACKAGED); + }); +}); + // ════════════════════════════════════════════════════════════════════════════ // Screen-flow copy resolvers (#7646 / #11287) // ════════════════════════════════════════════════════════════════════════════ diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 7c5a042741a..a8eade91831 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -1419,6 +1419,36 @@ function packagedPart( return {}; } +/** + * The packaged counterpart of one served FIELD of an object, with the same + * three answers as {@link packagedPart}: `undefined` when no packaged base was + * supplied, `{}` when the base is known but declares no such field (a field + * authored after the fact — an `objectExtensions` field is folded on after the + * owner's declaration the base carries), and the packaged field otherwise. + * + * A separate finder only because a field map is a record keyed by field name + * (the canonical shape) where {@link packagedPart} walks an array; the array + * shape some reads flatten to is matched by `name`, as `translateObject` + * itself accepts both. + */ +function packagedObjectField( + packagedObject: Record | undefined, + fieldName: string, +): Record | undefined { + if (packagedObject === undefined) return undefined; + const fields = packagedObject.fields; + if (Array.isArray(fields)) { + for (const candidate of fields) { + const record = asRecord(candidate); + if (record !== undefined && record.name === fieldName) return record; + } + return {}; + } + const map = asRecord(fields); + if (map === undefined || !Object.prototype.hasOwnProperty.call(map, fieldName)) return {}; + return asRecord(map[fieldName]) ?? {}; +} + /** * Overlay `dashboards..globalFilters..{label,options.}` * onto one authored filter (#16772). Returns the input object itself when @@ -2414,10 +2444,15 @@ export interface ObjectLike { listViews?: Record; } +/** + * Minimal field shape consumed by `translateObject`. It names only keys + * `FieldSchema` declares: there is no `help` here, because a field spells its + * help text `description` / `inlineHelpText`. The bundle's field `help` entry + * is served on `description` (see {@link translateObject}). + */ export interface ObjectFieldLike { name?: string; label?: string; - help?: string; description?: string; options?: Array<{ label?: string; value: string | number | boolean }>; [key: string]: any; @@ -2906,7 +2941,8 @@ function translateObjectListViews( /** * Apply the active locale to an object metadata document. Translates the * object's `label` / `pluralLabel` / `description`, walks each field to - * translate its `label`, `help`, and per-option `label`s, walks any + * translate its `label`, its `description` (from the bundle's field `help` + * entry — see below), and per-option `label`s, walks any * inline-declared `actions` through {@link translateAction}, and overlays the * copy of each EMBEDDED `listViews` entry from the `_views` keys the i18n * extractor writes for it ({@link translateObjectListViews}). The input document @@ -2949,13 +2985,35 @@ function translateObjectListViews( * conservative edges. `?layers=true` stays untranslated and diagnostic, * unchanged. * - * The rule is scoped to the three SCALARS, which are what the ruling covers: - * `fields` is a key-keyed spread whose per-field labels have their own - * (per-field) catalog keys, and no read has been measured to diverge on them. - * The embedded `listViews` follow the VIEW translator's application of the - * same rule (ADR-0029 D9.2a, as {@link translateView} applies it), judged per - * view against the packaged object's own `listViews` — see - * {@link translateObjectListViews}. + * The ruling covers the three SCALARS. A field's `label` stays a flat + * `catalog ?? document`: `fields` is a key-keyed spread whose per-field labels + * have their own (per-field) catalog keys, and no read has been measured to + * diverge on them. The embedded `listViews` follow the VIEW translator's + * application of the same rule (ADR-0029 D9.2a, as {@link translateView} + * applies it), judged per view against the packaged object's own `listViews` — + * see {@link translateObjectListViews}. + * + * ## A field's translated help is served on `description` — ADR-0029 D9.2a + * + * The bundle addresses a field's help as `objects..fields..help` + * (`FieldTranslationSchema.help`), and the i18n extractor writes that entry + * from the field's `description` (`packages/cli/src/utils/i18n-extract.ts`; + * its other source, a field `help`, is a key `FieldSchema` refuses). So the + * entry is the translation of `description`, and it is served THERE. It is + * ⛔ never served on a `help` key, which `FieldSchema` does not declare: the + * served field def then carried a key the contract refuses, every consumer + * that reads only declared keys rendered the source-language `description`, + * and the console's ingestion step warned once per such field (#21948). It is + * ⛔ not served on `inlineHelpText` either, which the extractor never reads. + * + * `description` is a key the catalog had never touched, so it follows the + * rule above, as every string this translator added since does: the catalog + * applies only while the served field's `description` still equals the same + * field's in {@link TranslateDocumentOptions.packagedBase}, judged by + * {@link valueOverridesPackagedBase} (⛔ never a second comparison). A field + * the base does not declare was authored after the fact, and its description + * counts as diverged; no base supplied means nothing is inferred and the + * catalog applies. */ export function translateObject( doc: T, @@ -2982,6 +3040,7 @@ export function translateObject( const label = resolveScalar('label', doc.label); const pluralLabel = resolveScalar('pluralLabel', doc.pluralLabel); const description = resolveScalar('description', doc.description); + const packagedObject = asRecord(opts?.packagedBase); const translateField = (name: string, def: ObjectFieldLike): ObjectFieldLike => { const next: ObjectFieldLike = { ...def }; @@ -2989,8 +3048,13 @@ export function translateObject( lookupObjectFieldAttr(bundle, objectName, name, 'label', opts) ?? builtinSystemFieldLabel(name, def.label, opts); if (translatedLabel) next.label = translatedLabel; - const translatedHelp = lookupObjectFieldAttr(bundle, objectName, name, 'help', opts); - if (translatedHelp) next.help = translatedHelp; + // The bundle's field `help` entry translates `description`, and is served + // there — never on an undeclared `help` — unless the served description + // diverged from the packaged field's (ADR-0029 D9.2a; see the docblock). + if (!valueOverridesPackagedBase(packagedObjectField(packagedObject, name), 'description', def.description)) { + const translatedDescription = lookupObjectFieldAttr(bundle, objectName, name, 'help', opts); + if (translatedDescription) next.description = translatedDescription; + } if (Array.isArray(def.options)) { // A picklist-bound field is served with its list's options resolved // onto it (`PicklistServedFieldSchema`), and INHERITS the list's option From 3ad2f22bed534424e1af81d7d96c38eab84afd0d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 05:24:58 +0000 Subject: [PATCH 2/2] chore(changeset): spec patch for the served field description translation Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- .../21948-spec-field-help-served-on-description.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/21948-spec-field-help-served-on-description.md diff --git a/.changeset/21948-spec-field-help-served-on-description.md b/.changeset/21948-spec-field-help-served-on-description.md new file mode 100644 index 00000000000..7a294fa74df --- /dev/null +++ b/.changeset/21948-spec-field-help-served-on-description.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': patch +--- + +A served object field no longer carries an undeclared `help` key. `translateObject` now serves a field's translated help on the field's `description` (#21948). + +Clause-②: no + +- **What moved.** A bundle's `objects..fields..help` entry is the translation of the field's `description`, because the i18n extractor writes it from that key. `translateObject` (and so `GET /api/v1/meta/object/:name` in a non-English locale) used to put it on a `help` key that `FieldSchema` does not declare. The served field then failed `FieldSchema` with `unrecognized_keys`. A consumer that reads only declared keys rendered the English `description`, and the console logged one ingestion warning per such field. The translation is now served on `description`, and the served field carries no `help`. +- **Precedence (ADR-0029 D9.2a).** The catalog applies only while the served field's `description` still equals the packaged field's. This is judged by the same comparison the object scalars, views and dashboards use. A description that diverged (an `objectExtensions` field, or a tenant's own edit) keeps its authored value in every locale. With no packaged base supplied, the catalog applies, as before. A field's `label` is unchanged and stays a flat `catalog ?? document`. +- **`ObjectFieldLike`** (`@objectstack/spec/system`) drops its `help?: string` member. Its `[key: string]: any` index signature still accepts and types a `help` key, so a caller that writes or reads one still compiles. `inlineHelpText` is not touched. +- Readers that fall back from `help` to `description` (`field.help || field.description`) render the same translated text as before. +- ⛔ No schema, parse or export change. The translation bundle's own field `help` key is unchanged.