Skip to content

Commit 3fb74b6

Browse files
feat(spec)!: object extension fieldPatches refuse a field the extension replaces, key option patches by value, refuse __proto__, carry display renames (#29331)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9LXi8jnV2NzztvoFy8gqAqx)_ Fixes #28655 Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: updated here — `content/docs/data-modeling/object-extensions.mdx` gains the "Patch it or take it over, not both" bullet (the replaced-field refusal and the option-key grammar). `skills/objectstack-data/SKILL.md` already teaches "Relabel a field the owner declares with `fieldPatches` — never by redeclaring it in `fields`" and its example keys the option by `technology`, so every taught line stays true and no skill line changes. ## 维护者速读 - 改了什么:对象扩展的 `fieldPatches` 多拒三种输入——同一扩展既在 `fields` 里整字段替换又在 `fieldPatches` 里打补丁的字段、不可能是选项值的选项补丁键(如 `Tech Sector`)、以及 `__proto__` 键;补丁上写 `helpText`/`title` 这类别名时给出改名提示;描述里的白名单成员改为从常量生成。 - 为什么改:`fieldPatches` 现在已被合并真实应用,原来这些写法"能保存、不生效":补丁落在扩展自己的替换字段上,或永远匹配不到选项,`__proto__` 键在保存门被存下却在注册时丢失。 - 风险与回滚:收窄(BREAKING,spec minor)。仓内 examples/skills/docs 无一处命中被拒写法;回滚即 revert 本 PR。HotCRM 仓不在本容器,未测。 - 先看:`packages/spec/src/data/object.zod.ts`(`refuseFieldPatchOfReplacedField`、`options` 的键 schema、`whitelistedAliases`)、`packages/spec/src/data/field-key-aliases.ts`(别名表搬出的唯一副本)、`packages/spec/src/data/field-customization.test.ts` 新增一节。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/data/field-customization.test.ts src/data/field-patches-derived.test.ts`,预期 2 个文件全绿(91 条)。 - 需要你做的:无,席位落地。 ## What changes All in `ObjectExtensionSchema` (`packages/spec/src/data/object.zod.ts`), the four should-fixes A–D of the contract review on PR #28650: - **A.** The root `superRefine` refuses a field name present in both `fields` and `fieldPatches` of one extension (`refuseFieldPatchOfReplacedField`): `custom` at `fieldPatches.NAME`, prescribing "patch the vendor's field or declare your own, not both". A `__c` name in both is left to the existing `__c` rule, so it is refused once. - **B.** A field patch's `options` map is keyed by `SelectOptionSchema.shape.value` (read off the shape, not restated), so `Tech Sector` is refused with `invalid_key` at `fieldPatches.industry.options.Tech Sector`. `refuseRecordProtoKey` now guards `fields`, `fieldPatches` and the `options` map: the measurement holds at the door (below), not only at schema level. - **C.** The field patch and the option patch carry the rows of their source shape's rename table whose target the whitelist admits, by filter (`whitelistedAliases`). Field: `title`, `displayName` onto `label`; `help`, `helpText`, `hint`, `tooltip` onto `inlineHelpText`. Option: `text`, `name`, `title` onto `label`. Carriage follows the `ui/action-target-aliases.ts` precedent: the two tables moved out of `field.zod.ts` into a relative-import-only leaf, `packages/spec/src/data/field-key-aliases.ts`, which `FieldSchema`, `SelectOptionSchema` and the patches all read. No second mechanism and no new public export (`check:api-surface` unchanged). - **D.** The `fieldPatches` `.describe()` lists its members from `BASE_FIELD_DISPLAY_WHITELIST` (`fieldPatchesDescription`). Generated and ledger files that ride along: - `content/docs/references/data/object.mdx` (regenerated description); - `packages/spec/dropped-refinements.baseline.json`: the five `data/ObjectExtension` `fields.valueType…` sites moved to `fields.out.valueType…`, because the `__proto__` guard wraps the record in a preprocess. The same sites, one level deeper, and no new site; - `content/docs/data-modeling/object-extensions.mdx` gains one bullet; - `.changeset/28655-field-patches-refuse-replaced-field.md`. Forced test edits: none. The new pins are added to `field-customization.test.ts` and `field-patches-derived.test.ts`. No existing assertion changed. ## Why The card's "reach: none yet" premise has expired: `mergeObjectDefinitions` (`packages/objectql/src/registry.ts`) applies `fieldPatches` on main (#28565). Each input A and B refuse parsed and then did nothing visible. A patch landed on the extension's own whole-field replacement, and an option key no value can be never matched an option. `__proto__` reach, measured at the door on main `47ee57fe` before the change. This is the `/api/v1/meta` `object_extension` save door's own schema (`OBJECT_EXTENSION_ITEM_SCHEMA`) and its registration seam (`registrableObjectExtension`), over a JSON-parsed body: - the door schema answered `success: true`, with `fields: {}` and `fieldPatches` missing the key; - the registered body was missing it too; - the stored (verbatim) body kept `fieldPatches.__proto__` and `fields.__proto__`. The stored row and the served extension disagree. ## Tests The new pins, each giving zod code, path and named subject beside a control through the same parse: - A: refusal at `fieldPatches.industry`, with a control (a patch beside a `fields` entry of another name parses), and the `__c` case refused once. - B: `invalid_key` at the option key, with a control (the key verdict equals `SelectOptionSchema.shape.value.safeParse` for four keys). There are three `__proto__` cases (`fields`, `fieldPatches`, `options`) over JSON documents, plus a control. - C: four field renames and three option renames, with a control (a rename whose target the patch refuses carries no arrow, and `colour` carries none under the shipped whitelist). The derived proof is in `field-patches-derived.test.ts`: once the mocked whitelist admits `color`, the option's `colour` rename reaches the patch. - D: every whitelist member appears in the description, and the widened members appear under the mock. Ablation, in a side worktree at `5f8d7494`, through `scripts/ablation-replace.mjs` (each anchor 1 → 0, every leg restored to blob `67228dde3fe9`, `git diff HEAD` empty). Spec tests import `src`, so no build is in the path: | leg | mutation | red | |:--|:--|:--| | A | drop the `refuseFieldPatchOfReplacedField` call | 1 (the A refusal) | | B key | `z.string()` key instead of `optionShape.value` | 2 (refusal + grammar control) | | B proto | drop the `fieldPatches` guard | 1 (the `fieldPatches` `__proto__` case) | | C field | drop the field patch `aliases` | 4 (the four field renames) | | C option | drop the option patch `aliases` | 3 (the three option renames) | | D | constant description | 3 (both description pins + the existing liveness pin) | ## Acceptance notes - The card listed the field renames as `title`, `help`, `helpText`, `hint`, `tooltip`. The filter also yields `displayName` onto `label`, which `FieldSchema`'s table carries. It is pinned. - The `options` map's `__proto__` guard goes beyond the card's "`fieldPatches` and `fields`". It is the same defect class and the same mechanism, in the same schema, inside the same gate family. Without it B's key grammar could not see that key, because zod skips `__proto__` before the key schema. - The claim's file surface names `object.zod.ts`. C also touches `field.zod.ts` (the two tables now read from the leaf, byte-identical rows) and adds `field-key-aliases.ts`. This is declared as a deviation for the seat to amend. - HotCRM fixtures: that repository is not reachable from this container, so its count of A/B hits is NOT MEASURED. In this repository, examples/, skills/ and content/docs/ carry zero refused forms. The only authored `fieldPatches` (the docs page and the skill) key options by `technology`. - `refuseUnlessPlatformWritten` and `mergeObjectDefinitions` are untouched (#28205 follows in the same file). ## Verification <details><summary>Consumer packages (downstream of spec), at 5f8d749</summary> - `@objectstack/spec` test: Test Files 790 passed, Tests 23390 passed, 1 todo. typecheck: exit 0. - `@objectstack/objectql` test: 527 files, 10140 tests passed. - `@objectstack/metadata-protocol` test: 300 passed, 4 skipped files; 22100 tests passed. - `@objectstack/metadata-core` test: 22 files, 885 tests passed. - `@objectstack/lint` test: 189 files, 7830 tests passed. - `@objectstack/cloud-connection` test: 67 files, 813 tests passed. - `@objectstack/rest` test: 367 files, 6963 passed, 332 skipped. - `@objectstack/cli` `--project unit`: 370 files passed. Two files reported "cli is not built" (`published-subpath-*.pin.test.ts`), a prerequisite, rerun after the full build: 2 files, 29 tests passed. `--project integration test/validate-field-patch-targets.test.ts`: 2/2 passed. </details> <details><summary>Gates and dogfood</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `5f8d7494`: 103 derived commands, each run with stdin closed. 102 exited 0 on the first pass. `check-plugin-teardown-shape --self-test` exited 3 (PREREQUISITE NOT MET: its pinned fixture commit is outside the shallow clone); after `git fetch --depth=1 origin 621a487…` it exited 0 (48 cases). `--ran` reconciliation: "103 derived, 103 run, 0 NOT-MEASURED, 0 UNRUN" (a DERIVED zero). Offline before the PR: `check-changeset-no-major --event` on this body's two lines: LEVEL AXIS accepted (`no (narrowing)` + `narrowed`, spec `minor`); `check:adr-0087-registration`: 1 declared-breaking changeset with a disposition. eslint on the five changed `.ts` files: exit 0. `pnpm --filter @objectstack/spec check:generated`: 1 stale (`check:docs`), fixed with `--fix` and re-checked green; the other 13 were up to date. Dogfood (title carries `!` and the `(narrowing)` arm) at `5f8d7494`, after `turbo run build --filter=./packages/**` (78/78): `os-verify-lock: VERDICT command-exit 0` · held 1829 s · `pnpm --filter @objectstack/dogfood test`: Test Files 389 passed, 3 skipped (392); Tests 3215 passed, 28 skipped (3243). </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmT6ynacYWXqz9Yredm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 838a802 commit 3fb74b6

9 files changed

Lines changed: 417 additions & 106 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: an object extension's `fieldPatches` refuses a field the same extension replaces in `fields`, keys option patches by the option-value grammar, refuses `__proto__`, and carries the field's and option's display renames
6+
7+
`ObjectExtensionSchema.fieldPatches` (ADR-0146 D10) is applied by the registry merge, so what it accepts now changes what every read serves. Three things it accepted are refused, each located at its key:
8+
9+
- a field named in both `fields` and `fieldPatches` of one extension (`fieldPatches.industry`): `fields` takes the vendor's field over whole, so the patch landed on the extension's own replacement and the author got neither answer;
10+
- an option-patch key no option `value` can be (`fieldPatches.industry.options.Tech Sector`): the map is now keyed by `SelectOptionSchema.shape.value` itself, so the patch can address only an option that can exist;
11+
- a `__proto__` own key in `fields`, `fieldPatches` or an option-patch map, which zod's record parser dropped while reporting success. Measured at the `/api/v1/meta` save door: the body parsed, the stored row kept the key, and the registered extension did not.
12+
13+
A field patch that spells a key the way the field's own rename table knows (`helpText`, `help`, `hint` or `tooltip` for `inlineHelpText`; `title` or `displayName` for `label`), and an option patch that spells one the option's table knows (`text`, `name` or `title` for `label`), now gets the rename hint instead of a bare unknown-key refusal. The rows are taken by filter from the one table each source shape reads, so a rename onto a whitelisted key reaches the patch the day it lands. The `fieldPatches` description lists its members from `BASE_FIELD_DISPLAY_WHITELIST`.
14+
15+
Clause-②: no (narrowing) — `ObjectExtensionSchema` refuses three inputs it accepted: a field both replaced and patched, an option-patch key outside the option-value grammar, and a `__proto__` own key in its three record slots. Nothing is widened: the renames are hints on keys already refused.
16+
17+
Metadata-protocol: narrowed (an object extension may no longer patch a field it replaces in `fields`, nor key an option patch by anything but a valid option value)
18+
19+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed or renamed. A field both replaced and patched has two opposite intents and no rewrite keeps both; an option-patch key outside the value grammar never addressed an option; a __proto__ key never survived the parse. The author decides, and each refusal names the key. -->
20+
21+
**BREAKING** accept-set narrowing on `ObjectExtensionSchema` (`@objectstack/spec`), at every door that parses an object extension: `defineObjectExtension`, `defineStack({ objectExtensions })`, `os validate`, the marketplace install and the `/api/v1/meta` `object_extension` save door.
22+
23+
- **Before.** `{ extend: 'crm_account', fields: { industry: {…} }, fieldPatches: { industry: { label: 'Sector' } } }` parsed, and the patch relabelled the extension's replacement. `fieldPatches: { industry: { options: { 'Tech Sector': { label: 'Tech' } } } }` parsed and could never match an option. A JSON body carrying `"__proto__"` under `fields` or `fieldPatches` parsed with that key silently missing.
24+
- **Now.** Each is refused: `custom` at `fieldPatches.industry`, `invalid_key` at `fieldPatches.industry.options.Tech Sector`, `custom` at `fields.__proto__` / `fieldPatches.__proto__`.
25+
- **Unchanged.** A patch of a field the extension does not replace, keyed by a valid option value, parses as before.
26+
- **Who sees it.** No extension in this repository's examples, skills or docs writes any of the refused forms.
27+
28+
**The remedy.** Choose one answer per field: keep the patch to change only its display (the vendor's type, values and upgrades still apply), or keep the `fields` entry and set the display properties there. Key an option patch by the option's stored `value` (`technology`), not its label. Rename a `__proto__` key.

‎content/docs/data-modeling/object-extensions.mdx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,12 @@ reason.
134134
sit in the owner's stored declaration, so the install door admits a patch of one and
135135
every read applies it, while `os validate`, which reads the declaration you wrote,
136136
refuses it.
137+
- **Patch it or take it over, not both.** An extension that names one field in both
138+
`fields` and `fieldPatches` is refused when it parses: `fields` takes the owner's field
139+
over whole, so the patch would land on your own replacement and the owner's field would
140+
reach neither. Keep the patch to change only its display, or keep the `fields` entry
141+
and set the text there. An option is addressed by its stored `value`, so a key no value
142+
can be (`Tech Sector`, a label) is refused too.
137143
- **Conflicts resolve like `label`.** Two extensions patching the same key: the higher
138144
`priority` wins. A tenant who has edited that text keeps their own, [below](#extensions-and-tenant-customization).
139145
- **A patch must name something real.** A field the target does not declare, or an option

‎content/docs/references/data/object.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -577,7 +577,7 @@ const result = ApiMethod.parse(data);
577577
| :--- | :--- | :--- | :--- |
578578
| **extend** | `string` | ✅ | Target object name (FQN) to extend |
579579
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | optional | Fields to add/override |
580-
| **fieldPatches** | `Record<string, { label?: string; description?: string; inlineHelpText?: string; placeholder?: string; … }>` | optional | Display patches of the target object's own fields, keyed by field name: change a vendor field's display properties (BASE_FIELD_DISPLAY_WHITELIST: label, description, help text, placeholder, option labels) without taking the field over, so its type, values and rules stay the vendor's and its upgrades still apply (ADR-0146 D10). Applied by the registry merge (`mergeObjectDefinitions`) after the additive merges, on every object read: a tenant's own text keeps winning, and between two extensions the higher `priority` wins. A patch naming a field or option value the target does not have is refused at install and by `os validate`. Example: `fieldPatches: { industry: { label: 'Sector', inlineHelpText: 'The market the account sells into', options: { technology: { label: 'Tech' } } } }`. |
580+
| **fieldPatches** | `Record<string, { label?: string; description?: string; inlineHelpText?: string; placeholder?: string; … }>` | optional | Display patches of the target object's own fields, keyed by field name: change a vendor field's display properties (BASE_FIELD_DISPLAY_WHITELIST: `label`, `description`, `inlineHelpText`, `placeholder` on the field, `label` on an option under `options`, keyed by its stored value) without taking the field over, so its type, values and rules stay the vendor's and its upgrades still apply (ADR-0146 D10). Applied by the registry merge (`mergeObjectDefinitions`) after the additive merges, on every object read: a tenant's own text keeps winning, and between two extensions the higher `priority` wins. A patch naming a field or option value the target does not have is refused at install and by `os validate`; a field the same extension replaces in `fields` is refused here. Example: `fieldPatches: { industry: { label: 'Sector', inlineHelpText: 'The market the account sells into', options: { technology: { label: 'Tech' } } } }`. |
581581
| **label** | `string` | optional | Override label for the extended object |
582582
| **pluralLabel** | `string` | optional | Override plural label for the extended object |
583583
| **description** | `string` | optional | Override description for the extended object |

‎packages/spec/dropped-refinements.baseline.json‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,11 +1015,11 @@
10151015
"data/ObjectExtension": {
10161016
"sites": [
10171017
"",
1018-
"fields.valueType",
1019-
"fields.valueType.currencyConfig",
1020-
"fields.valueType.inlineColumns.element",
1021-
"fields.valueType.relatedListFilter.lazy",
1022-
"fields.valueType.summaryOperations"
1018+
"fields.out.valueType",
1019+
"fields.out.valueType.currencyConfig",
1020+
"fields.out.valueType.inlineColumns.element",
1021+
"fields.out.valueType.relatedListFilter.lazy",
1022+
"fields.out.valueType.summaryOperations"
10231023
]
10241024
},
10251025
"data/PostgresConfig": {

‎packages/spec/src/data/field-customization.test.ts‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,133 @@ describe('`ObjectExtensionSchema.fieldPatches` — the per-field display patch s
218218
expect(description).toContain('mergeObjectDefinitions');
219219
});
220220
});
221+
222+
// ---------------------------------------------------------------------------
223+
// What the slot refuses beyond its key sets (ADR-0146 D10): a field the same
224+
// extension replaces, an option key no option can carry, and `__proto__`; and
225+
// the renames it carries from the shapes it patches. Each refusal names its
226+
// code, path and subject, beside a control through the same parse.
227+
// ---------------------------------------------------------------------------
228+
229+
/** Parse an extension body as the save door does: a JSON document, so `__proto__` is an own key. */
230+
function jsonIssues(json: string): Issue[] {
231+
const r = ObjectExtensionSchema.safeParse(JSON.parse(json));
232+
return r.success ? [] : (r.error.issues as unknown as Issue[]);
233+
}
234+
235+
const INDUSTRY_FIELD = { name: 'industry', type: 'text', label: 'Industry' } as const;
236+
237+
describe('`fieldPatches` — what the slot refuses beyond its keys, and the renames it carries', () => {
238+
it('refuses a field the same extension both replaces in `fields` and patches: patch it or declare your own, not both', () => {
239+
const r = ObjectExtensionSchema.safeParse({
240+
extend: 'crm_account',
241+
fields: { industry: INDUSTRY_FIELD },
242+
fieldPatches: { industry: { label: 'Sector' } },
243+
});
244+
expect(r.success).toBe(false);
245+
const issues = r.success ? [] : (r.error.issues as unknown as Issue[]);
246+
expect(issues).toHaveLength(1);
247+
expect(issues[0]!.code).toBe('custom');
248+
expect(issues[0]!.path).toEqual(['fieldPatches', 'industry']);
249+
expect(issues[0]!.message).toMatch(/^`industry` is both replaced in `fields` and patched in `fieldPatches`/);
250+
expect(issues[0]!.message).toContain('not both');
251+
});
252+
253+
it('control: a patch beside a `fields` entry of ANOTHER name parses', () => {
254+
const r = ObjectExtensionSchema.safeParse({
255+
extend: 'crm_account',
256+
fields: { region: { name: 'region', type: 'text', label: 'Region' } },
257+
fieldPatches: { industry: { label: 'Sector' } },
258+
});
259+
expect(r.success).toBe(true);
260+
});
261+
262+
it('a `__c` key in both is refused once, by the `__c` rule, whose prescription already names `fields`', () => {
263+
const r = ObjectExtensionSchema.safeParse({
264+
extend: 'crm_account',
265+
fields: { region__c: { name: 'region__c', type: 'text', label: 'Region' } },
266+
fieldPatches: { region__c: { label: 'Region' } },
267+
});
268+
const issues = r.success ? [] : (r.error.issues as unknown as Issue[]);
269+
expect(issues.map((i) => [i.code, i.path.join('.')])).toEqual([['custom', 'fieldPatches.region__c']]);
270+
expect(issues[0]!.message).toContain('spelled as an extension field');
271+
});
272+
273+
it('refuses an option key no option can carry: the map is keyed by `SelectOptionSchema.shape.value`', () => {
274+
const issues = patchIssues({ options: { 'Tech Sector': { label: 'Tech' } } });
275+
expect(issues).toHaveLength(1);
276+
expect(issues[0]!.code).toBe('invalid_key');
277+
expect(issues[0]!.path).toEqual(['fieldPatches', 'industry', 'options', 'Tech Sector']);
278+
expect(issues[0]!.message).toMatch(/^`Tech Sector` cannot be an option's stored value/);
279+
});
280+
281+
it('control: every key the option `value` grammar admits is accepted, and the key schema IS that grammar', () => {
282+
expect(patchIssues({ options: { technology: { label: 'Tech' }, 'sector.retail_2': { label: 'Retail' } } })).toEqual([]);
283+
for (const key of ['technology', 'Tech Sector', 'x', '2nd']) {
284+
expect(patchIssues({ options: { [key]: { label: 'L' } } }).length === 0, key)
285+
.toBe(SelectOptionSchema.shape.value.safeParse(key).success);
286+
}
287+
});
288+
289+
it.each([
290+
['fields', '{"extend":"crm_account","fields":{"__proto__":{"name":"x","type":"text","label":"X"}}}', ['fields', '__proto__']],
291+
['fieldPatches', '{"extend":"crm_account","fieldPatches":{"__proto__":{"label":"X"}}}', ['fieldPatches', '__proto__']],
292+
['options', '{"extend":"crm_account","fieldPatches":{"industry":{"options":{"__proto__":{"label":"X"}}}}}', ['fieldPatches', 'industry', 'options', '__proto__']],
293+
])('refuses a `__proto__` own key in `%s`, which zod would drop while reporting success', (slot, json, path) => {
294+
const issues = jsonIssues(json);
295+
expect(issues).toHaveLength(1);
296+
expect(issues[0]!.code).toBe('custom');
297+
expect(issues[0]!.path).toEqual(path);
298+
expect(issues[0]!.message).toMatch(new RegExp(`^\`${slot}\` cannot contain a key named "__proto__"`));
299+
});
300+
301+
it('control: the same JSON documents without `__proto__` parse, and keep every key', () => {
302+
const parsed = ObjectExtensionSchema.parse(JSON.parse(
303+
'{"extend":"crm_account","fields":{"region":{"name":"region","type":"text","label":"Region"}},' +
304+
'"fieldPatches":{"industry":{"options":{"technology":{"label":"Tech"}}}}}',
305+
));
306+
expect(Object.keys(parsed.fields ?? {})).toEqual(['region']);
307+
expect(parsed.fieldPatches?.industry?.options).toEqual({ technology: { label: 'Tech' } });
308+
});
309+
310+
it.each([
311+
['helpText', 'inlineHelpText'],
312+
['tooltip', 'inlineHelpText'],
313+
['title', 'label'],
314+
['displayName', 'label'],
315+
])('a field patch carries the field\'s rename `%s` → `%s`', (written, target) => {
316+
const issues = patchIssues({ [written]: 'Display text' });
317+
expect(issues).toHaveLength(1);
318+
expect(issues[0]!.code).toBe('unrecognized_keys');
319+
expect(issues[0]!.keys).toEqual([written]);
320+
expect(issues[0]!.message).toContain(`\`${written}\` → \`${target}\``);
321+
});
322+
323+
it.each([['text'], ['name'], ['title']])('an option patch carries the option\'s rename `%s` → `label`', (written) => {
324+
const issues = patchIssues({ options: { technology: { [written]: 'Tech' } } });
325+
expect(issues).toHaveLength(1);
326+
expect(issues[0]!.code).toBe('unrecognized_keys');
327+
expect(issues[0]!.path).toEqual(['fieldPatches', 'industry', 'options', 'technology']);
328+
expect(issues[0]!.message).toContain(`\`${written}\` → \`label\``);
329+
});
330+
331+
it('control: a rename whose target the patch refuses is not carried (no `isRequired` → `required`, no `key` → `value`)', () => {
332+
const field = patchIssues({ isRequired: true });
333+
expect(field.map((i) => i.code)).toEqual(['unrecognized_keys']);
334+
expect(field[0]!.message).not.toContain('→');
335+
const option = patchIssues({ options: { technology: { key: 'tech' } } });
336+
expect(option.map((i) => i.code)).toEqual(['unrecognized_keys']);
337+
expect(option[0]!.message).not.toContain('→');
338+
// `colour` → `color` is carried only once the whitelist admits `color` (`field-patches-derived.test.ts`).
339+
const colour = patchIssues({ options: { technology: { colour: '#2563eb' } } });
340+
expect(colour.map((i) => i.code)).toEqual(['unrecognized_keys']);
341+
expect(colour[0]!.message).not.toContain('`colour` → `color`');
342+
});
343+
344+
it('the description lists the whitelist\'s members, read from the constant', () => {
345+
const description = shapeOf(ObjectExtensionSchema).fieldPatches!.description ?? '';
346+
for (const key of [...BASE_FIELD_DISPLAY_WHITELIST.field, ...BASE_FIELD_DISPLAY_WHITELIST.option]) {
347+
expect(description, key).toContain(`\`${key}\``);
348+
}
349+
});
350+
});

0 commit comments

Comments
 (0)