From a4ad73543bdaeff9e09f93257f6054fa1e49ba1f Mon Sep 17 00:00:00 2001 From: os-sam Date: Mon, 31 Aug 2026 02:35:32 +0000 Subject: [PATCH 1/2] feat(types)!: retire ComponentInput's four inert constraint keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `min`, `max`, `step` and `placeholder` were declared on `ComponentInput` and read by nothing, on either the consumption or the publication path. The manifest serializer forwards exactly six keys per input — `name`, `type`, `required`, `enum`, `binding`, `description` — so an authored value could not reach the published `sdui.manifest.json` even in principle. Retired as ADR-0049 tombstones rather than deletions: `?: never` on the interface (a `tsc` error at the authoring site) and `retirementTombstone()` on the Zod mirror (a parse refusal carrying its own migration note). A deletion would have been stripped silently by the non-strict mirror — one silent no-op traded for another. `inputType` is deliberately NOT retired: `plugin-markdown` authors it, which makes it declared-and-dropped, a different defect needing a ruling. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- ...5-componentinput-retire-constraint-keys.md | 51 +++++ ...nent-input-retired-constraint-keys.test.ts | 197 ++++++++++++++++++ packages/types/src/base.ts | 94 +++++++-- packages/types/src/zod/base.zod.ts | 53 ++++- 4 files changed, 373 insertions(+), 22 deletions(-) create mode 100644 .changeset/5905-componentinput-retire-constraint-keys.md create mode 100644 packages/types/src/__tests__/component-input-retired-constraint-keys.test.ts diff --git a/.changeset/5905-componentinput-retire-constraint-keys.md b/.changeset/5905-componentinput-retire-constraint-keys.md new file mode 100644 index 0000000000..b5fe614c7b --- /dev/null +++ b/.changeset/5905-componentinput-retire-constraint-keys.md @@ -0,0 +1,51 @@ +--- +'@object-ui/types': minor +--- + +Retire `ComponentInput`'s four inert constraint keys — `min`, `max`, `step` and +`placeholder` (objectui#5905, ADR-0049 enforce-or-remove). + +All four were declared on `ComponentInput` and read by nothing, on either path. No consumer +reads them off a `ComponentInput` value, and the manifest serializer +(`packages/sdui-parser/src/index.ts`) forwards exactly six keys per input — `name`, `type`, +`required`, `enum`, `binding`, `description` — so a value authored here could not reach the +published `sdui.manifest.json` even in principle. Re-measured on this branch's merge-base +rather than inherited from the card: a structural census over every `inputs:` array in the +repository (219 regions, all tracked files) scores `min` **0**, `max` **0**, `step` **0** +and `placeholder` **0**, against `name` 926, `type` 926, `description` 161, `enum` 114 and +`required` 87 in the same pass over the same regions — the instrument was not blind. + +FROM → TO, per key: + +- `min: number` → **removed**. Spell the numeric domain out in `description`, which IS + published (`'A positive integer — the contract rejects 0 and fractional values'`). +- `max: number` → **removed**. Same remedy. +- `step: number` → **removed**. Same remedy. +- `placeholder: string` → **removed**. Put the hint in `description`. ⚠️ + `BaseSchema.placeholder` — the node-level prop a renderer does read — is a DIFFERENT key + and is unaffected. + +The retirement kit: `?: never` on the interface (`packages/types/src/base.ts`), so authoring +one is a `tsc` error at the registration site; `retirementTombstone()` on the Zod mirror +(`packages/types/src/zod/base.zod.ts`), so an authored value is REFUSED at parse time with +`code: 'invalid_type'`, the key named in the issue `path`, and the migration note as the +message. Deleting the members outright was the option NOT taken: `ComponentInputSchema` is +a non-strict `z.object`, which strips an undeclared key silently — one silent no-op traded +for another. Pinned in +`packages/types/src/__tests__/component-input-retired-constraint-keys.test.ts`. + +Two limits worth stating rather than papering over: + +- The in-repo zero is what was measured. Whether anything OUTSIDE this repository writes + these keys is **not measurable from here** (the same limit objectui#5674 recorded for + `PluginComponentInput`). Converting such a write from a silent drop into a named refusal + is exactly what the tombstone buys. +- The fifth key objectui#5905 named, `inputType`, is **NOT retired here**. + `packages/plugin-markdown` authors it (`inputType: 'textarea'`), so it is + declared-and-DROPPED — a different defect that needs a ruling, not a removal. + +This is not a verdict that constraint slots on `ComponentInput` were a mistake. The +neighbouring `type` field carries a maintainer ruling of 2026-08-17 recording that giving +`ComponentInput` real constraint slots was **deferred, not rejected** — `min`/`max`/`step` +read exactly like the slots that ruling declined to add. What is retired is this inert +spelling; the ruling's own reopen condition still stands. diff --git a/packages/types/src/__tests__/component-input-retired-constraint-keys.test.ts b/packages/types/src/__tests__/component-input-retired-constraint-keys.test.ts new file mode 100644 index 0000000000..11ecf9ebc4 --- /dev/null +++ b/packages/types/src/__tests__/component-input-retired-constraint-keys.test.ts @@ -0,0 +1,197 @@ +/** + * 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. + */ + +/** + * `ComponentInput`'s four inert constraint keys are ADR-0049 RETIREMENT + * TOMBSTONES, and the refusal is LOUD (objectui#5905). + * + * ## What was measured + * + * `min` / `max` / `step` / `placeholder` were declared on `ComponentInput` and + * read by nothing, on either path: + * + * - no consumer reads them off a `ComponentInput` value; and + * - the manifest serializer (`packages/sdui-parser/src/index.ts`) forwards + * exactly six keys per input — `name`, `type`, `required`, `enum`, + * `binding`, `description` — so an authored value could not reach the + * published `sdui.manifest.json` even in principle. + * + * A structural census over EVERY `inputs:` array in the repository found zero + * authoring sites for the four; the same pass, over the same regions, counted + * 926 `name`, 926 `type` and 161 `description` sites, so the instrument was + * demonstrably not blind. Authorship from OUTSIDE this repository is not + * measurable from here (the limit objectui#5674 recorded for + * `PluginComponentInput`) — and that unmeasurable half is precisely what the + * tombstone serves: an outside write becomes a NAMED REFUSAL carrying its own + * remedy instead of a silent drop. + * + * ## Why tombstones and not deletions + * + * `ComponentInputSchema` is a NON-STRICT `z.object`, so a deleted key would be + * silently STRIPPED — one silent no-op traded for another. The tombstone keeps + * the key declared and unwritable: `?: never` on the interface (a `tsc` error + * at the authoring site) and `retirementTombstone()` on the mirror (a parse + * refusal whose message IS the migration note). Both halves are pinned below, + * plus the CONTRAST against a genuinely undeclared key, so nobody can "simplify" + * the tombstones into deletions without this file going red. + * + * ## `inputType` is NOT here, deliberately + * + * The fifth key objectui#5905 named is still live and still writable, because + * the repository AUTHORS it: `packages/plugin-markdown/src/index.tsx` declares + * `inputType: 'textarea'` on its `content` input (pinned by that package's own + * test). That is declared-and-DROPPED — a different defect from the + * declared-and-unread four — and it needs a ruling, not a removal. Its liveness + * is pinned below so the fork stays visible and closing it stays a deliberate + * edit to this file. + * + * The `@ts-expect-error` directives are REAL enforcement: this package + * type-checks its tests through `tsconfig.test.json`, so re-widening the + * declaration fails the build on the unused directive. + */ + +import { describe, it, expect } from 'vitest'; +import type { ComponentInput } from '../base'; +import { ComponentInputSchema } from '../zod/base.zod'; + +/** The four retired keys, with a value an author would plausibly have written. */ +const RETIRED = { + min: 0, + max: 100, + step: 1, + placeholder: 'Type here…', +} as const; + +type RetiredKey = keyof typeof RETIRED; + +/** A fully live input — every key here is declared AND forwarded by the serializer. */ +const LIVE_INPUT = { + name: 'content', + type: 'string', + label: 'Markdown Content', + required: true, + description: 'A positive integer — the contract rejects 0 and fractional values', +} as const; + +const shapeOf = (schema: unknown): Record => + (schema as { shape: Record }).shape; + +const describeOf = (schema: unknown, key: string): string | undefined => + (shapeOf(schema)[key] as { description?: string } | undefined)?.description; + +/* ── type-level pins: the `tsc` channel ──────────────────────────────────── */ + +describe('the interface tombstones make authoring a `tsc` error', () => { + it('refuses each retired key at the authoring site', () => { + const input: ComponentInput = { + name: 'content', + type: 'string', + // @ts-expect-error `min` is a retirement tombstone (objectui#5905) + min: 0, + // @ts-expect-error `max` is a retirement tombstone (objectui#5905) + max: 100, + // @ts-expect-error `step` is a retirement tombstone (objectui#5905) + step: 1, + // @ts-expect-error `placeholder` is a retirement tombstone (objectui#5905) + placeholder: 'Type here…', + }; + expect(input.name).toBe('content'); + }); + + it('keeps `inputType` WRITABLE — the fork objectui#5905 reported, not an oversight', () => { + // No `@ts-expect-error`: `plugin-markdown` authors this key today, so + // retiring it is a ruling about that registration, not a cleanup. If this + // line ever needs a directive, the fork was closed — say so on the card. + const input: ComponentInput = { name: 'content', type: 'string', inputType: 'textarea' }; + expect(input.inputType).toBe('textarea'); + }); +}); + +/* ── the mirror refuses, and the refusal carries its remedy ──────────────── */ + +describe('the zod tombstones REFUSE, loudly (objectui#5905)', () => { + it('a fully live input still parses GREEN — the non-vacuity control, in this test', () => { + // Without this, a mirror that refused everything would satisfy every + // assertion below by accident. + const control = ComponentInputSchema.safeParse(LIVE_INPUT); + expect(control.success).toBe(true); + if (control.success) { + expect(control.data.name).toBe('content'); + expect(control.data.description).toBe(LIVE_INPUT.description); + } + }); + + it('`inputType` still parses green — the fork half of the same control', () => { + const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, inputType: 'textarea' }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.inputType).toBe('textarea'); + }); + + for (const key of Object.keys(RETIRED) as RetiredKey[]) { + it(`refuses \`${key}\`, names it in the path, and answers with its own guidance`, () => { + const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, [key]: RETIRED[key] }); + expect(result.success, key).toBe(false); + if (result.success) return; + + const issue = result.error.issues.find((i) => String(i.path[0]) === key); + expect(issue, `no issue addressed to \`${key}\``).toBeDefined(); + + // The accept-set contract: same address, same code a bare `z.never()` + // reports. A `refine`-based spelling would report `custom` and was + // rejected for exactly that reason (objectui#6105). + expect(issue!.code, key).toBe('invalid_type'); + expect(issue!.path, key).toEqual([key]); + + // The message is the migration note, not zod's generic string. + expect(issue!.message, key).not.toContain('Invalid input: expected never, received '); + expect(issue!.message, key).toContain('RETIRED (objectui#5905)'); + expect(issue!.message, key).toContain(`\`ComponentInput.${key}\``); + expect(issue!.message, key).toContain('`description`'); + + // ONE string, BOTH channels — the invariant `retirementTombstone()` + // exists to make unbreakable. Asserted derived (nothing hand-copied to + // rot), which is why the literal anchors above sit beside it: two empty + // strings are also equal. + expect(issue!.message, key).toBe(describeOf(ComponentInputSchema, key)); + }); + } + + it('`placeholder` answers with the full string, including the `BaseSchema` disambiguation', () => { + // One member pinned as a LITERAL so the derived assertions above cannot all + // drift together. `BaseSchema.placeholder` is a different, live key — an + // author who trips this one must not read it as that one being retired. + const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, placeholder: 'Type here…' }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe( + 'RETIRED (objectui#5905) — `ComponentInput.placeholder` was never read, and never published: ' + + 'the manifest serializer forwards `name`/`type`/`required`/`enum`/`binding`/`description` and ' + + 'this is not one of them, so an authored value was silently dropped. Delete the key; put the ' + + 'hint in `description`, which IS published. `BaseSchema.placeholder`, the node-level prop, is ' + + 'a DIFFERENT key and is unaffected.', + ); + } + }); +}); + +/* ── the contrast a deletion would have produced ─────────────────────────── */ + +describe('a tombstone is not a deletion — the contrast, measured in one run', () => { + it('an UNDECLARED key is silently stripped, which is what deleting these four would have bought', () => { + const result = ComponentInputSchema.safeParse({ ...LIVE_INPUT, notAKeyAtAll: 'anything' }); + expect(result.success).toBe(true); + if (result.success) expect(result.data).not.toHaveProperty('notAKeyAtAll'); + }); + + it('the four stay in the mirror\'s shape — a tombstone is DECLARED, just unwritable', () => { + for (const key of Object.keys(RETIRED)) { + expect(shapeOf(ComponentInputSchema)).toHaveProperty(key); + expect(describeOf(ComponentInputSchema, key)).toContain('RETIRED (objectui#5905)'); + } + }); +}); diff --git a/packages/types/src/base.ts b/packages/types/src/base.ts index bf5ca5162e..780e910ec4 100644 --- a/packages/types/src/base.ts +++ b/packages/types/src/base.ts @@ -544,28 +544,86 @@ export interface ComponentInput { /** * Specific input type (e.g., 'email', 'password' for string) + * + * ⚠️ NOT retired alongside the four tombstones below (objectui#5905), and the + * difference is measured rather than stylistic. `plugin-markdown`'s + * registration AUTHORS this key — `inputs: [{ name: 'content', …, inputType: + * 'textarea' }]` in `packages/plugin-markdown/src/index.tsx`, pinned by that + * package's own test — while the manifest serializer still drops it. That is + * declared-and-DROPPED, a different defect from the declared-and-unread four + * below: retiring it would convert one registration's silent no-op into a + * build failure without first deciding what that registration should say + * instead (delete the line, or teach the publication path to carry it). The + * fork is recorded on objectui#5905 for a ruling; until then this stays a + * live, writable key that nothing publishes. */ inputType?: string; /** - * Minimum value (for number/date) - */ - min?: number; - - /** - * Maximum value (for number/date) - */ - max?: number; - - /** - * Step value (for number) - */ - step?: number; - - /** - * Placeholder text - */ - placeholder?: string; + * ADR-0049 RETIREMENT TOMBSTONES — `min` / `max` / `step` / `placeholder` + * (objectui#5905). + * + * `?: never` is this package's tombstone convention (see `crud.ts` `confirm` + * and {@link StaticTableColumn} in `data-display.ts`): the key stays + * DECLARED and becomes UNWRITABLE, so authoring one is a `tsc` error here and + * a named parse refusal in the Zod twin (`zod/base.zod.ts` + * `ComponentInputSchema`, via `retirementTombstone()`). Deleting the members + * outright would have been the quiet option — an undeclared key is silently + * stripped by the non-strict mirror, which trades one silent no-op for + * another. + * + * What was measured (objectui#5905, re-measured on the merge-base of the + * retiring PR): no consumer reads any of the four, and the manifest + * serializer (`packages/sdui-parser/src/index.ts`) forwards exactly six keys + * per input — `name`, `type`, `required`, `enum`, `binding`, `description` — + * so a value authored here could not reach the published + * `sdui.manifest.json` even in principle. A structural census over every + * `inputs:` array in the repository found ZERO authoring sites for the four + * (the same pass counted 926 `name`, 926 `type` and 161 `description` sites, + * so the instrument was not blind). Authorship from OUTSIDE the repository is + * not measurable from here — the limit objectui#5674 recorded for + * `PluginComponentInput` — and converting such a write from a silent drop + * into a NAMED REFUSAL is exactly what these tombstones buy. + * + * ⚠️ Why a future reader must NOT read this as "these keys were a mistake": + * the neighbouring `type` field carries a maintainer ruling of 2026-08-17 + * (quoted in full above) recording that giving `ComponentInput` real + * constraint slots was **DEFERRED, NOT REJECTED** — two sources of truth, + * free to drift, was the stated cost. `min` / `max` / `step` read exactly + * like the slots that ruling declined to add. What is retired is this inert + * spelling of them, not the idea; the ruling's own reopen condition (a + * measured case of an author shipping a spec-rejected value objectui's + * silence let through) is still the route back. + * + * RETIRED (objectui#5905, ADR-0049) — never read, and never published: the + * manifest serializer forwards six keys and this is not one of them. Spell + * the numeric domain out in `description`, which IS published. + * @deprecated Not part of `ComponentInput`'s contract — the value was inert. + */ + min?: never; + /** + * RETIRED (objectui#5905, ADR-0049) — never read, and never published: the + * manifest serializer forwards six keys and this is not one of them. Spell + * the numeric domain out in `description`, which IS published. + * @deprecated Not part of `ComponentInput`'s contract — the value was inert. + */ + max?: never; + /** + * RETIRED (objectui#5905, ADR-0049) — never read, and never published: the + * manifest serializer forwards six keys and this is not one of them. Spell + * the numeric domain out in `description`, which IS published. + * @deprecated Not part of `ComponentInput`'s contract — the value was inert. + */ + step?: never; + /** + * RETIRED (objectui#5905, ADR-0049) — never read, and never published: the + * manifest serializer forwards six keys and this is not one of them. Put the + * hint in `description`, which IS published. `BaseSchema.placeholder` — the + * node-level prop a renderer does read — is a DIFFERENT key and is + * unaffected. + * @deprecated Not part of `ComponentInput`'s contract — the value was inert. + */ + placeholder?: never; } /** diff --git a/packages/types/src/zod/base.zod.ts b/packages/types/src/zod/base.zod.ts index a648f06926..c82523ed33 100644 --- a/packages/types/src/zod/base.zod.ts +++ b/packages/types/src/zod/base.zod.ts @@ -18,6 +18,7 @@ import { z } from 'zod'; import { I18nLabelSchema } from '@objectstack/spec/ui'; +import { retirementTombstone } from './tombstone.zod.js'; /** * A KEYED i18n label — the runtime mirror of `KeyedI18nLabel` in `../base.ts`. @@ -299,11 +300,55 @@ export const ComponentInputSchema = z.object({ ]).optional().describe('Enum options'), description: z.string().optional().describe('Help text'), advanced: z.boolean().optional().describe('Advanced option flag'), + /** + * ⚠️ NOT retired with the four tombstones below (objectui#5905): + * `plugin-markdown`'s registration authors it, so it is declared-and-DROPPED + * rather than declared-and-unread. See `ComponentInput.inputType` in + * `../base.ts` for the fork and what a ruling on it has to decide. + */ inputType: z.string().optional().describe('Specific input type'), - min: z.number().optional().describe('Minimum value'), - max: z.number().optional().describe('Maximum value'), - step: z.number().optional().describe('Step value'), - placeholder: z.string().optional().describe('Placeholder text'), + /** + * ADR-0049 RETIREMENT TOMBSTONES (objectui#5905) — `min` / `max` / `step` / + * `placeholder`, the four `ComponentInput` keys measured with no reader on + * either the consumption or the publication path. + * + * `retirementTombstone()` (`./tombstone.zod.ts`) writes each guidance string + * ONCE into both author-facing channels — the parse-time issue message and + * `.describe()`, which feeds generated JSON-Schema and docs — so the two + * cannot drift. Without a tombstone the non-strict mirror would SILENTLY + * STRIP an authored value, trading one silent no-op for another; with it, a + * write from outside this repository (the half objectui#5905 could not + * measure) arrives as a NAMED REFUSAL carrying its own remedy. + * + * The accept set is the point, not a side effect: issue `code` stays + * `invalid_type` and the issue `path` names the key. A `refine`-based + * spelling would report `custom` and was rejected for exactly that reason + * (objectui#6105). + */ + min: retirementTombstone( + 'RETIRED (objectui#5905) — `ComponentInput.min` was never read, and never published: the manifest ' + + 'serializer forwards `name`/`type`/`required`/`enum`/`binding`/`description` and this is not one of them, ' + + 'so an authored value was silently dropped. Delete the key; spell the numeric domain out in `description`, ' + + 'which IS published.', + ), + max: retirementTombstone( + 'RETIRED (objectui#5905) — `ComponentInput.max` was never read, and never published: the manifest ' + + 'serializer forwards `name`/`type`/`required`/`enum`/`binding`/`description` and this is not one of them, ' + + 'so an authored value was silently dropped. Delete the key; spell the numeric domain out in `description`, ' + + 'which IS published.', + ), + step: retirementTombstone( + 'RETIRED (objectui#5905) — `ComponentInput.step` was never read, and never published: the manifest ' + + 'serializer forwards `name`/`type`/`required`/`enum`/`binding`/`description` and this is not one of them, ' + + 'so an authored value was silently dropped. Delete the key; spell the numeric domain out in `description`, ' + + 'which IS published.', + ), + placeholder: retirementTombstone( + 'RETIRED (objectui#5905) — `ComponentInput.placeholder` was never read, and never published: the manifest ' + + 'serializer forwards `name`/`type`/`required`/`enum`/`binding`/`description` and this is not one of them, ' + + 'so an authored value was silently dropped. Delete the key; put the hint in `description`, which IS ' + + 'published. `BaseSchema.placeholder`, the node-level prop, is a DIFFERENT key and is unaffected.', + ), }); /** From 33846a77dac43d3c12f5615f1ed60fb8377cb173 Mon Sep 17 00:00:00 2001 From: os-sam Date: Mon, 31 Aug 2026 02:40:20 +0000 Subject: [PATCH 2/2] docs(types): record the four tombstones in WidgetInput's divergence block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block enumerated five keys `ComponentInput` carries that `WidgetInput` does not. Four of them are now tombstones, so "five keys this face declines to copy" became "one live key plus four unwritable ones" — stated where the next reader of that divergence will look. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- packages/types/src/widget.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/types/src/widget.ts b/packages/types/src/widget.ts index 6eaa2b1c08..1db9c9c797 100644 --- a/packages/types/src/widget.ts +++ b/packages/types/src/widget.ts @@ -219,6 +219,16 @@ export interface WidgetSourceRegistry { * these. Copying them here would mirror surface that nothing reads on the * face it already lives on. * + * ⚠️ FOUR of those five are now ADR-0049 RETIREMENT TOMBSTONES on + * `ComponentInput` (`min` / `max` / `step` / `placeholder` — `?: never` plus + * a named Zod refusal, objectui#5905), so what this clause records is no + * longer "five keys this face declines to copy" but ONE live key + * (`inputType`) plus four unwritable ones. Copying any of them here is now + * doubly wrong: the four are REFUSED on the face they already live on, and + * `inputType` is the open fork objectui#5905 reported — `plugin-markdown` + * authors it and the serializer still drops it, which is a ruling to make, + * not a surface to mirror. + * * Pin: `__tests__/widget-input-control-vocabulary.test.ts`. */ export interface WidgetInput {