diff --git a/.changeset/6854-layout-renderer-retired-onclick.md b/.changeset/6854-layout-renderer-retired-onclick.md index 94b2bee660..ee542966aa 100644 --- a/.changeset/6854-layout-renderer-retired-onclick.md +++ b/.changeset/6854-layout-renderer-retired-onclick.md @@ -8,7 +8,9 @@ makes `AppActionSchema.onClick`'s retirement message true again (objectui#6854, maintainer ruling of 2026-09-05, option B2). `AppAction.items` is `AppMenuItem[]`, and the zod mirror parses it with the legacy -eight-member `MenuItemSchema` — neither declares `onClick` or `shortcut`. +`MenuItemSchema`, which declared neither `onClick` nor `shortcut` when this change was +made. (`shortcut` has since become a declared refusal there — objectui#7719 — while +`onClick` remains undeclared on that mirror and is still dropped in silence.) `LayoutRenderer` reached both through `as any`, past the type it was handed, and that left three mutually exclusive signals about the same key: the TypeScript face said `?: never`, the validator's refusal said "no renderer reads this key, so @@ -25,7 +27,9 @@ its sentence true rather than restating it. - `@object-ui/runner`: `LayoutRenderer` no longer reads `onClick` or `shortcut` on a `type: 'user'` action's `items`. The `onClick` branch was an empty body and could never run a JSON value; the `shortcut` read rendered a `DropdownMenuShortcut` from - a key the mirror strips in silence, so no validated document could reach it. A + a key the mirror stripped in silence at the time, so no validated document could + reach it. (objectui#7719 has since replaced that silent strip with a named refusal; + either way the read was unreachable, which is what made deleting it a cleanup.) A census of every JSON and TypeScript app document in this repository found zero authors of either key (positive controls recorded on the issue). - `@object-ui/types`: the rationale comments on `AppAction.onClick` and @@ -33,5 +37,6 @@ its sentence true rather than restating it. That was false — the runner renders both the `'button'` and the `'user'` arm. Corrected to what was measured: `actions[]` is read, `onClick` is not. -Whether `shortcut` should become authorable on `AppAction.items` is a separate -contract question and is filed on its own. +Whether `shortcut` should become authorable on `AppAction.items` was a separate +contract question, filed as objectui#7719 and since answered: it does not become +authorable, and the mirror refuses it by name instead of stripping it. diff --git a/.changeset/7719-app-menu-item-shortcut-named-refusal.md b/.changeset/7719-app-menu-item-shortcut-named-refusal.md new file mode 100644 index 0000000000..0d2f24595c --- /dev/null +++ b/.changeset/7719-app-menu-item-shortcut-named-refusal.md @@ -0,0 +1,44 @@ +--- +'@object-ui/types': minor +--- + +`AppAction.items` 上的 `shortcut` 由「静默剥掉」改为「具名拒收」 + +⚠️ **这是一次已发布 mirror 的收窄**:本次改动**之前**,一份在 app action 的菜单项上写了 `shortcut` +的文档 `safeParse` 是**绿**的(键被 `MenuItemSchema` 无声丢弃,作者拿不到任何提示);**之后**在该键上**转红**, +issue 指向 `NavigationItem`。TS 面同步收窄:`AppMenuItem.shortcut?: never`,在编写处就被 `tsc` 拒掉。 + +零迁移面:本仓**零作者**写过这个键,且 `@object-ui/runner` 不发布库入口,没有绕过校验器塞进来的 +路径。⛔ 这里刻意不冻结任何总数 —— objectui#6854 当初普查的那个 JSON 总体此后已经变动,冻进散文 +的数字没有东西会去重新求值。判据写成可复跑的规则:**结构化**地 `JSON.parse` 每一个 tracked +`*.json`,无限深度遍历,报出「经由字面名为 `items` 的键抵达的数组、其元素对象自带 `shortcut`」者; +在本次改动的头上跑出 0 命中,注入 fixture 的正控制会开火(含一处嵌套命中),且不会把 action 层的 `shortcut` +兄弟键误计。总体数随树变化,规则不变。 + +裁决:director seat decision batch #70(objectui#7719,2026-09-07,维护者「同意」)。 +⛔ 选项 B(给已弃用的 `AppMenuItem` 长一个真正的 `shortcut` 成员并渲染它)与选项 C(把 +`AppAction.items` 改成 overlay 的 `MenuItem`)**均被拒绝**。唯一的改动是**诊断**: + +- TS 面 —— `AppMenuItem.shortcut?: never`; +- zod 面 —— `MenuItemSchema.shortcut` 走 `retirementTombstone()`,一条 guidance 同时喂 parse + 消息与 `.describe()`(即已发布的 JSON-Schema 描述)。 + +⛔ `LayoutRenderer` 未恢复任何读点,objectui#6854 的那枚 pin 断言原样保留;本次只修了它与 +`LayoutRenderer.tsx` 里已经过期的散文(两处都在把一个已裁决的问题描述成悬而未决)。 +`@object-ui/runner` 的改动**仅为注释**,无任何已发布行为变化。 + +⚠️ 受影响的**不是** `AppAction.shortcut`:那是 header 按钮自己的快捷键,一直声明着、本次不动, +并由控制断言钉住——只有它下面一层的 `items[]` 被收窄。 + +键盘快捷键若确实需要,它是 `NavigationItem` 那条线的能力,⛔ 不在这条已弃用的 legacy 面上补。 + +⚠️ 协议侧的现状,实测于**已解析安装**的 `@objectstack/spec@17.4.0`(⛔ 不是读源码推断): +`action.shortcut` 在 17.0.0 的审计收尾里被移除的是**可编写性**,它的 tombstone 是**保留着的** —— +`ActionSchema.safeParse({ name, type, label, shortcut })` 返回 `success: false`,issue 落在 +`shortcut` 上、`code` 为 `invalid_type`,消息开头即 +「`action.shortcut` was removed in @objectstack/spec 17.0.0 (audit close-out)」。 +正控制:换一个协议从未声明过的兄弟键,同样被拒,但**没有**落在 `shortcut` 上的 issue —— +⇒ 上面那条读数是关于这个键的,不是 strict 对象的通用效果。 +⚠️ 两边的**终局形状**一致 —— 键不可编写、拒收具名;但**路径不同**,不要把它们说成一回事: +协议那边是先声明、再移除可编写性、留下 tombstone;本仓这个键在 `AppMenuItem` 上**从未声明过**, +它走的是「静默剥掉」→「具名拒收」,⛔ 没有任何可编写性被移除。 diff --git a/packages/runner/src/LayoutRenderer.tsx b/packages/runner/src/LayoutRenderer.tsx index 7e45da193e..d7c8cba1cb 100644 --- a/packages/runner/src/LayoutRenderer.tsx +++ b/packages/runner/src/LayoutRenderer.tsx @@ -302,15 +302,21 @@ export const LayoutRenderer = ({ app, children, currentPath, onNavigate }: Layou * (objectui#6854, maintainer ruling of 2026-09-05, option B2). * * `items` is `AppMenuItem[]` (`@object-ui/types` `app.ts`), and the zod - * mirror parses it with the legacy eight-member `MenuItemSchema`. - * Neither declares `onClick` or `shortcut`; this map used to reach both + * mirror parses it with the legacy `MenuItemSchema`. Neither makes + * `onClick` or `shortcut` AUTHORABLE; this map used to reach both * through `as any`, i.e. past the type it was handed. The `onClick` read * is also what made the retirement refusal's own sentence — "no renderer * reads this key, so nothing could ever run it" — false. `type` and * `label` ARE declared on `AppMenuItem` and stay. * - * Whether `shortcut` should become authorable on `AppAction.items` is a - * separate contract question; do not re-add either read to answer it. + * `shortcut` is SETTLED, and the answer left this map alone + * (objectui#7719, director seat decision batch #70 of 2026-09-07): it + * does not become authorable on `AppAction.items`. What changed is the + * DIAGNOSTIC on the types side — `shortcut?: never` on `AppMenuItem` + * and a named refusal on `MenuItemSchema`, so an authored value is + * refused instead of stripped in silence. A keyboard shortcut on a + * navigation entry is a capability of the `NavigationItem` line. + * ⛔ No read is re-added here; that is the ruling, not an open question. */} {userAction.items?.map((item, idx) => { if (item.type === 'separator') { diff --git a/packages/runner/src/__tests__/LayoutRenderer.appActionItems-6854.test.tsx b/packages/runner/src/__tests__/LayoutRenderer.appActionItems-6854.test.tsx index 409cb37853..00f1ddae9e 100644 --- a/packages/runner/src/__tests__/LayoutRenderer.appActionItems-6854.test.tsx +++ b/packages/runner/src/__tests__/LayoutRenderer.appActionItems-6854.test.tsx @@ -12,9 +12,10 @@ * * `AppAction.items` is `AppMenuItem[]` — `type` / `label` / `icon` / `path` / * `href` / `children` / `badge` / `hidden` — and the zod mirror parses it with - * the legacy eight-member `MenuItemSchema`, which drops anything else in - * silence. This map used to reach two keys that are on neither list through - * `as any`: `onClick` and `shortcut`. + * the legacy `MenuItemSchema`, which drops anything else in silence, `shortcut` + * excepted since objectui#7719 (see the note below). This map used to reach two + * keys that are on neither authorable list through `as any`: `onClick` and + * `shortcut`. * * Deleting them is what makes `AppActionSchema.onClick`'s refusal message true * again. It tells an author "no renderer reads this key, so nothing could ever @@ -27,9 +28,18 @@ * none of in this repo — and require the renderer to ignore both. Re-adding * either read turns one of them red. * - * ⛔ NOT a ruling that `shortcut` must stay unrendered for ever: whether it - * should become AUTHORABLE on `AppAction.items` is its own contract card. This - * pins the contract as it stands, not the answer to that question. + * ⭐ THE RULING THIS NOTE USED TO BE WAITING FOR HAS LANDED. This paragraph read + * "⛔ NOT a ruling that `shortcut` must stay unrendered for ever: whether it + * should become AUTHORABLE on `AppAction.items` is its own contract card" — + * that card is objectui#7719, and director seat decision batch #70 of + * 2026-09-07 answered it: `shortcut` does NOT become authorable here, and ⛔ no + * read is re-added in this renderer. What that card changed is the DIAGNOSTIC on + * the types side — `shortcut?: never` on `AppMenuItem` and a named refusal on + * `MenuItemSchema`, so an authored value is refused by name instead of stripped + * in silence, pinned in + * `packages/types/src/__tests__/app-menu-item-shortcut-refusal-7719.test.ts`. + * ⇒ The assertions below are unchanged BY THAT RULING, which says the #6854 pin + * stays as is. They now pin a settled contract rather than an interim state. * * The `packages/types` half of the same claim — that the refusal message still * makes it — is pinned in diff --git a/packages/types/src/__tests__/app-action-onclick-refusal-6854.test.ts b/packages/types/src/__tests__/app-action-onclick-refusal-6854.test.ts index 8460690a12..f395aaa622 100644 --- a/packages/types/src/__tests__/app-action-onclick-refusal-6854.test.ts +++ b/packages/types/src/__tests__/app-action-onclick-refusal-6854.test.ts @@ -80,22 +80,42 @@ describe('AppActionSchema.onClick — the retirement message states a measured f }); describe('why the cast could never have been fed by an author (objectui#6854 Zone 2, the premise)', () => { - // `AppAction.items` is parsed by the LEGACY eight-member `MenuItemSchema`, a - // plain `z.object` — so `onClick` and `shortcut` are not refused there, they - // are STRIPPED in silence. An author therefore has no declared route to send - // either key, which is what made deleting the two reads a cleanup rather than - // a behaviour removal. Whether `shortcut` SHOULD become authorable here is a - // separate contract question and deliberately not answered by this file. - const authored = { label: 'Profile', onClick: 'goProfile', shortcut: 'Ctrl+P' }; + // `AppAction.items` is parsed by the LEGACY `MenuItemSchema`, a plain + // `z.object` — so `onClick` is not refused there, it is STRIPPED in silence. + // An author therefore has no declared route to send it, which is what made + // deleting the two reads a cleanup rather than a behaviour removal. + // + // ⭐ UPDATED by objectui#7719 (director seat decision batch #70 of 2026-09-07): + // this block used to carry `shortcut` in the same fixture and the same + // sentence, and recorded "whether `shortcut` SHOULD become authorable here is + // a separate contract question" as OPEN. It is answered — `shortcut` does not + // become authorable, and it is no longer stripped here either: it is a named + // `retirementTombstone()` refusal pointing at `NavigationItem`. So the two + // keys no longer share a fate and no longer share a fixture. + // ⛔ The Zone-2 premise this file exists for is UNCHANGED, and in fact + // stronger: an author still has no declared route to send either key, and is + // now told so for one of them. The `shortcut` contract itself is pinned in + // `./app-menu-item-shortcut-refusal-7719.test.ts`, not here. + const authored = { label: 'Profile', onClick: 'goProfile' }; - it('the items mirror accepts the document and drops both undeclared keys', () => { + it('the items mirror accepts the document and drops the undeclared `onClick`', () => { const result = MenuItemSchema.safeParse(authored); expect(result.success).toBe(true); if (!result.success) return; const parsed = result.data as Record; expect(parsed.label).toBe('Profile'); expect('onClick' in parsed).toBe(false); - expect('shortcut' in parsed).toBe(false); + }); + + it('an authored `shortcut` on the same item is REFUSED, not dropped (objectui#7719)', () => { + // Kept HERE, beside the `onClick` row, rather than only in the #7719 pin: + // the two keys were one sentence in objectui#6854 and are two contracts now, + // and this pair is what stops them being conflated again. Swap either + // expectation and the file says which key it is describing. + const result = MenuItemSchema.safeParse({ ...authored, shortcut: 'Ctrl+P' }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues.some((i) => String(i.path[0]) === 'shortcut')).toBe(true); }); it('a whole action carrying such an item parses green, with the item scrubbed', () => { @@ -110,11 +130,27 @@ describe('why the cast could never have been fed by an author (objectui#6854 Zon // element mirror `MenuItemSchema` was annotated `z.ZodType` to break its own // recursion. objectui#7760 gave that mirror its declaration as both type arguments, // so the element is `AppMenuItem` now and a direct assertion to an index-signature - // type no longer overlaps. ⛔ The assertions below are unchanged and still read the - // RUNTIME object — the point of this file is that both undeclared keys are gone - // from the parsed value, which no static type can answer. + // type no longer overlaps. The assertion below still reads the RUNTIME object, + // which is the point of this file: whether a key survived a parse is not something + // any static type can answer. + // + // ⭐ It used to be a PAIR, asserting `onClick` and `shortcut` both gone, driven by a + // fixture that carried both keys. objectui#7719 split the two contracts, so only the + // `onClick` half belongs here — and `authored` above no longer carries `shortcut` at + // all. `shortcut` is no longer an UNDECLARED key that gets scrubbed; it is a declared + // `retirementTombstone`. + // + // ⛔ Do not restore the second assertion. Restored ALONE it would still pass, because + // this fixture has no `shortcut` for the parse to refuse — a green row asserting the + // absence of a key nobody wrote, which pins nothing. Making it mean anything would + // require putting `shortcut` back into `authored`, and THAT is what turns this block + // red: the parse fails, `result.success` is false, and the early return above fires + // before `first` is ever destructured. So the two halves cannot share one fixture any + // more, which is the whole reason they were split. The refusal is pinned by the row + // named "an authored `shortcut` on the same item is REFUSED, not dropped" — cited by + // NAME, because a positional reference goes stale the moment a row is inserted — and + // the contract itself by `./app-menu-item-shortcut-refusal-7719.test.ts`. const [first] = (result.data as unknown as { items: Record[] }).items; expect('onClick' in first).toBe(false); - expect('shortcut' in first).toBe(false); }); }); diff --git a/packages/types/src/__tests__/app-menu-item-shortcut-refusal-7719.test.ts b/packages/types/src/__tests__/app-menu-item-shortcut-refusal-7719.test.ts new file mode 100644 index 0000000000..a3efcd8a8c --- /dev/null +++ b/packages/types/src/__tests__/app-menu-item-shortcut-refusal-7719.test.ts @@ -0,0 +1,346 @@ +/** + * 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. + */ + +/** + * objectui#7719 — `shortcut` is REFUSED BY NAME on an app action ITEM + * (`AppAction.items`, i.e. the legacy `AppMenuItem`), on BOTH faces. + * + * ## The ruling, quoted rather than paraphrased + * + * Director seat decision batch #70, 2026-09-07, maintainer verbatim 「同意」: + * + * > The deprecated `AppMenuItem` does not grow a `shortcut` member (option B + * > refused: zero measured pull, and the type is being retired in favour of + * > `NavigationItem`); `AppAction.items` is not re-typed to the overlay + * > `MenuItem` (option C refused, as on #6854). The one change: an authored + * > `shortcut` on an app action item is **refused by name** on the legacy + * > mirror (`shortcut?: never` on the TS face, a named refusal on + * > `MenuItemSchema` in `app.zod.ts`) with a message pointing at + * > `NavigationItem`, so the key is no longer stripped in silence. ⛔ No read is + * > re-added in `LayoutRenderer`; the #6854 pin stays as is. + * + * ⇒ This file pins the ruling's THREE clauses: the TS `never`, the named mirror + * refusal, and — block (e) — the untouched renderer contract. + * + * ## ⚠️ The failure mode here is STRIP, not KEEP — measured, and it differs + * ## from the family's other retirements + * + * objectui#7997 (`DetailViewSchema.related`) recorded that a dropped member key + * is KEPT: `BaseSchemaCore` ends `.passthrough()` and the TypeScript + * `BaseSchema` closes with an any-valued index signature. ⛔ That mechanism does + * NOT apply to this surface and assuming it would misdescribe the change. + * `MenuItemSchema` is a plain `z.object` under a `z.lazy`, built on no base, and + * `AppMenuItem` declares no index signature ⇒ an undeclared key is STRIPPED, + * silently, and never reaches `.data`. Block (c) takes that reading rather than + * restating it, and the distinction is the point: both mechanisms are a SILENT + * ACCEPT, so a bare non-declaration refuses nothing either way — but only one of + * them leaves the value visible afterwards, and here it does not. "Stripped in + * silence" is the card's own phrase and it is the accurate one. + * + * ## Which refusal helper, and why it is a measurement rather than a taste + * + * `retirementTombstone()` — `z.never({ error }).optional().describe()`. + * + * - ⛔ NOT `handlerKeyRefusal()`, despite the in-file precedent on the sibling + * `AppActionSchema.onClick`. Two independent reasons, the second decisive: + * its message says JSON has no function value, which is FALSE of a + * string-valued key an author can perfectly well write; and its `z.custom` + * primitive makes `z.toJSONSchema` THROW. Measured before this change: + * `z.toJSONSchema(MenuItemSchema)` SUCCEEDS in both `io` directions while + * `z.toJSONSchema(AppActionSchema)` already throws — because of that very + * `onClick` arm. Choosing `handlerKeyRefusal` here would have taken + * `MenuItemSchema` from representable to throwing. Block (f) pins it. + * - ⛔ NOT `aliasKeyRefusal()`: that helper composes "Did you mean `shortcut` → + * ``?", and there is no canonical member of `MenuItemSchema` + * meaning a keyboard shortcut. The remedy here is a different TYPE + * (`NavigationItem`), not a sibling spelling, so that helper could only be + * made to say something untrue. + * - ✅ `retirementTombstone()` is the helper whose own contract is this exact + * situation — "a key that stays declared but is unwritable, so an authored + * value is REFUSED loudly instead of being silently stripped the way an + * undeclared key would be". Declaration history does not separate it from its + * siblings: the overlay `MenuItemSchema.type` (objectui#6523) is a + * `retirementTombstone` for a spelling that was NEVER declared either, which + * is precisely this case. + * - ⛔ And not `@objectstack/spec`'s own `retiredKey`, whose describe contract + * prefixes `[REMOVED] `; these describe strings are this package's published + * metadata. + * + * ## Which program checks this file + * + * `packages/types`' `type-check` runs THREE programs; this file is in the third + * (`tsconfig.test.json` — `tsc --noEmit` builds `tsconfig.json`, which excludes + * `__tests__/` by directory). ⚠️ The type-level rows in block (a) are therefore + * pinned by `tsc`, ⛔ NOT by vitest, which strips types and would pass them + * vacuously. Both are run and reported. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import type { AppAction, AppMenuItem } from '../app'; +import { AppActionSchema, MenuItemSchema } from '../zod/app.zod'; + +/** Mutual assignability, the standard invariant `Eq` — not `extends`. */ +type Eq = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) + ? true + : false; + +/* ── (a) the TypeScript face ──────────────────────────────────────────────── */ + +describe('objectui#7719 — the TypeScript face refuses `shortcut` on `AppMenuItem`', () => { + it('the member is a `never` tombstone — neither a value type nor an absent key', () => { + // ⚠️ Pinned by `tsc -p tsconfig.test.json`; vacuous under vitest. + // + // PROVING REMOVAL: delete `shortcut?: never` from `AppMenuItem` and this + // line stops COMPILING (TS2339 — the property does not exist), which is the + // pre-change state of this tree. Re-declare it as `shortcut?: string` + // (option B, the thing the ruling refused) and mutual assignability fails in + // the other direction. Both directions matter and a one-way `extends` would + // have caught only one of them. + const _tombstoned: Eq = true; + expect(_tombstoned).toBe(true); + }); + + it('authoring a shortcut on a menu item no longer compiles', () => { + // PROVING REMOVAL: widen the member to `shortcut?: string` and this row + // reds — the `@ts-expect-error` goes unused (TS2578). That is the direction + // worth guarding: this row is what turns a future option-B patch red. + const item: AppMenuItem = { + label: 'Profile', + // @ts-expect-error `shortcut` is refused on an app action item — author navigation as `NavigationItem` (objectui#7719) + shortcut: 'Ctrl+P', + }; + expect(item.label).toBe('Profile'); + }); + + it('CONTROL — the REST of `AppMenuItem` still type-checks; this is one member, not a closed face', () => { + const item: AppMenuItem = { + type: 'item', + label: 'Profile', + icon: 'user', + path: '/profile', + badge: 3, + hidden: false, + }; + expect(item.path).toBe('/profile'); + }); + + it('CONTROL — `AppAction.shortcut` is a DIFFERENT key, still declared and still a `string`', () => { + // ⭐ The non-target control, and the reason it is in this file: the first + // reading of a `grep` for `shortcut` over `app.ts` finds THIS member and + // reads it as "option B is already landed". It is the header BUTTON's own + // shortcut, one level up from `items[]`, and the ruling does not touch it. + // + // PROVING REMOVAL: retire `AppAction.shortcut` too — over-applying this card + // to every `shortcut` in the file — and this row reds in both halves. + const _stillAString: Eq = true; + expect(_stillAString).toBe(true); + const action: AppAction = { type: 'button', label: 'Search', shortcut: 'Ctrl+K' }; + expect(action.shortcut).toBe('Ctrl+K'); + }); +}); + +/* ── (b) the zod mirror ───────────────────────────────────────────────────── */ + +describe('objectui#7719 — the JSON face refuses `shortcut` by name', () => { + const authored = { type: 'item' as const, label: 'Profile', shortcut: 'Ctrl+P' }; + + it('a menu item authoring `shortcut` is refused', () => { + // It parsed GREEN before this card — green, and one key lighter. That is the + // accept-set narrowing the changeset declares. + expect(MenuItemSchema.safeParse(authored).success).toBe(false); + }); + + it('the issue is addressed to `shortcut` and points the author at `NavigationItem`', () => { + // A refusal an author cannot act on is half a refusal. `retirementTombstone` + // feeds ONE guidance string into both the parse-time message and + // `.describe()`, so what an author reads and what generated docs publish + // cannot drift apart. + const r = MenuItemSchema.safeParse(authored); + expect(r.success).toBe(false); + if (r.success) return; + const issue = r.error.issues.find((i) => i.path[0] === 'shortcut'); + expect(issue, 'no issue was addressed to `shortcut`').toBeDefined(); + expect(issue!.message).toContain('NavigationItem'); + expect(issue!.message).toContain('objectui#7719'); + // ⚠️ The message must not read as though `shortcut` is refused on app + // actions generally — it is refused on the ITEMS. It says so by naming the + // surviving sibling explicitly. + expect(issue!.message).toContain('AppAction.shortcut'); + }); + + it('the refusal reports `invalid_type` at the key path — the `z.never` code, not `custom`', () => { + // Pins WHICH helper landed, from the outside. `handlerKeyRefusal`'s + // `z.custom` would report `custom` here; see block (f) for why that would + // also have broken JSON-Schema emission. + const r = MenuItemSchema.safeParse(authored); + expect(r.success).toBe(false); + if (r.success) return; + const issue = r.error.issues.find((i) => i.path[0] === 'shortcut'); + expect(issue!.code).toBe('invalid_type'); + }); + + it('CONTROL — the same document parses green with `shortcut` removed', () => { + // Says the refusal above is about this member and not about the fixture. + const { shortcut: _dropped, ...withoutShortcut } = authored; + expect(MenuItemSchema.safeParse(withoutShortcut).success).toBe(true); + }); + + it('CONTROL — the mirror still refuses a genuinely malformed item', () => { + // And says the green above is a reading rather than a mirror that accepts + // anything: `type` is an enum. + expect(MenuItemSchema.safeParse({ type: 'not-a-menu-item-type' }).success).toBe(false); + }); + + it('the refusal is inherited by nested `children`, which are the same schema', () => { + // `children: z.array(MenuItemSchema)` is recursive through `z.lazy`, so a + // refusal that only held at the top level would be a half-refusal. + const r = MenuItemSchema.safeParse({ + type: 'group', + label: 'More', + children: [{ label: 'Profile', shortcut: 'Ctrl+P' }], + }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues.some((i) => i.path.join('.') === 'children.0.shortcut')).toBe(true); + }); +}); + +/* ── (c) why it had to be a refusal — the SILENT STRIP, measured here ─────── */ + +describe('objectui#7719 — a bare non-declaration strips in silence; it refuses nothing', () => { + it('an UNDECLARED sibling key is accepted and STRIPPED — it never reaches `.data`', () => { + // THE LOAD-BEARING ROW of this file: without it, "leaving `shortcut` + // undeclared would not have refused it" is unfalsifiable prose. Here it is a + // reading taken on this very schema. `keyboardShortcut` is declared nowhere; + // the parse SUCCEEDS and the key is gone from the output. + // + // ⚠️ Deliberately different from objectui#7997's reading on + // `DetailViewSchema`, where the undeclared key SURVIVES into `.data` because + // `BaseSchemaCore` ends `.passthrough()`. This mirror has no base and no + // passthrough. Same conclusion, different mechanism; ⛔ do not copy that + // file's wording onto this surface. + // + // PROVING REMOVAL: append `.passthrough()` to the object inside the `z.lazy` + // and the second assertion reds (the key survives) while the first stays + // green — which is exactly how this row tells the two mechanisms apart. + const r = MenuItemSchema.safeParse({ type: 'item', label: 'Profile', keyboardShortcut: 'Ctrl+P' }); + expect(r.success).toBe(true); + if (!r.success) return; + expect(Object.keys(r.data as Record)).not.toContain('keyboardShortcut'); + }); + + it('and the tombstoned key does NOT get that treatment — the pair is the whole argument', () => { + // The same parse, one key swapped. Undeclared: accepted and stripped. + // Tombstoned: refused by name. That difference IS this card. + expect( + MenuItemSchema.safeParse({ type: 'item', label: 'Profile', shortcut: 'Ctrl+P' }).success, + ).toBe(false); + }); +}); + +/* ── (d) the real authoring position: `AppAction.items[]` ─────────────────── */ + +describe('objectui#7719 — the refusal holds where an author actually writes it', () => { + const action = { + type: 'user' as const, + label: 'Ada Lovelace', + items: [{ label: 'Profile', shortcut: 'Ctrl+P' }], + }; + + it('an app action whose item carries `shortcut` is refused at `items.0.shortcut`', () => { + // Block (b) parses `MenuItemSchema` directly; this one goes through the + // declaration an author actually writes, so a refusal that existed only on + // the standalone const would show up here as green. + const r = AppActionSchema.safeParse(action); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues.some((i) => i.path.join('.') === 'items.0.shortcut')).toBe(true); + }); + + it('CONTROL — the SAME action parses green carrying `shortcut` on the ACTION itself', () => { + // ⭐ The mirror half of block (a)'s non-target control, and the row that + // stops this card from being read as "shortcut is refused on app actions". + // The header button's own shortcut is declared, untouched, and still green. + // + // PROVING REMOVAL: move the tombstone one declaration up — onto + // `AppActionSchema` instead of `MenuItemSchema` — and this row reds while + // block (d)'s first row goes green. The pair localises the change to the + // right declaration, which a single row could not do. + const r = AppActionSchema.safeParse({ + type: 'user' as const, + label: 'Ada Lovelace', + shortcut: 'Ctrl+K', + items: [{ label: 'Profile' }], + }); + expect(r.success).toBe(true); + }); +}); + +/* ── (e) the renderer contract the ruling left ALONE ──────────────────────── */ + +describe('objectui#7719 — the eight authorable members are untouched: this narrows one key, not the face', () => { + it('every declared member of the legacy item still parses green', () => { + // ⛔ The ruling refused option C (re-typing `AppAction.items` to the overlay + // `MenuItem`), whose cost was precisely that `path` / `href` / `badge` / + // `type` would start being refused and the divider spelling would change. + // This row is what turns such a re-type red, and it is the reason a + // retirement pin is not allowed to only prove absence. + const r = MenuItemSchema.safeParse({ + type: 'item', + label: 'Profile', + icon: 'user', + path: '/profile', + href: 'https://example.com', + badge: 7, + hidden: false, + children: [{ type: 'item', label: 'Nested' }], + }); + expect(r.success).toBe(true); + }); + + it('the legacy divider spelling `{ "type": "separator" }` still parses green', () => { + // Option C would have moved this to `{ "separator": true }`. + expect(MenuItemSchema.safeParse({ type: 'separator' }).success).toBe(true); + }); +}); + +/* ── (f) the helper choice, pinned from the outside ───────────────────────── */ + +describe('objectui#7719 — `MenuItemSchema` stays representable in JSON Schema, because the arm is `z.never`', () => { + it('`z.toJSONSchema(MenuItemSchema)` still succeeds in both io directions', () => { + // MEASURED BEFORE THIS CHANGE: it succeeded. `handlerKeyRefusal`'s + // `z.custom` would have made it throw ("Custom types cannot be represented + // in JSON Schema") — the same reason objectui#7694 gives for `aliasKeyRefusal` + // not borrowing that primitive. This row is the helper choice stated as a + // consequence an outsider can check. + expect(() => z.toJSONSchema(MenuItemSchema, { io: 'input' })).not.toThrow(); + expect(() => z.toJSONSchema(MenuItemSchema, { io: 'output' })).not.toThrow(); + }); + + it('the emitted `shortcut` property is the unsatisfiable `{ not: {} }`, carrying the guidance', () => { + // ONE string, BOTH channels: the parse message asserted in block (b) and + // this published description are the same argument to `retirementTombstone`, + // so they cannot drift apart. + const js = z.toJSONSchema(MenuItemSchema, { io: 'input' }) as { + properties?: Record; + }; + const prop = js.properties?.shortcut; + expect(prop, 'no `shortcut` property was emitted').toBeDefined(); + expect(prop!.not).toEqual({}); + expect(prop!.description).toContain('NavigationItem'); + }); + + it('CONTROL — a `z.custom` arm really does throw, so the row above is not vacuous', () => { + // Without this, "we avoided `z.custom` for a reason" is a claim about a + // library rather than a reading of it. + expect(() => z.toJSONSchema(z.object({ k: z.custom(() => false).optional() }))).toThrow( + /cannot be represented in JSON Schema/, + ); + }); +}); diff --git a/packages/types/src/app.ts b/packages/types/src/app.ts index 108b410366..850ee60718 100644 --- a/packages/types/src/app.ts +++ b/packages/types/src/app.ts @@ -578,6 +578,39 @@ export interface AppMenuItem { * Visibility Condition */ hidden?: boolean | string; + + /** + * REFUSED (objectui#7719, director seat decision batch #70 of 2026-09-07, + * maintainer verbatim 「同意」, in the objectui#6124 / ADR-0049 shape). + * + * `shortcut` is not authorable on an app action ITEM. The ruling refused BOTH + * widening alternatives — growing this deprecated type a `shortcut` member + * (zero measured pull, and the type is being retired in favour of + * {@link NavigationItem}), and re-typing {@link AppAction.items} to the + * overlay `MenuItem`. What it changed is the DIAGNOSTIC: an authored value + * used to be stripped in silence by the zod mirror, and is now refused by + * name there, with this face's `never` refusing it at the authoring site + * before anything runs. + * + * ⚠️ NOT the same key as {@link AppAction.shortcut}, which is declared, + * authorable and deliberately untouched — that one is the header BUTTON's own + * shortcut, one level up from these items. This file declares `shortcut` TWICE, + * on two different interfaces, and reading one as the other is how the widening + * the ruling refused looks like work already done. ⛔ Resolve which declaration + * OWNS a hit before acting on it; a `grep` reports positions, and a position is + * not an owner. (⚠️ This paragraph deliberately states no ORDER between the two: + * an ordering claim is falsified by the next insertion into this file — including + * the one that introduced this very docblock, which reversed the order a previous + * draft of this sentence asserted.) + * + * ⛔ No read was re-added in the standalone runner's `LayoutRenderer`; the + * objectui#6854 pin stands. Both halves of this refusal are pinned in + * `__tests__/app-menu-item-shortcut-refusal-7719.test.ts`. + * + * @deprecated Not part of this contract — author the menu as a + * {@link NavigationItem} and put the shortcut capability there. + */ + shortcut?: never; } // ============================================================================ diff --git a/packages/types/src/zod/app.zod.ts b/packages/types/src/zod/app.zod.ts index 6bd61d6544..edba7cd23e 100644 --- a/packages/types/src/zod/app.zod.ts +++ b/packages/types/src/zod/app.zod.ts @@ -30,7 +30,7 @@ import { objectNavTargetExclusivity, } from '@objectstack/spec/ui'; import { BaseSchema, specFieldsExcept } from './base.zod.js'; -import { handlerKeyRefusal } from './tombstone.zod.js'; +import { handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; import type { AppMenuItem } from '../app.js'; import { stripImportedDefaults } from './imported-defaults.js'; @@ -274,6 +274,35 @@ export const MenuItemSchema: z.ZodType = z.lazy(() => children: z.array(MenuItemSchema).optional().describe('Child items (submenu)'), badge: z.union([z.string(), z.number()]).optional().describe('Badge or count'), hidden: z.union([z.boolean(), z.string()]).optional().describe('Visibility condition'), + // REFUSED (objectui#7719, director seat decision batch #70 of 2026-09-07, + // maintainer verbatim 「同意」). The ruling refused both widening options — a + // `shortcut` member on this deprecated type, and re-typing `AppAction.items` + // to the overlay `MenuItem` — and changed the DIAGNOSTIC instead: an authored + // value used to be stripped here in silence, and is refused by name now. + // + // WHY `retirementTombstone` and not `handlerKeyRefusal`, which the sibling + // `AppActionSchema.onClick` uses in this same file: that helper's message + // says JSON has no function value, which is false of a string-valued key, and + // its `z.custom` primitive makes `z.toJSONSchema` THROW. Measured: this const + // is representable today and `AppActionSchema` is NOT, precisely because of + // that `onClick` arm. Nor `aliasKeyRefusal`, which would have to name a + // canonical sibling key; the remedy here is a different TYPE. + // + // ⛔ No read was re-added in the standalone runner's `LayoutRenderer`; the + // objectui#6854 pin stands. Pinned in + // `../__tests__/app-menu-item-shortcut-refusal-7719.test.ts`. + shortcut: retirementTombstone( + 'REFUSED (objectui#7719, director seat decision batch #70, 2026-09-07; ADR-0049) — `shortcut` ' + + 'is not authorable on an app action ITEM. `AppAction.items` is this legacy `AppMenuItem` ' + + 'face, deprecated in favour of `NavigationItem`, and no renderer reads a shortcut here: the ' + + 'standalone runner renders an item\'s `label` and its `type: "separator"` and nothing else ' + + '(objectui#6854). Until this refusal an authored value was STRIPPED in silence by this ' + + 'mirror, which is the outcome the ruling closed; growing the deprecated type instead was ' + + 'refused. NOT to be confused with `AppAction.shortcut`, which is declared, authorable and ' + + 'unchanged — that is the header BUTTON\'s own shortcut, one level up from these items. ' + + 'A keyboard shortcut on a navigation entry is a capability of the `NavigationItem` line: ' + + 'author the menu as `NavigationItem`, and file the capability there if it is wanted.', + ), })); // ============================================================================