Skip to content

Commit bab7685

Browse files
fix(spec): serve a field's translated help on description, never on an undeclared help (#21956)
Fixes #21948 Clause-②: no ## What changed `translateObject` used to put a bundle's `objects.OBJECT.fields.FIELD.help` entry 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. - **Target key, measured.** The extractor writes that bundle entry from `field.help ?? field.description` (`packages/cli/src/utils/i18n-extract.ts:1223`). `FieldSchema` refuses `help`, so on a spec-valid field the only source is `description`. The nine bundle source trees author 0 `inlineHelpText` and 0 field `help`. The nine `en` bundles carry 523 `help` entries. - **`translateField`** (`packages/spec/src/system/i18n-resolver.ts:3054`) now serves the translated help on `description` and writes no `help` key. `inlineHelpText` is not touched. - **Precedence: ADR-0029 D9.2a**, the resolver's own rule, applied through the existing `valueOverridesPackagedBase` predicate. There is no second comparison. The catalog applies only while the served field's `description` equals the packaged field's. - No packaged base supplied: the catalog applies (the conservative edge #8284 established). - A field the base does not declare counts as diverged, which is `packagedPart`'s convention. The new finder `packagedObjectField` (`:1434`) reads both field-map shapes. - A field's `label` is unchanged and stays a flat `catalog ?? document`, as its existing pin scopes it. - **`ObjectFieldLike`** (`:2453`) drops its `help?: string` member. This is safe: - its `[key: string]: any` index signature still accepts and types a `help` key, so a caller that writes or reads one still compiles (probe below); - `packages/spec/api-surface/system.json` records only `ObjectFieldLike (interface)`, not its members, and `check:api-surface` stays green; - the one in-repo importer (`service-analytics`, which reads `options` only) still typechecks. - The `translateObject` docblock no longer says it translates a field's `help`. A new section states the target key and the D9.2a precedence. - `.changeset/21948-spec-field-help-served-on-description.md`: `@objectstack/spec` `patch`. ## Clause-② — measured `no` - No Zod schema, parse or export changed. These all stay green: `check:authorable-surface`, `check:api-surface`, `check:export-origins`, and `check:generated` (15 artifacts up to date). - **Type probe** (scratch, not committed), compiled against the rebuilt `dist/system/index.d.ts`, exit 0: - `const legacy: ObjectFieldLike = { name: 'x', help: 'legacy' }` compiles, and so does reading `legacy.help`; - a declared member still refuses a wrong value (an `@ts-expect-error` on `label: 42` is consumed); - `const proof: number = ({} as ObjectFieldLike).help` compiles, which shows the probe read the rebuilt declaration. - **Control leg:** the same probe against a copy of that `.d.ts` with BASE's `help?: string` put back gives exit 2, with exactly one error: TS2322 on the `proof` line. Writing and reading `help` compile under both declarations. - So no value the type or the schemas used to accept is refused now. The one type-level delta is that `ObjectFieldLike['help']` is now read through the index signature (`any`) instead of `string | undefined`. That is stated here for the at-tier review. ## Measured All at HEAD `3ad2f22bed` unless stated. **Corpus at the resolver seam (scratch script, not committed).** Every object in `packages/platform-objects/scripts/i18n-extract.config.ts` (48 objects, 617 fields), with that package's real bundles, using each object as its own packaged base: | | served `help` keys | `help` refused by `FieldSchema` | `sys_user` fields with `help` | zh-CN `description` equal to the bundle entry | |---|---|---|---|---| | BASE behaviour (ablation leg 1 below) | 332 | 332 | 18 of 26 | 0 | | HEAD | 0 | 0 | 0 of 26 | 332 | - At HEAD in zh-CN, `sys_user.two_factor_enabled.description` = `该用户是否已启用双因素认证。由 better-auth 的 \`twoFactor\` 插件维护。`. - In `en`, 0 descriptions change from source, because the en entries repeat the source. - **NOT MEASURED: the HTTP door** (`GET /api/v1/meta/object/sys_user`). The spec change invalidates the showcase build closure: 62 of 63 turbo tasks miss the cache. The pins and the corpus run read the same `translateMetadataDocument` dispatch the REST read calls. **Pins** (`packages/spec/src/system/i18n-resolver.test.ts:3552`, 9 cases): - a zh-CN served field carries no `help`, has the translation on `description`, and parses with no `unrecognized_keys`; - the untranslated control is unchanged; - a field with both `description` and `inlineHelpText` gets the translation on `description`, and `inlineHelpText` is untouched; - a diverged `description` is kept in zh-CN and en, through the type dispatch, while the undiverged sibling is translated; - a field the base does not declare counts as diverged; - with no packaged base (`undefined`, `null`, or omitted) the catalog applies; - an absent `description` is filled by the catalog; - array-shaped fields are judged against an array-shaped base; - the input is not mutated. No existing pin asserted a served field `help`. I searched every test outside the bundle suites, so none had to move. **Ablation**, run on the committed head. Each leg used `scripts/ablation-replace.mjs` in WRAP mode plus a trap. Predictions were written down before the run. Both legs were restored, with the restored blob equal to HEAD (`a8eade91`) and `git diff HEAD` empty: | leg | mutation (anchor 1 → 0 on disk) | predicted | observed | |---|---|---|---| | 1 | restore BASE's two lines (write `next.help`, never `description`) | 7 failed / 2 passed (control and no-mutation stay green) | `Tests 7 failed \| 2 passed` | | 2 | flat overlay (the D9.2a comparison replaced by `true`) | 3 failed: the diverged, undeclared-field and array cases | `Tests 3 failed \| 6 passed` | - Leg 1 sample: `expected 'Whether two-factor authentication is …' to be '该用户是否已启用双因素认证。…'`. - Leg 2 sample: `expected '该用户是否已启用双因素认证。…' to be 'Edited by the tenant.'`. **Tests** - `@objectstack/spec`, full local project: `Test Files 619 passed`, `Tests 18480 passed | 1 todo`. - `pnpm --filter @objectstack/spec typecheck`: exit 0. Its test layer compiles under `tsconfig.test.json` with the identity-pinned debt held. - `@objectstack/service-analytics`: `typecheck` exit 0. The three dimension/label suites that call `translateObject`: `Tests 53 passed`. - Targeted eslint over the two changed `.ts` files: 0 errors and 0 warnings. That run is not the repo-wide lint, which is CI's. **Gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran` derived 85 families: 84 ran with exit 0 and 1 is NOT MEASURED. - The NOT MEASURED one is `pnpm check:dual-build-cjs-loads`, which exits 3 with `PREREQUISITE NOT MET` because 78 packages have no `dist/`. - Narrowed in its place: all 19 `require` entries of `@objectstack/spec`'s `exports` load from the rebuilt `dist`, and `system.translateObject` is a function. - `pnpm check:lean-entry-closure` first answered exit 3 (`objectql/core` unbuilt). After `turbo run build --filter=@objectstack/objectql...` it answered exit 0, and that is the recorded code. **Ships:** `npm pack --dry-run` of `@objectstack/spec` lists `dist/system/index.js`, `.mjs` and `.d.ts`. Both runtime files carry `packagedObjectField` and no `next.help = translatedHelp`. **Base:** origin/main moved to `a3bd157730` (#21947, test titles under `packages/spec/src/ui/` only). None of this PR's files is touched, so it is not merged. ## Known readers in objectui objectui at `.objectui-sha` `0abd4f9f`, read-only. No objectui file is edited here. - `packages/plugin-form/src/ObjectForm.tsx:1033` and `packages/plugin-form/src/sectionFields.ts:311` (`field.help || field.description`), and `packages/app-shell/src/utils/resolveActionParams.ts:714` (`param.helpText ?? field.help ?? field.description`). - All three fall back to `description`, so they render the same translated text, and their `help` arm is now dead. Retiring those arms is objectui's job. - The ingestion warning is `packages/core/src/utils/reference-keys.ts:362`, reached through the `no-declared-twin` arm (`:415`) of `canonicalizeRetiredFieldKeys`. It fires for any undeclared key on a served field def, so it goes quiet once no `help` is served. ## Acceptance notes None of these is addressed in this PR. 1. **Studio `saveFields` write-back.** NOT MEASURED; this is a read-only inference. - objectui `packages/app-shell/src/services/MetadataService.ts:898` to `:939` carries per-field server keys from the translated read back into the object PUT. - `help` is not in objectui's `RETIRED_FIELD_KEYS` (`packages/types/src/internal/retired-field-keys.ts`). So a field save on a platform object with bundle `help` entries should have sent a key `FieldSchema` refuses. - This PR removes the source. - Carrier: this PR. Reach would need one measured PUT. 2. **The extractor's `field.help ??` arm** (`packages/cli/src/utils/i18n-extract.ts:1223`) reads a key `FieldSchema` refuses, so it is dead on every spec-valid field. Carrier: `domain:cli`; none in flight. 3. **Field `placeholder` is never overlaid.** `FieldTranslationSchema` declares `placeholder`, and the extractor emits `objects.OBJECT.fields.FIELD.placeholder` (`:1224`), but `translateField` never overlays it. The nine shipped bundles carry 0 field-level placeholder entries (the 4 per locale are action params), so it is dormant. Carrier: none. 4. **`inlineHelpText` has no translation path.** The extractor never reads it, so an authored one is never offered for translation. The nine bundle sources author 0 of them. Carrier: none. 5. **Docs line.** `content/docs/protocol/kernel/i18n-standard.mdx:166` lists a field's `label` / `help` / `placeholder` as display labels, but `FieldSchema` declares no `help`. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 80f9f7e commit bab7685

3 files changed

Lines changed: 257 additions & 11 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
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).
6+
7+
Clause-②: no
8+
9+
- **What moved.** A bundle's `objects.<object>.fields.<field>.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`.
10+
- **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`.
11+
- **`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.
12+
- Readers that fall back from `help` to `description` (`field.help || field.description`) render the same translated text as before.
13+
- ⛔ No schema, parse or export change. The translation bundle's own field `help` key is unchanged.

‎packages/spec/src/system/i18n-resolver.test.ts‎

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3548,6 +3548,175 @@ describe('translateObject — catalog vs explicit override (#8284)', () => {
35483548
});
35493549
});
35503550

3551+
// ────────────────────────────────────────────────────────────────────────────
3552+
// translateObject — a field's translated help is served on `description` (#21948)
3553+
// ────────────────────────────────────────────────────────────────────────────
3554+
3555+
import { FieldSchema } from '../data/field.zod';
3556+
3557+
/**
3558+
* #21948 — the bundle's `objects.<object>.fields.<field>.help` entry is the
3559+
* translation of the field's `description` (the i18n extractor writes it from
3560+
* that key), and it used to be served on a `help` key `FieldSchema` does not
3561+
* declare. Every consumer that reads only declared keys rendered the English
3562+
* `description`, and the console warned once per such field on every load.
3563+
*
3564+
* The entry is served on `description` now, under ADR-0029 D9.2a: the catalog
3565+
* applies only while the served `description` still equals the packaged
3566+
* field's (`valueOverridesPackagedBase`), and with no packaged base it applies.
3567+
*/
3568+
describe('translateObject — a field\'s translated help is served on `description`', () => {
3569+
const TWO_FACTOR_EN =
3570+
'Whether two-factor authentication is enabled for this user. Maintained by the better-auth `twoFactor` plugin.';
3571+
const TWO_FACTOR_ZH = '该用户是否已启用双因素认证。由 better-auth 的 `twoFactor` 插件维护。';
3572+
3573+
/** The packaged declaration, as the owner contributor holds it. */
3574+
const PACKAGED = {
3575+
name: 'sys_user',
3576+
label: 'User',
3577+
fields: {
3578+
two_factor_enabled: {
3579+
name: 'two_factor_enabled',
3580+
type: 'boolean',
3581+
label: 'Two-Factor Enabled',
3582+
description: TWO_FACTOR_EN,
3583+
},
3584+
phone: {
3585+
name: 'phone',
3586+
type: 'text',
3587+
label: 'Phone',
3588+
description: 'Contact number.',
3589+
inlineHelpText: 'Include the country code.',
3590+
},
3591+
nickname: { name: 'nickname', type: 'text', label: 'Nickname' },
3592+
},
3593+
};
3594+
3595+
/**
3596+
* What the extractor writes: `en` repeats the packaged `description` under
3597+
* `help`, `zh-CN` is its translation. `nickname` has no entry at all.
3598+
*/
3599+
const BUNDLE: TranslationBundle = {
3600+
en: {
3601+
objects: {
3602+
sys_user: {
3603+
fields: {
3604+
two_factor_enabled: { label: 'Two-Factor Enabled', help: TWO_FACTOR_EN },
3605+
phone: { label: 'Phone', help: 'Contact number.' },
3606+
},
3607+
},
3608+
},
3609+
} as any,
3610+
'zh-CN': {
3611+
objects: {
3612+
sys_user: {
3613+
fields: {
3614+
two_factor_enabled: { label: '已启用双因素认证', help: TWO_FACTOR_ZH },
3615+
phone: { label: '电话', help: '联系电话。' },
3616+
},
3617+
},
3618+
},
3619+
} as any,
3620+
};
3621+
3622+
const clone = <V>(value: V): V => JSON.parse(JSON.stringify(value));
3623+
const fieldsOf = (out: unknown) => (out as { fields: Record<string, Record<string, unknown>> }).fields;
3624+
const unrecognizedKeys = (def: unknown) => {
3625+
const parsed = FieldSchema.safeParse(def);
3626+
return parsed.success ? [] : parsed.error.issues.filter((issue) => issue.code === 'unrecognized_keys');
3627+
};
3628+
3629+
it('serves the zh-CN translation on `description`, carries no `help` key, and parses with no unrecognized key', () => {
3630+
const field = fieldsOf(translateObject(clone(PACKAGED), BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED }))
3631+
.two_factor_enabled;
3632+
expect(field.description).toBe(TWO_FACTOR_ZH);
3633+
expect('help' in field).toBe(false);
3634+
expect(field.label).toBe('已启用双因素认证');
3635+
expect(unrecognizedKeys(field)).toEqual([]);
3636+
});
3637+
3638+
it('leaves a field with no bundle entry exactly as it came — the control', () => {
3639+
const doc = clone(PACKAGED);
3640+
const field = fieldsOf(translateObject(doc, BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED })).nickname;
3641+
expect(field).toEqual(PACKAGED.fields.nickname);
3642+
expect('description' in field).toBe(false);
3643+
expect('help' in field).toBe(false);
3644+
});
3645+
3646+
it('a field declaring both `description` and `inlineHelpText` gets the translation on `description`; `inlineHelpText` is untouched', () => {
3647+
const field = fieldsOf(translateObject(clone(PACKAGED), BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED })).phone;
3648+
expect(field.description).toBe('联系电话。');
3649+
expect(field.inlineHelpText).toBe('Include the country code.');
3650+
expect('help' in field).toBe(false);
3651+
expect(unrecognizedKeys(field)).toEqual([]);
3652+
});
3653+
3654+
it('keeps a `description` that diverged from the packaged field, in every locale, through the type dispatch', () => {
3655+
// The REST boundary reaches `translateObject` through the dispatcher, so the
3656+
// packaged base has to arrive there for the comparison to be made at all.
3657+
const served = clone(PACKAGED);
3658+
served.fields.two_factor_enabled.description = 'Edited by the tenant.';
3659+
for (const locale of ['zh-CN', 'en']) {
3660+
const fields = fieldsOf(translateMetadataDocument('object', clone(served), BUNDLE, { locale, packagedBase: PACKAGED }));
3661+
expect(fields.two_factor_enabled.description).toBe('Edited by the tenant.');
3662+
expect('help' in fields.two_factor_enabled).toBe(false);
3663+
}
3664+
// Judged per field: the undiverged sibling is still translated…
3665+
const zh = fieldsOf(translateMetadataDocument('object', clone(served), BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED }));
3666+
expect(zh.phone.description).toBe('联系电话。');
3667+
// …and the field `label` stays a flat `catalog ?? document`, as before.
3668+
expect(zh.two_factor_enabled.label).toBe('已启用双因素认证');
3669+
});
3670+
3671+
it('a field the packaged base does not declare counts as diverged', () => {
3672+
// An `objectExtensions` field is folded on after the owner's declaration,
3673+
// which is all the base carries.
3674+
const served = clone(PACKAGED) as any;
3675+
served.fields.extra = { name: 'extra', type: 'text', label: 'Extra', description: 'Added by an extension.' };
3676+
const bundle = clone(BUNDLE) as any;
3677+
bundle['zh-CN'].objects.sys_user.fields.extra = { help: '扩展添加。' };
3678+
const field = fieldsOf(translateObject(served, bundle, { locale: 'zh-CN', packagedBase: PACKAGED })).extra;
3679+
expect(field.description).toBe('Added by an extension.');
3680+
expect('help' in field).toBe(false);
3681+
});
3682+
3683+
it('with no packaged base, the catalog applies — "unknown" is not "authored"', () => {
3684+
const served = clone(PACKAGED);
3685+
served.fields.two_factor_enabled.description = 'Edited by the tenant.';
3686+
for (const packagedBase of [undefined, null]) {
3687+
const field = fieldsOf(translateObject(clone(served), BUNDLE, { locale: 'zh-CN', packagedBase })).two_factor_enabled;
3688+
expect(field.description).toBe(TWO_FACTOR_ZH);
3689+
expect('help' in field).toBe(false);
3690+
}
3691+
expect(fieldsOf(translateObject(clone(served), BUNDLE, { locale: 'zh-CN' })).two_factor_enabled.description)
3692+
.toBe(TWO_FACTOR_ZH);
3693+
});
3694+
3695+
it('an absent served `description` is not an override — the catalog fills it', () => {
3696+
const served = clone(PACKAGED) as any;
3697+
delete served.fields.two_factor_enabled.description;
3698+
const field = fieldsOf(translateObject(served, BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED })).two_factor_enabled;
3699+
expect(field.description).toBe(TWO_FACTOR_ZH);
3700+
});
3701+
3702+
it('judges an array-shaped field list against an array-shaped base by field name', () => {
3703+
const asArray = (doc: typeof PACKAGED) => ({ ...doc, fields: Object.values(doc.fields) });
3704+
const served = clone(PACKAGED);
3705+
served.fields.two_factor_enabled.description = 'Edited by the tenant.';
3706+
const out = translateObject(asArray(served) as any, BUNDLE, { locale: 'zh-CN', packagedBase: asArray(PACKAGED) });
3707+
const byName = Object.fromEntries((out.fields as any[]).map((f) => [f.name, f]));
3708+
expect(byName.two_factor_enabled.description).toBe('Edited by the tenant.');
3709+
expect(byName.phone.description).toBe('联系电话。');
3710+
expect((out.fields as any[]).some((f) => 'help' in f)).toBe(false);
3711+
});
3712+
3713+
it('does not mutate the input document', () => {
3714+
const doc = clone(PACKAGED);
3715+
translateObject(doc, BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED });
3716+
expect(doc).toEqual(PACKAGED);
3717+
});
3718+
});
3719+
35513720
// ════════════════════════════════════════════════════════════════════════════
35523721
// Screen-flow copy resolvers (#7646 / #11287)
35533722
// ════════════════════════════════════════════════════════════════════════════

‎packages/spec/src/system/i18n-resolver.ts‎

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1419,6 +1419,36 @@ function packagedPart(
14191419
return {};
14201420
}
14211421

1422+
/**
1423+
* The packaged counterpart of one served FIELD of an object, with the same
1424+
* three answers as {@link packagedPart}: `undefined` when no packaged base was
1425+
* supplied, `{}` when the base is known but declares no such field (a field
1426+
* authored after the fact — an `objectExtensions` field is folded on after the
1427+
* owner's declaration the base carries), and the packaged field otherwise.
1428+
*
1429+
* A separate finder only because a field map is a record keyed by field name
1430+
* (the canonical shape) where {@link packagedPart} walks an array; the array
1431+
* shape some reads flatten to is matched by `name`, as `translateObject`
1432+
* itself accepts both.
1433+
*/
1434+
function packagedObjectField(
1435+
packagedObject: Record<string, unknown> | undefined,
1436+
fieldName: string,
1437+
): Record<string, unknown> | undefined {
1438+
if (packagedObject === undefined) return undefined;
1439+
const fields = packagedObject.fields;
1440+
if (Array.isArray(fields)) {
1441+
for (const candidate of fields) {
1442+
const record = asRecord(candidate);
1443+
if (record !== undefined && record.name === fieldName) return record;
1444+
}
1445+
return {};
1446+
}
1447+
const map = asRecord(fields);
1448+
if (map === undefined || !Object.prototype.hasOwnProperty.call(map, fieldName)) return {};
1449+
return asRecord(map[fieldName]) ?? {};
1450+
}
1451+
14221452
/**
14231453
* Overlay `dashboards.<name>.globalFilters.<key>.{label,options.<value>}`
14241454
* onto one authored filter (#16772). Returns the input object itself when
@@ -2414,10 +2444,15 @@ export interface ObjectLike {
24142444
listViews?: Record<string, unknown>;
24152445
}
24162446

2447+
/**
2448+
* Minimal field shape consumed by `translateObject`. It names only keys
2449+
* `FieldSchema` declares: there is no `help` here, because a field spells its
2450+
* help text `description` / `inlineHelpText`. The bundle's field `help` entry
2451+
* is served on `description` (see {@link translateObject}).
2452+
*/
24172453
export interface ObjectFieldLike {
24182454
name?: string;
24192455
label?: string;
2420-
help?: string;
24212456
description?: string;
24222457
options?: Array<{ label?: string; value: string | number | boolean }>;
24232458
[key: string]: any;
@@ -2906,7 +2941,8 @@ function translateObjectListViews(
29062941
/**
29072942
* Apply the active locale to an object metadata document. Translates the
29082943
* object's `label` / `pluralLabel` / `description`, walks each field to
2909-
* translate its `label`, `help`, and per-option `label`s, walks any
2944+
* translate its `label`, its `description` (from the bundle's field `help`
2945+
* entry — see below), and per-option `label`s, walks any
29102946
* inline-declared `actions` through {@link translateAction}, and overlays the
29112947
* copy of each EMBEDDED `listViews` entry from the `_views` keys the i18n
29122948
* extractor writes for it ({@link translateObjectListViews}). The input document
@@ -2949,13 +2985,35 @@ function translateObjectListViews(
29492985
* conservative edges. `?layers=true` stays untranslated and diagnostic,
29502986
* unchanged.
29512987
*
2952-
* The rule is scoped to the three SCALARS, which are what the ruling covers:
2953-
* `fields` is a key-keyed spread whose per-field labels have their own
2954-
* (per-field) catalog keys, and no read has been measured to diverge on them.
2955-
* The embedded `listViews` follow the VIEW translator's application of the
2956-
* same rule (ADR-0029 D9.2a, as {@link translateView} applies it), judged per
2957-
* view against the packaged object's own `listViews` — see
2958-
* {@link translateObjectListViews}.
2988+
* The ruling covers the three SCALARS. A field's `label` stays a flat
2989+
* `catalog ?? document`: `fields` is a key-keyed spread whose per-field labels
2990+
* have their own (per-field) catalog keys, and no read has been measured to
2991+
* diverge on them. The embedded `listViews` follow the VIEW translator's
2992+
* application of the same rule (ADR-0029 D9.2a, as {@link translateView}
2993+
* applies it), judged per view against the packaged object's own `listViews` —
2994+
* see {@link translateObjectListViews}.
2995+
*
2996+
* ## A field's translated help is served on `description` — ADR-0029 D9.2a
2997+
*
2998+
* The bundle addresses a field's help as `objects.<object>.fields.<field>.help`
2999+
* (`FieldTranslationSchema.help`), and the i18n extractor writes that entry
3000+
* from the field's `description` (`packages/cli/src/utils/i18n-extract.ts`;
3001+
* its other source, a field `help`, is a key `FieldSchema` refuses). So the
3002+
* entry is the translation of `description`, and it is served THERE. It is
3003+
* ⛔ never served on a `help` key, which `FieldSchema` does not declare: the
3004+
* served field def then carried a key the contract refuses, every consumer
3005+
* that reads only declared keys rendered the source-language `description`,
3006+
* and the console's ingestion step warned once per such field (#21948). It is
3007+
* ⛔ not served on `inlineHelpText` either, which the extractor never reads.
3008+
*
3009+
* `description` is a key the catalog had never touched, so it follows the
3010+
* rule above, as every string this translator added since does: the catalog
3011+
* applies only while the served field's `description` still equals the same
3012+
* field's in {@link TranslateDocumentOptions.packagedBase}, judged by
3013+
* {@link valueOverridesPackagedBase} (⛔ never a second comparison). A field
3014+
* the base does not declare was authored after the fact, and its description
3015+
* counts as diverged; no base supplied means nothing is inferred and the
3016+
* catalog applies.
29593017
*/
29603018
export function translateObject<T extends ObjectLike>(
29613019
doc: T,
@@ -2982,15 +3040,21 @@ export function translateObject<T extends ObjectLike>(
29823040
const label = resolveScalar('label', doc.label);
29833041
const pluralLabel = resolveScalar('pluralLabel', doc.pluralLabel);
29843042
const description = resolveScalar('description', doc.description);
3043+
const packagedObject = asRecord(opts?.packagedBase);
29853044

29863045
const translateField = (name: string, def: ObjectFieldLike): ObjectFieldLike => {
29873046
const next: ObjectFieldLike = { ...def };
29883047
const translatedLabel =
29893048
lookupObjectFieldAttr(bundle, objectName, name, 'label', opts) ??
29903049
builtinSystemFieldLabel(name, def.label, opts);
29913050
if (translatedLabel) next.label = translatedLabel;
2992-
const translatedHelp = lookupObjectFieldAttr(bundle, objectName, name, 'help', opts);
2993-
if (translatedHelp) next.help = translatedHelp;
3051+
// The bundle's field `help` entry translates `description`, and is served
3052+
// there — never on an undeclared `help` — unless the served description
3053+
// diverged from the packaged field's (ADR-0029 D9.2a; see the docblock).
3054+
if (!valueOverridesPackagedBase(packagedObjectField(packagedObject, name), 'description', def.description)) {
3055+
const translatedDescription = lookupObjectFieldAttr(bundle, objectName, name, 'help', opts);
3056+
if (translatedDescription) next.description = translatedDescription;
3057+
}
29943058
if (Array.isArray(def.options)) {
29953059
// A picklist-bound field is served with its list's options resolved
29963060
// onto it (`PicklistServedFieldSchema`), and INHERITS the list's option

0 commit comments

Comments
 (0)