From b126f43feb2cfad6a9878810651994dab8f50d1a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 16:19:24 +0000 Subject: [PATCH] =?UTF-8?q?fix(plugin-detail):=20record:highlights=20?= =?UTF-8?q?=E7=9A=84=20fields=20=E5=A3=B0=E6=98=8E=E8=A1=A5=E4=B8=8A=20rea?= =?UTF-8?q?donly,=E8=AE=A9=20manifest=20=E8=83=BD=E8=A2=AB=E4=BD=9C?= =?UTF-8?q?=E8=80=85=E8=AF=BB=E5=88=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `readonly` 早已被兑现:renderer 归一化时逐条拷贝它,`HeaderHighlight` 的可编辑门 据此拒绝 chip 上的内联编辑(objectstack#5077);`@objectstack/spec` 也已在 `RecordHighlightsField` 上声明(objectstack#5176 / PR #5607)。缺的只是本仓这一侧的 **发布面**:`fields` input 的 description 仍把条目形状写成 `{name,label?,icon?,type?}`,而 registry `inputs` 正是 `gen-manifest.ts` 序列化进 `sdui.manifest.json` 的东西 —— 读 manifest 的 AI 作者被告知这个键不存在。 `readonly` 写在 `fields` 的 description **里**,而不是新增一个自己的 input,因为 契约就是这么放的:spec 的 `RecordHighlightsProps` 顶层恰好三个键 (`fields`/`layout`/`aria`),`readonly` 挂在每个**条目**上。顶层加 `{ name: 'readonly', type: 'boolean' }` 会发布一个平台默默丢弃的键 —— 生成的 `sdui.manifest.json` 与 `sdui-intrinsics.d.ts` 会宣告 `readonly` prop, manifest 门只校验顶层 prop、不会报任何诊断,`RecordHighlightsProps` 是普通 `z.object`、parse 时把未知键无声剥掉,而逐条读 `field.readonly` 的 renderer 永远 看不到它。信了这个面的作者,最后得到的是机器维护的列仍可手改、且任何地方都没有 诊断说明原因。`ComponentInput` 本就是扁平的,所以数组对象型 input 用散文发布成员 键,`record:path.stages` 与 `record:alert.action` 一直如此。 新增的 spec-parity 测试两个方向都在运行时从 `@objectstack/spec` 推导,而不是复述 今天的键表:`RecordHighlightsField` 对象分支的每个键都必须出现在 `fields` 的 description 里;本 block 也不得声明 `RecordHighlightsProps` 不接受的顶层 input。 此前没有任何东西交叉校验 registry `inputs` 与 spec,两个漂移方向都是静默的。 反向核验(方向事先预判,两个都应转红,各钉住改动的一半): - 把 description 退回旧文本 → 「every spec entry key is discoverable」转红, `undocumented = ['readonly']`; - 按 issue 字面加顶层 `readonly` input → 「declares no top-level input the spec does not accept」转红,`offSpec = ['readonly']` —— 新门恰好拦住字面方案。 无运行时行为变化。 Fixes #3407 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- ...hlights-readonly-authoring-surface-3407.md | 35 ++++++ ...recordHighlightsInputs.spec-parity.test.ts | 108 ++++++++++++++++++ packages/plugin-detail/src/index.tsx | 20 +++- 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 .changeset/highlights-readonly-authoring-surface-3407.md create mode 100644 packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts diff --git a/.changeset/highlights-readonly-authoring-surface-3407.md b/.changeset/highlights-readonly-authoring-surface-3407.md new file mode 100644 index 0000000000..d4fb034540 --- /dev/null +++ b/.changeset/highlights-readonly-authoring-surface-3407.md @@ -0,0 +1,35 @@ +--- +"@object-ui/plugin-detail": patch +--- + +`record:highlights` publishes the `readonly` entry key, so an AI author can discover it from the manifest + +`readonly` on a `fields[]` entry has been enforced for a while — the renderer copies it +through normalization and `HeaderHighlight`'s editability gate refuses inline editing on a +chip carrying it (objectstack#5077) — and `@objectstack/spec` declares it on +`RecordHighlightsField` (objectstack#5176 / PR #5607). The block's own published authoring +surface never mentioned it: the `fields` input still spelled the entry shape +`{name,label?,icon?,type?}`, and since the registry `inputs` are what +`gen-manifest.ts` serializes into `sdui.manifest.json`, an author reading the manifest was +told the key did not exist. The `fields` description now states the full entry shape and +what `readonly` does, which is the discoverability the manifest is for. + +`readonly` is documented **inside** the `fields` description rather than declared as an +input of its own, because that is where the contract puts it. The spec's +`RecordHighlightsProps` has exactly three top-level keys (`fields`, `layout`, `aria`) and +carries `readonly` per ENTRY. A top-level `{ name: 'readonly', type: 'boolean' }` input +would publish a key the platform silently discards: the generated `sdui.manifest.json` and +`sdui-intrinsics.d.ts` would advertise a `readonly` prop, the manifest gate validates +top-level props only and would raise no diagnostic, `RecordHighlightsProps` is a plain +`z.object` so the unknown key is stripped on parse without error, and the renderer — which +reads `field.readonly` per entry — would never see it. An author who trusted that surface +would be left with the machine-owned column still hand-editable and no diagnostic anywhere +explaining why. `ComponentInput` is flat by design, so an array-of-objects input publishes +its member keys in prose, as `record:path.stages` and `record:alert.action` already do. + +A new spec-parity test derives both directions from `@objectstack/spec` at runtime instead +of restating today's key list: every key of `RecordHighlightsField`'s object arm must be +named in the `fields` description, and the block must declare no top-level input that +`RecordHighlightsProps` does not accept. Nothing previously cross-checked the registry +`inputs` against the spec, so both drift directions were silent. No runtime behaviour +changes. diff --git a/packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts b/packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts new file mode 100644 index 0000000000..444868825b --- /dev/null +++ b/packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts @@ -0,0 +1,108 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * `record:highlights` — the published authoring surface stays in parity with + * `@objectstack/spec` RecordHighlights* (objectui#3407, objectstack#5176). + * + * The registry `inputs` ARE the published contract: `gen-manifest.ts` + * serializes them into `sdui.manifest.json` (the save-gate + parser whitelist) + * and into `sdui-intrinsics.d.ts` (the JSX authoring type surface). Nothing in + * the repo cross-checks them against the spec, so both drift directions are + * silent and both are harmful: + * + * - a spec ENTRY key that no input mentions is a key an AI author cannot + * discover (the complaint that opened #3407: `readonly` was enforced by the + * HeaderHighlight gate and honoured by the renderer, but the `fields` + * description still spelled the entry shape `{name,label?,icon?,type?}`); + * - a top-level input the spec does not declare is worse than undocumented, + * it is actively misleading. `RecordHighlightsProps` is a plain `z.object`, + * so an unknown top-level key is STRIPPED on parse with no error, the + * manifest gate only validates top-level props and raises no diagnostic, + * and the renderer never sees it. The manifest would be telling authors to + * write something the platform throws away. + * + * Both assertions derive their expectation from the spec at runtime rather than + * restating today's key list, so a spec change fails here instead of quietly + * widening the gap. + */ + +import { describe, it, expect } from 'vitest'; +import { ComponentRegistry } from '@object-ui/core'; +import { RecordHighlightsField, RecordHighlightsProps } from '@objectstack/spec/ui'; +import '../index'; + +/** Keys of the object arm of the spec's `RecordHighlightsField` union. */ +function specEntryKeys(): string[] { + const union = RecordHighlightsField as unknown as { + def?: { options?: unknown[] }; + _def?: { options?: unknown[] }; + }; + const arms = union.def?.options ?? union._def?.options ?? []; + for (const arm of arms) { + const shape = (arm as { shape?: unknown; _def?: { shape?: unknown } }).shape + ?? (arm as { _def?: { shape?: unknown } })._def?.shape; + const resolved = typeof shape === 'function' ? (shape as () => object)() : shape; + if (resolved && typeof resolved === 'object') return Object.keys(resolved); + } + return []; +} + +/** Top-level keys of the spec's `RecordHighlightsProps`. */ +function specTopLevelKeys(): string[] { + const obj = RecordHighlightsProps as unknown as { + shape?: unknown; + _def?: { shape?: unknown }; + }; + const shape = obj.shape ?? obj._def?.shape; + const resolved = typeof shape === 'function' ? (shape as () => object)() : shape; + return resolved && typeof resolved === 'object' ? Object.keys(resolved) : []; +} + +const config = () => ComponentRegistry.getConfig('record:highlights'); +const inputs = () => config()?.inputs ?? []; +const fieldsInput = () => inputs().find((i) => i.name === 'fields'); + +describe('record:highlights — registry inputs vs @objectstack/spec', () => { + it('is registered with a non-empty `inputs` surface', () => { + expect(config()).toBeDefined(); + expect(inputs().length).toBeGreaterThan(0); + expect(inputs().map((i) => i.name)).toContain('fields'); + }); + + it('the spec really carries `readonly` per ENTRY, not top-level', () => { + // Guards the premise the rest of the file rests on. If a future spec moves + // `readonly` up to the props object, this fails and the `inputs` shape + // above should be revisited — a top-level input would then be correct. + expect(specEntryKeys()).toContain('readonly'); + expect(specTopLevelKeys()).not.toContain('readonly'); + }); + + it('a top-level `readonly` is silently stripped by the spec, so it must not be published', () => { + // The concrete harm: no throw, no diagnostic, key gone. + const parsed = RecordHighlightsProps.parse({ fields: ['amount'], readonly: true }); + expect(parsed).not.toHaveProperty('readonly'); + // …while the per-entry spelling survives, which is the one authors need. + const perEntry = RecordHighlightsProps.parse({ fields: [{ name: 'amount', readonly: true }] }); + expect(perEntry.fields[0]).toMatchObject({ name: 'amount', readonly: true }); + }); + + it('every spec entry key is discoverable from the `fields` input description', () => { + const description = fieldsInput()?.description ?? ''; + expect(description).not.toBe(''); + const undocumented = specEntryKeys().filter((key) => !description.includes(key)); + expect(undocumented).toEqual([]); + // The key this issue was filed for, named explicitly so the regression is + // legible if the derived check above is ever loosened. + expect(description).toContain('readonly'); + }); + + it('declares no top-level input the spec does not accept', () => { + const allowed = new Set(specTopLevelKeys()); + const offSpec = inputs().map((i) => i.name).filter((name) => !allowed.has(name)); + expect(offSpec).toEqual([]); + }); +}); diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index 2846a506a2..81cccfcad0 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -278,8 +278,26 @@ ComponentRegistry.register('highlights', RecordHighlightsRenderer, { label: 'Highlights Panel', icon: 'Star', // Mirrors @objectstack/spec RecordHighlightsProps. + // + // `readonly` is documented INSIDE the `fields` description, not declared as + // an input of its own, because that is where the contract puts it: the spec's + // `RecordHighlightsField` carries `readonly` on each ENTRY, while + // `RecordHighlightsProps` has exactly three top-level keys (fields, layout, + // aria). A top-level `{ name: 'readonly', type: 'boolean' }` here would look + // like the fix for "the manifest never mentions readonly" and would instead + // publish a key the platform silently discards: the generated + // `sdui.manifest.json` and `sdui-intrinsics.d.ts` would advertise + // ``, the manifest gate validates top-level props + // only and would raise no diagnostic, the spec strips the unknown key on + // parse without error, and the renderer — which reads `field.readonly` per + // entry — would never see it. An author who trusted that surface would be + // left with the machine-owned column still hand-editable and nothing + // anywhere saying why. `ComponentInput` is flat by design (`name` = "must + // match schema property"), so an array-of-objects input publishes its member + // keys in prose, the same way `record:path.stages` and `record:alert.action` + // do. objectui#3407 / objectstack#5176. inputs: [ - { name: 'fields', type: 'array', label: 'Fields', required: true, description: 'Key fields to highlight (1-7), bare names or {name,label?,icon?,type?}' }, + { name: 'fields', type: 'array', label: 'Fields', required: true, description: 'Key fields to highlight (1-7), bare names or {name,label?,icon?,type?,readonly?}. Set readonly: true on an entry to render that chip read-only — it suppresses the inline-edit affordance and the HeaderHighlight editability gate enforces it. Use it for hook/automation-maintained columns that must not be hand-edited from the record header; marking the OBJECT field readonly instead would also strip the hook\'s own write-back.' }, { name: 'layout', type: 'enum', label: 'Layout', enum: ['horizontal', 'vertical'], defaultValue: 'horizontal', description: 'Layout orientation for highlight fields' }, ], });