From 691aa71ba09d8fa24d39bec4d9df41f8cf6764f8 Mon Sep 17 00:00:00 2001 From: os-warren Date: Thu, 10 Sep 2026 20:27:30 +0000 Subject: [PATCH] fix(types): refuse `inputType` by name on the `email` / `password` shorthands (objectui#8762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `packages/components/src/renderers/form/input.tsx` registers both shorthands by wrapping the `input` renderer and spreading its own `inputType` LAST, so an authored value is overwritten before the renderer reads it. `BaseSchema` is `.passthrough()`, so `{ "type": "password", "inputType": "text" }` parsed green, kept the key in `safeParse`'s output, and rendered a MASKED field anyway — a validated key the runtime discards, with no diagnostic. objectui#8499 gave this arm its declared face with the key omitted and recorded that omission is not refusal. This closes it: the key stays DECLARED and unwritable on both faces, refused by name via `retirementTombstone`, with one guidance string feeding both the parse-time message and `.describe()` and pointing at `{ "type": "input", "inputType": "email" }` — the spelling that is read. One rule, not an enumeration: a single member on the one arm whose `type` is an enum, so a literal added later inherits the refusal. The new types pin compares that enum against the registration site, so a third shorthand cannot diverge in silence. The wrapper's precedence is deliberately NOT flipped: letting the author win would render `{ "type": "password", "inputType": "text" }` as an UNMASKED field under a `password` key. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- .../8762-shorthand-input-type-refusal.md | 43 ++++ ...orthand-input-type-discarded-8762.test.tsx | 113 +++++++++ .../shorthand-input-type-refusal-8762.test.ts | 227 ++++++++++++++++++ packages/types/src/form.ts | 29 ++- packages/types/src/zod/form.zod.ts | 74 +++++- 5 files changed, 469 insertions(+), 17 deletions(-) create mode 100644 .changeset/8762-shorthand-input-type-refusal.md create mode 100644 packages/components/src/renderers/form/__tests__/shorthand-input-type-discarded-8762.test.tsx create mode 100644 packages/types/src/__tests__/shorthand-input-type-refusal-8762.test.ts diff --git a/.changeset/8762-shorthand-input-type-refusal.md b/.changeset/8762-shorthand-input-type-refusal.md new file mode 100644 index 0000000000..f0ceda92d3 --- /dev/null +++ b/.changeset/8762-shorthand-input-type-refusal.md @@ -0,0 +1,43 @@ +--- +'@object-ui/types': minor +--- + +`InputShorthandSchema` (and its TS twin) now REFUSES `inputType` on the `email` / `password` +node shorthands BY NAME and points at `{ "type": "input", "inputType": "email" }` — the +spelling that is actually read (objectui#8762). + +⚠️ Shipped as `minor`, not `patch`, because this is a NARROWING of a published accept +surface, and the precedent it follows is objectui#7694's named alias refusal, which says the +same thing in the same words. (`major` is not available: AGENTS.md §版本号策略 keeps this +repository's major aligned with `@objectstack`, so objectui's own breaking changes ship as +`minor` with the breaking semantics spelled out in the body — mechanically enforced by +`scripts/check-changeset-no-major.mjs`.) + +- **Before:** `{ "type": "password", "inputType": "text" }` validated green, and the renderer + drew a MASKED field anyway. `packages/components/src/renderers/form/input.tsx` registers both + shorthands by wrapping the `input` renderer and spreading its own `inputType` LAST, so the + authored value was overwritten before the renderer read it. `BaseSchema` is `.passthrough()`, + so the key even survived into `safeParse`'s output. Nothing anywhere said so. +- **After:** the same document is refused at `path: ['inputType']` with guidance naming the + key, the reason, and the spelling to write instead. One string feeds both the parse-time + message and `.describe()`, so the generated docs cannot drift from the error. + +**What is NOT changed, and was measured to make sure:** + +- `{ "type": "input", "inputType": "…" }` — the honoured spelling and the one the guidance + points at — parses and renders exactly as before, at every value. +- `{ "type": "email" }` / `{ "type": "password" }` without the key are untouched, and every + other key on the arm is still judged. +- A form FIELD is a different position with the opposite precedence: inside `fields: [ … ]`, + `{ "name": "contact", "type": "email", "inputType": "text" }` still renders a text box, and + this refusal does not reach there. The message says so, and the sentence is pinned. +- ⛔ The wrapper's precedence is deliberately NOT flipped. Letting an authored `inputType` win + would render `{ "type": "password", "inputType": "text" }` as an UNMASKED field under a + `password` key — a secret in clear text, which is worse than refusing the key. + +**Who this can break.** Any document authoring `inputType` on an `email` / `password` node +stops validating — that is the defect surfacing, not collateral damage, since the value never +did anything. A census of every corpus in this repository found NONE: over 546 parsed JSON +documents plus every fenced JSON block in `content/**` and the package READMEs, six nodes +carry a shorthand `type` and not one of them authors `inputType`. The `hotcrm` and +`objectstack` corpora are outside this repository and were NOT measured. diff --git a/packages/components/src/renderers/form/__tests__/shorthand-input-type-discarded-8762.test.tsx b/packages/components/src/renderers/form/__tests__/shorthand-input-type-discarded-8762.test.tsx new file mode 100644 index 0000000000..b4675bd433 --- /dev/null +++ b/packages/components/src/renderers/form/__tests__/shorthand-input-type-discarded-8762.test.tsx @@ -0,0 +1,113 @@ +/** + * 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. + */ + +/** + * The runtime half of objectui#8762: an `inputType` authored on the `email` / + * `password` NODE shorthands never reaches the DOM, while the two neighbours + * that DO honour the key keep honouring it. + * + * ## Why this file exists beside the refusal and not instead of it + * + * `@object-ui/types` refuses the key by name (`zod/form.zod.ts#InputShorthandSchema`, + * pinned in `packages/types/src/__tests__/shorthand-input-type-refusal-8762.test.ts`). + * That refusal REASONS from a runtime fact — the registration wrapper spreads its + * own `inputType` last — and `@object-ui/types` cannot render React, so nothing + * over there could check the reason. Measured on this card's base (681d3f10e), + * before the refusal landed: + * + * { type: 'password', inputType: 'text' } -> DISCARDED + * { type: 'email', inputType: 'text' } -> DISCARDED + * { type: 'input', inputType: 'text' } -> HONOURED + * + * ⚠️ The documents in the first two rows are REFUSED by the validator as of this + * card. Rendering them here is deliberate and is the point: the refusal is + * warranted exactly because the runtime ignores what they wrote. + * + * ## ⛔ What must NOT be "fixed" to make these pass + * + * The cheaper-looking repair is to flip the wrapper's precedence so the author's + * value wins. That would render `{ type: 'password', inputType: 'text' }` as an + * UNMASKED field under a `password` key — a secret in clear text, which is worse + * than refusing the key. If a change makes the first two rows "honour" the + * authored value, this file is the one that must go red. + */ + +import { describe, it, expect } from 'vitest'; +import { renderComponent } from '../../../__tests__/test-utils'; +// Registers the renderers at module scope, NOT inside a `beforeAll` — there the +// cold transform is billed to `hookTimeout` (objectui#3010/#3021). +import '../../../renderers'; + +/** The `type` attribute the rendered `` actually carries. */ +function renderedInputType(schema: Record): string | null { + const { container } = renderComponent(schema as never); + const input = container.querySelector('input'); + expect(input, `no rendered for ${JSON.stringify(schema)}`).toBeTruthy(); + return input!.getAttribute('type'); +} + +describe('objectui#8762 — the shorthand wrapper discards an authored `inputType`', () => { + it.each([ + ['password', 'text'], + ['password', 'email'], + ['email', 'text'], + ['email', 'password'], + ])('`%s` renders its pinned type, not the authored `%s`', (type, authored) => { + const rendered = renderedInputType({ type, inputType: authored, name: 'x' }); + expect(rendered).toBe(type); + expect(rendered, 'the wrapper stopped pinning — see the ⛔ note in this header') + .not.toBe(authored); + }); + + it.each(['email', 'password'])('`%s` renders its pinned type with nothing authored', (type) => { + // The baseline the pin is measured against: the wrapper's value is what a + // correct document gets, and that is unchanged by this card. + expect(renderedInputType({ type, name: 'x' })).toBe(type); + }); +}); + +describe('objectui#8762 — the neighbours that DO honour `inputType`', () => { + it.each(['text', 'email', 'password', 'tel', 'url'])( + '`input` honours an authored `%s` — the spelling the refusal points at', + (authored) => { + // ⭐ THE FIRING CONTROL for the whole card. `{ "type": "input", "inputType": + // "email" }` is what the refusal's guidance tells the author to write, so a + // narrowing that also broke it would be worse than the defect it repairs. + expect(renderedInputType({ type: 'input', inputType: authored, name: 'x' })).toBe(authored); + }, + ); + + it('a form FIELD keeps the OPPOSITE precedence — the carve-out the guidance claims', () => { + // `renderers/form/form.tsx`: `type={inputType || NATIVE_INPUT_FIELD_TYPES[declaredType] || 'text'}` + // — at a FIELD position the authored value wins over the one the field type + // implies. The refusal's message says so, so the sentence is checked here + // rather than left as prose that can rot. Same two literals, different + // position, different answer. + const { container } = renderComponent({ + type: 'form', + fields: [{ name: 'contact', label: 'Contact', type: 'email', inputType: 'text' }], + } as never); + const input = container.querySelector('input[name="contact"], input#contact, input'); + expect(input, 'no field control rendered').toBeTruthy(); + expect(input!.getAttribute('type')).toBe('text'); + }); + + it('and a form field with NO authored `inputType` still masks a secret', () => { + // The control for the control: the carve-out above must not read as "a field + // ignores its declared type". A plain `{ name, type: 'password' }` authors no + // `inputType`, and `NATIVE_INPUT_FIELD_TYPES` is what keeps it masked + // (objectui#5254 / #5375) — without it the secret would render as clear text. + const { container } = renderComponent({ + type: 'form', + fields: [{ name: 'secretField', label: 'Secret', type: 'password' }], + } as never); + const input = container.querySelector('input'); + expect(input, 'no field control rendered').toBeTruthy(); + expect(input!.getAttribute('type')).toBe('password'); + }); +}); diff --git a/packages/types/src/__tests__/shorthand-input-type-refusal-8762.test.ts b/packages/types/src/__tests__/shorthand-input-type-refusal-8762.test.ts new file mode 100644 index 0000000000..23d2ec75fa --- /dev/null +++ b/packages/types/src/__tests__/shorthand-input-type-refusal-8762.test.ts @@ -0,0 +1,227 @@ +/** + * 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. + */ + +/** + * `inputType` is refused BY NAME on the `email` / `password` shorthands, and the + * neighbour that HONOURS the key still parses (objectui#8762). + * + * ## The defect + * + * `packages/components/src/renderers/form/input.tsx` registers both shorthands by + * wrapping the `input` renderer and spreading its own `inputType` LAST, so an + * authored value is overwritten before the renderer reads it. objectui#8499 gave + * the arm its declared face with the key OMITTED, and recorded that omission is + * not refusal while `BaseSchema` is `.passthrough()`. Measured on this card's base + * (681d3f10e) before the change: + * + * ACCEPT { type: 'password', inputType: 'text' } parsed data KEPT inputType: 'text' + * ACCEPT { type: 'input', inputType: 'text' } <- the control, where the key IS read + * + * DOM { type: 'password', inputType: 'text' } -> DISCARDED + * DOM { type: 'input', inputType: 'text' } -> HONOURED + * + * So the author wrote a key, every check passed, and the runtime threw the value + * away — the class-(c) trap. This file pins the repair and, just as importantly, + * the CONTROL: a narrowing with nothing proving the neighbour still parses is not + * a measurement. + * + * ## What is deliberately NOT pinned here + * + * ⛔ Not the wrapper's precedence being flipped so the author wins — that would + * render `{ type: 'password', inputType: 'text' }` as an UNMASKED field under a + * `password` key, which is worse than refusing it. The runtime half of the + * evidence (both precedences, in the DOM) lives beside the renderer, in + * `packages/components/src/renderers/form/__tests__/shorthand-input-type-discarded-8762.test.tsx`; + * this package cannot render React. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { z } from 'zod'; + +import { AnyComponentSchema } from '../zod/index.zod.js'; +import { InputSchema, InputShorthandSchema } from '../zod/form.zod.js'; + +// Root the file reads on THIS FILE, never on `process.cwd()` — the two test +// invocation forms give cwd two different values (AGENTS.md §怎么跑测试). +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..', '..'); +const INPUT_RENDERER = 'packages/components/src/renderers/form/input.tsx'; +const read = (relative: string): string => readFileSync(join(REPO_ROOT, relative), 'utf8'); + +/** The `type` literals a schema declares to Zod's discriminator dispatch. */ +function literalsOf(schema: unknown): string[] { + const values = (schema as { _zod?: { propValues?: { type?: Set } } })._zod?.propValues + ?.type; + return values === undefined ? [] : [...values]; +} + +const SHORTHANDS = ['email', 'password'] as const; + +function issuesFor(doc: unknown, schema: z.ZodType = AnyComponentSchema) { + const result = schema.safeParse(doc); + return result.success ? [] : result.error.issues; +} + +describe('objectui#8762 — the shorthands refuse `inputType` BY NAME', () => { + it.each(SHORTHANDS)('refuses `inputType` on `%s` at the document root', (type) => { + const issues = issuesFor({ type, inputType: 'text' }); + expect(issues.length, 'the trap still parses green').toBeGreaterThan(0); + // BY NAME: the issue is addressed at the key's own path, so a consumer that + // reports paths blames `inputType` and nothing else. + const own = issues.filter((i) => i.path.join('.') === 'inputType'); + expect(own.length, JSON.stringify(issues)).toBe(1); + expect(own[0].code).toBe('invalid_type'); + // WITH GUIDANCE: and the guidance names the spelling that IS honoured. A + // refusal that only says "invalid" sends the author to delete the node. + expect(own[0].message).toContain('`inputType`'); + expect(own[0].message).toContain('{ "type": "input", "inputType": "email" }'); + }); + + it.each(SHORTHANDS)('refuses it nested at a declared node slot too, on `%s`', (type) => { + // objectui#8344 pointed the node recursion point at this union, so a nested + // node is judged by its own component schema. A root-only refusal would leave + // every real document — where inputs live inside a form or a stack — untouched. + expect(issuesFor({ type: 'div', children: [{ type, inputType: 'text' }] }).length) + .toBeGreaterThan(0); + }); + + it('refuses it on the arm read directly, not only through the union', () => { + const issues = issuesFor({ type: 'password', inputType: 'text' }, InputShorthandSchema); + expect(issues.map((i) => i.path.join('.'))).toEqual(['inputType']); + }); + + it('feeds ONE string into both author-facing channels', () => { + // The `./zod/tombstone.zod.ts` discipline: the parse-time message and the + // `.describe()` metadata generated docs publish cannot drift apart, because + // there is only one string. Compared here rather than asserted separately. + const described = (InputShorthandSchema.shape.inputType as z.ZodType).description; + const parseMessage = issuesFor({ type: 'password', inputType: 'text' }, InputShorthandSchema)[0] + ?.message; + expect(described, 'the member lost its `.describe()`').toBeTruthy(); + expect(parseMessage).toBe(described); + }); + + it('addresses the author it is actually addressed to — the message keeps its FIELD carve-out', () => { + // A form FIELD is a different position with the OPPOSITE precedence + // (`renderers/form/form.tsx`: `inputType || NATIVE_INPUT_FIELD_TYPES[…]`), and + // this refusal does not reach there. Without the sentence, an author who meets + // this message while looking at `fields: [{ type: 'email', inputType: 'text' }]` + // is told their working document is wrong. Pinned because it is a claim about + // a surface this file does not otherwise touch. + const described = (InputShorthandSchema.shape.inputType as z.ZodType).description ?? ''; + expect(described).toContain('form FIELD'); + expect(described).toContain('fields:'); + }); +}); + +describe('objectui#8762 — the controls that keep the narrowing honest', () => { + it('the neighbour that HONOURS `inputType` still parses', () => { + // ⭐ THE FIRING CONTROL. `input` is where the key is read, and it must be + // untouched — a narrowing that also broke the spelling the guidance points at + // would be worse than the defect. + for (const inputType of ['text', 'email', 'password']) { + expect(issuesFor({ type: 'input', inputType }), `input/${inputType}`).toEqual([]); + expect(issuesFor({ type: 'input', inputType }, InputSchema), `input/${inputType}`).toEqual([]); + } + }); + + it.each(SHORTHANDS)('`%s` without `inputType` is untouched', (type) => { + expect(issuesFor({ type })).toEqual([]); + expect(issuesFor({ type, label: 'X', name: 'x', required: true, placeholder: 'p' })).toEqual([]); + expect(issuesFor({ type: 'div', children: [{ type }] })).toEqual([]); + }); + + it.each(SHORTHANDS)('`%s` still JUDGES its other values — this is not a dead arm', (type) => { + // An arm that refused one key and validated nothing else would satisfy every + // assertion above. One green reading and one red reading on the same key. + expect(issuesFor({ type, required: true })).toEqual([]); + expect(issuesFor({ type, required: 'yes' }).length).toBeGreaterThan(0); + }); + + it('`BaseSchema` is still passthrough — so this is a DECLARED refusal, not strictness', () => { + // The premise the repair rests on, re-measured rather than inherited: if the + // base had become strict, every undeclared key would already be refused and + // this arm would be solving a problem that had moved. It has not — an + // undeclared key still rides through on the very same document. + expect(issuesFor({ type: 'password', zzzUndeclaredKey: 1 })).toEqual([]); + expect(issuesFor({ type: 'input', zzzUndeclaredKey: 1 })).toEqual([]); + }); + + it('stays representable as JSON Schema, and does not make the arm any less so', () => { + // Why the `z.never` primitive and not `z.custom`: `z.toJSONSchema` THROWS on + // a `z.custom` arm and represents a `z.never` arm as `{ not: {} }` carrying + // the description — the reading `aliasKeyRefusal`'s docblock records. The + // docs surface is generated from these schemas. + const alone = z.toJSONSchema(InputShorthandSchema.shape.inputType as z.ZodType, { + io: 'input', + }) as { not?: unknown; description?: string }; + expect(alone.not, 'the member stopped converting on its own').toEqual({}); + expect(alone.description).toContain('objectui#8762'); + + // ⚠️ MEASURED, not assumed, and it corrected this file's first draft: the + // WHOLE arm does not convert under bare options, and did not before this card + // either. `InputSchema` — untouched here — throws the same way, because + // `handlerKeyRefusal('onChange', …)` is a `z.custom` and `ZodUndefined` has no + // JSON Schema form. Every live caller in this repo + // (`app-shell/src/views/metadata-admin/*-schema.ts`) passes + // `unrepresentable: 'any'`, so this is the reading that describes production. + const opts = { io: 'input', unrepresentable: 'any' } as const; + expect(() => z.toJSONSchema(InputSchema, opts)).not.toThrow(); + const json = z.toJSONSchema(InputShorthandSchema, opts) as { + properties?: Record; + }; + expect(json.properties?.inputType?.description).toContain('objectui#8762'); + }); +}); + +describe('objectui#8762 — ONE rule, and the next shorthand cannot slip past it', () => { + it('the refusal is one member on one arm, covering every literal the arm claims', () => { + // Not an enumeration: `InputShorthandSchema` is a single arm over a `type` + // enum, so a literal added to that enum inherits the refusal with no second + // edit. That is what makes a future sibling covered BY CONSTRUCTION. + expect([...literalsOf(InputShorthandSchema)].sort()).toEqual([...SHORTHANDS].sort()); + expect(Object.keys(InputShorthandSchema.shape)).toContain('inputType'); + }); + + it('the arm names exactly the shorthands `input.tsx` registers with a pinned `inputType`', () => { + // ⭐ THE COMPARISON INSTRUMENT, the shape objectui#8499 established for the + // two family arms. The two faces can only diverge where nothing compares + // them: register a third shorthand and forget the enum, and this goes red + // instead of leaving a literal that renders and validates nowhere. + const source = read(INPUT_RENDERER); + const pinned = [ + ...source.matchAll( + /ComponentRegistry\.register\(\s*'([^']+)'\s*,[\s\S]{0,200}?inputType:\s*'([^']+)'/g, + ), + ] + .filter((m) => m[1] === m[2]) + .map((m) => m[1]); + // Non-vacuity: a reader that has stopped reading must not be able to pass. + expect(pinned.length, 'the registration read went vacuous — check `input.tsx`').toBe(2); + expect([...pinned].sort()).toEqual([...literalsOf(InputShorthandSchema)].sort()); + }); + + it('detects a registered-but-unarmed shorthand, and fails closed on an unreadable source', () => { + // ⚠️ What this control must NOT be: comparing a list against itself plus an + // element. Both halves below run the REAL reader. `input` is registered in + // the same file WITHOUT a pinned inputType, so the reader must not pick it up + // — that is the same instrument answering a case it must exclude. + const source = read(INPUT_RENDERER); + const allRegistered = [...source.matchAll(/ComponentRegistry\.register\(\s*'([^']+)'/g)].map( + (m) => m[1], + ); + expect(allRegistered.length, 'the registration read went vacuous').toBe(3); + expect(allRegistered).toContain('input'); + expect([...literalsOf(InputShorthandSchema)]).not.toContain('input'); + // And the reader must throw rather than fabricate a pass when the file moves. + expect(() => read('packages/components/src/renderers/form/not-a-file.tsx')).toThrow(); + }); +}); diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index ac33618b12..65b9980c4f 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -1644,20 +1644,35 @@ export interface CodeEditorSchema extends BaseSchema { * Both are the SAME renderer as `input`, wrapped so `inputType` is pinned: * ``. * - * ⛔ `inputType` is therefore absent here, and that absence is the whole + * ⛔ `inputType` is therefore not a member here, and that is the whole * difference from {@link InputSchema}. The wrapper spreads its own value LAST, - * so an authored `inputType` is silently overwritten; declaring it would publish - * a key the runtime discards. Write `{ type: 'input', inputType: 'email' }` when - * the input type is the choice. + * so an authored `inputType` never reaches the renderer. Write + * `{ type: 'input', inputType: 'email' }` when the input type is the choice. * - * ⚠️ {@link BaseSchema} carries an index signature and its mirror passes unknown - * keys through, so omitting the key states the contract — it does not refuse the - * value. Refusing it by name is an accept-set narrowing left to its own ruling. + * ⚠️ objectui#8499 shipped this interface with the key merely OMITTED, and + * recorded why that was not enough: {@link BaseSchema} carries an index + * signature and its mirror is `.passthrough()`, so `inputType` rode through + * both faces unchallenged while the renderer threw the value away. objectui#8762 + * closes that — the key is DECLARED and unwritable on both faces, so `tsc` + * refuses it at the authoring site and the zod twin refuses it BY NAME with + * guidance pointing at `{ type: 'input', inputType: 'email' }`. * * Mirror: `zod/form.zod.ts#InputShorthandSchema`. */ export interface InputShorthandSchema extends Omit { type: 'email' | 'password'; + /** + * ⛔ UNWRITABLE at this position (objectui#8762). The `email` / `password` + * registration wrapper pins `inputType` itself and spreads it LAST, so an + * authored value is discarded — `{ type: 'password', inputType: 'text' }` + * renders a MASKED field. Write `{ type: 'input', inputType: 'email' }` + * instead. The zod twin refuses it by name and carries the same guidance. + * + * ⚠️ Not a retirement of `inputType`: the key is alive and honoured on + * {@link InputSchema}, and on the FORM FIELD path an authored `inputType` + * still wins. It is this POSITION that cannot author it. + */ + inputType?: never; } /** diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts index ec2a40a718..0d7db28821 100644 --- a/packages/types/src/zod/form.zod.ts +++ b/packages/types/src/zod/form.zod.ts @@ -686,6 +686,30 @@ export const FormSchema = BaseSchema.extend({ showActions: z.boolean().optional().describe('Show action buttons'), }); +/** + * The one string this arm's `inputType` refusal carries into BOTH author-facing + * channels — the parse-time issue message and the `.describe()` metadata — so + * they cannot drift apart ({@link retirementTombstone}'s discipline). + * + * ⚠️ The closing sentence is a claim about a DIFFERENT surface and is pinned as + * such: on the FORM FIELD path the precedence is the other way round + * (`renderers/form/form.tsx`, `type={inputType || NATIVE_INPUT_FIELD_TYPES[declaredType] || 'text'}`), + * so an author who meets this message inside `fields: [ … ]` is not the author it + * is addressed to. `__tests__/shorthand-input-type-refusal-8762.test.ts` fails if + * the message stops saying it; the components-side pin + * (`renderers/form/__tests__/shorthand-input-type-discarded-8762.test.tsx`) fails + * if either precedence moves. + */ +const SHORTHAND_INPUT_TYPE_REFUSAL = + '`inputType` is NOT authorable on the `email` / `password` shorthands (objectui#8762). ' + + '`packages/components/src/renderers/form/input.tsx` registers each of them by wrapping the ' + + '`input` renderer and spreading its OWN `inputType` LAST, so an authored value is overwritten ' + + 'before the renderer reads it: `{ "type": "password", "inputType": "text" }` renders a MASKED ' + + 'field, not a text one. Write the input node itself when the input type is the choice — ' + + '`{ "type": "input", "inputType": "email" }`, the spelling that IS read. ' + + '(A form FIELD is a different position: inside `fields: [ … ]` an authored `inputType` still ' + + 'wins over the one the field type implies, and this refusal does not reach there.)'; + /** * Input Shorthand Schema — the `email` / `password` aliases * `packages/components/src/renderers/form/input.tsx` registers (objectui#8499). @@ -697,18 +721,45 @@ export const FormSchema = BaseSchema.extend({ * (props) => , …) * ``` * - * ⛔ `inputType` is therefore ABSENT from this arm, deliberately, and that - * absence is the whole difference from {@link InputSchema}. The wrapper spreads - * its own value LAST, so an authored `inputType` is silently overwritten; - * declaring it here would publish a key the runtime discards. Write + * ⛔ `inputType` is therefore NOT a member here, and that is the whole difference + * from {@link InputSchema}. The wrapper spreads its own value LAST, so an + * authored `inputType` never reaches the renderer. Write * `{ type: 'input', inputType: 'email' }` when the input type is the choice. * - * ⚠️ MEASURED, so the omission is not over-read: `BaseSchema` passes unknown - * keys through, so `{ type: 'password', inputType: 'text' }` still PARSES — - * omitting the key states the contract on the declared face, it does not refuse - * the value. Refusing it by name (the `./tombstone.zod.ts` mechanism) would be - * an accept-set NARROWING in the opposite direction from this card and is - * deliberately left to its own ruling; objectui#8499's report records it. + * ## Why the key is DECLARED-AND-REFUSED rather than merely omitted (objectui#8762) + * + * ⚠️ objectui#8499 shipped this arm with the key simply OMITTED and recorded the + * reading that made that insufficient: `BaseSchema` is `.passthrough()`, so + * `{ type: 'password', inputType: 'text' }` PARSED GREEN and the value survived + * into `safeParse`'s output — omission states the contract on the declared face, + * it does not refuse the value. Re-measured on this card's base before the + * change: ACCEPT, with `inputType: 'text'` still present in the parsed data, + * while the DOM rendered `type="password"`. That is the class-(c) trap — a + * validated key the runtime discards — so #8499's report left the repair to its + * own ruling, which is this card. + * + * The key therefore stays DECLARED and unwritable, refused BY NAME with guidance + * ({@link retirementTombstone}, the mechanism `./tombstone.zod.ts` owns). ⚠️ Not + * an ADR-0049 retirement of `inputType` itself — the key is alive and honoured on + * {@link InputSchema}; what is unwritable is this POSITION. That is the same + * reading `MenuItemSchema.type` (`overlay.zod.ts`) is filed under, and + * `aliasKeyRefusal`'s docblock records why declaration history is not what picks + * the helper. ⛔ Not `aliasKeyRefusal` either: the remedy here is a different + * `type`, not a sibling spelling of a key on this same object, so its + * "Did you mean `inputType` → `inputType`?" lead would be nonsense. + * + * ⛔ The repair is NOT to flip the wrapper's precedence so the author wins: + * `{ type: 'password', inputType: 'text' }` would then render an UNMASKED field + * under a `password` key, which is worse than refusing it. The card records this + * so the cheaper-looking route is not taken by accident. + * + * ONE rule, not an enumeration: the refusal is a single member on this ONE arm, + * whose `type` is an enum, so it covers both literals and any literal later added + * to that enum. A shorthand registered in `input.tsx` and NOT added to the enum + * is refused whole by `AnyComponentSchema` (no arm claims the literal) rather + * than silently accepted — the loud direction — and + * `__tests__/shorthand-input-type-refusal-8762.test.ts` compares the enum against + * the registration site so the gap turns red instead of widening in silence. * * Every other key is {@link InputSchema}'s, because it is literally the same * renderer reading the same schema. @@ -716,6 +767,9 @@ export const FormSchema = BaseSchema.extend({ export const InputShorthandSchema = InputSchema.omit({ type: true, inputType: true }).extend({ type: z.enum(['email', 'password']) .describe('Input shorthand — `renderers/form/input.tsx` pins `inputType` to match'), + // Declared and unwritable — the `.omit()` above removes the HONOURED enum + // {@link InputSchema} carries, and this puts a named refusal in its place. + inputType: retirementTombstone(SHORTHAND_INPUT_TYPE_REFUSAL), // Declared here and not (yet) on {@link InputSchema}, which is the pair // `__tests__/zod-mirror-parity.test.ts` records as unmirrored for this very key. // The renderer both arms share reads it — `renderers/form/input.tsx:42`,