fix(types): refuse inputType by name on the email / password shorthands (objectui#8762) - #9051
Conversation
…orthands (objectui#8762)
`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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract reviewPR: #9051 · Card: #8762 · Round: 1 MethodFed the card (body + all 3 comments: triage ruling, ① Derived judgments — every accept-set and public-surface change, one by one
② Semver grading
③ Boundary flags — each answered or escalated
Checks on the head
Independence (machine-readable)Implemented-by: branch Same-session test, applied literally to the identities as the charter defines them. contract-review.md line 36 records a Not measured
VERDICT: PASS — the narrowing is to the right shape (by name, with guidance, at the position, on both published faces), through the established mechanism, in one direction only, with the protocol reading in hand and censuses that hold under an independent instrument with positive controls; Generated by Claude Code |
Provenance — carriers cleared, landingClause-② contract review: Reviewer ran at Independence — NOT self-review: Fifth reviewer today to reach this from the charter text, and it disposed of both objections rather than one: Landing pre-checks — all measured just now
⭐ The reviewer resolved a number two parties had reported differentlyThe card said "23 files carry It also re-derived the censuses independently and got 632/546/86 exactly, 1692 markdown files against the dev's 1691, and text 84 = the dev's 81 plus this PR's own 3 files. And it ran a hostile variant of the dev's third ablation leg (a differently-named wrapper) which the count guard still caught, 1/16 red. What it escalated rather than blocked
Both carriers stripped in this same act; PR flipped ready and enqueued. ⛔ Base is not hand-merged — the queue rebuilds on current Generated by Claude Code |
Dequeued at 21:48:55Z — the failure is NOT this PR's, and it is already anchored
The failing check: Why it is not this PR's — measured, three ways:
The fix exists and is in flight: objectui#9066, "test(plugin-grid): give #8920's case-P pin a wait of its own". ⛔ It is deliberately NOT ported into this PR. The standing rule is to port an existing fix rather than wait — but here the fix is a test file in another package owned by another seat, and any push to this branch moves the head and voids the contract-review record ( ⇒ Disposition per
Generated by Claude Code |
Fixes #8762
inputTypeauthored on theemail/passwordnode shorthands parsed green and was thenoverwritten by the registration wrapper, which pins
inputTypeitself and spreads it last.So
{ "type": "password", "inputType": "text" }validated, rendered a masked field anyway,and no diagnostic said so — a validated key the runtime discards. This refuses the key by
name, with guidance, on those shorthand arms.
This is a narrowing of the published accept set (
Clause-②: yes), and the diff moves thatset in one direction only. Nothing else rides.
The measurements, re-derived on this base (
681d3f10e)Every reading below was taken here, not inherited from the card.
The trap and its firing control, on one instrument
Before the change:
typeattribute){ "type": "password", "inputType": "text" }inputType: "text"survived intosafeParse's outputpassword— DISCARDED{ "type": "email", "inputType": "text" }email— DISCARDED{ "type": "input", "inputType": "text" }text— HONOUREDAfter the change: the first two are REFUSED at
path: ['inputType']with guidance namingthe key and pointing at
{ "type": "input", "inputType": "email" }; the third still parses andstill renders
text, at every value tested. Both halves are pinned —packages/types/src/__tests__/shorthand-input-type-refusal-8762.test.ts(17 tests) andpackages/components/src/renderers/form/__tests__/shorthand-input-type-discarded-8762.test.tsx(13 tests).
BaseSchemais still passthrough — so omission really was not refusalConfirmed by content (
packages/types/src/zod/base.zod.ts,BaseSchemaCoreends}).passthrough();) and by measurement: an undeclared key still rides through on the verysame document after this change, which is what makes this a declared refusal rather than
strictness. The premise has not moved; pinned as its own leg.
Census — who stops validating
Nobody in reach. Four independent readings:
tsconfig*.json,which are JSONC). Six nodes carry a shorthand
type; none authorsinputType.fields[]position, which isFormFieldSchema, not this arm..md/.mdxfiles — zero shorthand nodes,and zero blocks containing both
inputTypeand a shorthandtypeliteral.inputTypeanywhere — 13 co-occurrence windows, everyone of them prose, a docblock, a CHANGELOG entry, or a form field. Zero component-node
authorings.
pnpm check(the CLI over 629 files) rc=0,check:doc-typesrc=0,check:doc-snippetsrc=0 (639 blocks compiled against the builtnarrowed types),
check:doc-examplesrc=0.hotcrmandobjectstackcorpora are outside this repository and were NOT measured.They are out of reach from here; this PR makes no claim about them.
The card's figure of "23 files carry
inputType" did not reproduce under any spelling I couldconstruct: repo-wide it is 81, tracked JSON 17, catalog schemas 15,
content/**5.The card's conclusion — none on a shorthand root — does reproduce, and this PR strengthens it to
none at any depth, in any parsed document.
One rule, not an enumeration — and what happens to the next shorthand
InputShorthandSchemais a single arm over atypeenum, so the refusal is one membercovering both literals and any literal later added to that enum. Enumerating per literal would
have meant splitting the arm in two.
What happens to a third shorthand:
AnyComponentSchemarefuses the literal whole,because no arm claims it (measured:
tel,url,search,numberall REFUSE today). That isthe loud direction, not a silent accept, so the class-(c) trap cannot recur through this path.
inputType— the only way to recreate the defect, so thenew types pin compares the enum against the registration site: it reads
input.tsx, collectsevery
ComponentRegistry.registercall whose wrapper pins a matchinginputType, and assertsset equality, with a non-vacuity guard and a fail-closed control. Ablation leg C below fires it.
Changeset level, from this repo's own precedent
minor. The closest sibling —.changeset/7694-chart-series-chart-type-alias-refusal.md, also anamed refusal narrowing a published accept surface — says it in the same words: "
minor, notpatch, because this is a NARROWING of a published accept surface".majoris notavailable at all: AGENTS.md §版本号策略 keeps this repository's major aligned with
@objectstackand states that objectui's own breaking changes ship as
minorwith the breaking semantics in thebody — mechanically enforced by
scripts/check-changeset-no-major.mjs(rc=0).A form FIELD is a different position with the opposite precedence, and this refusal does not
reach it.
renderers/form/form.tsxresolvestype={inputType || NATIVE_INPUT_FIELD_TYPES[declaredType] || 'text'}, so insidefields: [ … ]an authored
inputTypestill wins:{ "name": "contact", "type": "email", "inputType": "text" }renders a text box, before andafter this PR. The same two spellings therefore mean two different things depending on position.
The refusal's guidance string says so explicitly, and that sentence is pinned on both sides so
it cannot rot into false prose. Five of the six shorthand nodes in the catalog sit at exactly this
position and are untouched.
⛔ The cheaper-looking route, deliberately not taken
Flipping the wrapper's precedence so the author's value wins would render
{ "type": "password", "inputType": "text" }as an unmasked field under apasswordkey — asecret in clear text, worse than refusing the key. The wrapper is unchanged, and the components
pin is the thing that goes red if anyone flips it (see ablation leg B).
Mechanism — the established one, not a new one
packages/types/src/zod/tombstone.zod.tsalready owns named refusal. Of its four helpers,retirementTombstoneis the fit, decided by content:aliasKeyRefusal— its lead composes "Did you mean `alias` → `canonical`?", and theremedy here is a different
type, not a sibling spelling of a key on the same object. Its leadwould read "Did you mean `inputType` → `inputType`?".
handlerKeyRefusal— that is for function-valued keys, and itsz.customprimitive isnot JSON-Schema representable.
retiredNodeType— that claims atypeliteral on the union, not a member.inputTypeitself. The key is alive and honoured onInputSchema; it is this position that cannot author it. That reading has precedent:aliasKeyRefusal's own docblock recordsMenuItemSchema.typeas aretirementTombstonefor anever-declared spelling pointed at its canonical form. The new docblock states this so the arm
is not later misread as a withdrawal of the key.
One string feeds both the parse-time message and
.describe(), so the error an author reads andthe text generated docs publish cannot drift apart. Pinned by comparing them.
Both faces move together
The zod arm refuses by name; the TS twin (
packages/types/src/form.ts) declaresinputType?: never, sotscrefuses it at the authoring site. Reverse-verified against thebuilt
dist/form.d.ts(published as the@object-ui/types/formsubpath), two legs in onefile:
error TS2322: Type 'string' is not assignable to type 'undefined'at theinputTypeposition, while theInputSchemacontrol in the same file compiled;That proves the reading came from the rebuilt
⚠️ Honest limitation: the TS message names the position, not the key — the guidance lives in the
.d.tsand not a cache.member's JSDoc (which editors surface on hover). The zod face is the one that names the key.
Ablation — each pin proven able to fail
Each leg: mutate → prove the mutation reached disk by blob hash → run → restore → prove the
restore by hash and an empty
git diff HEAD. Committed before ablating; every leg restoredclean.
input.tsxVerification
All rc values captured to a file before any pipe.
pnpm exec vitest run packages/types/— 170 files, 3369 tests, rc=0. Includeszod-mirror-parity.test.ts(held by fix(types): collapse the redundantUserActionsSchemaextension, correct its docblock (objectui#8992) #9017, not edited) — thez.nevermember and the?: nevertwin agree, so no ledger row is owed.pnpm exec vitest run packages/components/src/renderers/form/— 60 files, 416 tests, rc=0;plus
form-renderers.test.tsx+text-input.test.tsx— 42 tests, rc=0.pnpm --filter @object-ui/types type-checkrc=0 (all three tsconfigs);pnpm --filter @object-ui/components type-checkrc=0.distartefact;after
pnpm --filter '@object-ui/components^...' buildit is 0. The pre-build reading is notreported as a result.
check:control-bytes0,check:new-line-citations0,check:handler-key-reads0,check:sdui-registration-pins0 (after building the console —it reports
PREREQUISITE NOT METotherwise, which is not a red),check:unreferenced-sources0,check:published-tsconfig-exclude0,check:lint-rule-coverage0,check:self-import0,check:esm-specifiers0,check:doc-types0,check:doc-fences0,check:doc-snippets0,check:doc-examples0,check:doc-example-ids0,check:skill-examples0,check:changeset-presence0,check:changeset-no-major0,pnpm check0.--format json, not from grepped text: the four changed files lint0 errors. The 10 warnings are all
@typescript-eslint/no-explicit-anyinform.tsat lines883–1233 — above my edit point (line 1640+), so their line numbers are unshifted and they are
demonstrably pre-existing.
packages/components/suite did not fit the foreground10-minute cap (killed at 560s having produced no test output — NOT MEASURED, neither green
nor red). It was narrowed to the form-renderer directory, which is where the diff and the new
test live, and declared here. CI runs the full farm.
check:governed-queue-guard --teston all five paths: NOT GOVERNED.pnpm-lock.yamlandpackages/core/package.jsonare untouched(held by fix(core): raise declared @objectstack/spec floor to ^17.3.0 #9032); the diff is 2 modified + 3 added files, all under
packages/types/src/,packages/components/src/and.changeset/.zod-mirror-parity.test.tsin particular is read and run, never written.Premise
Valid. Every load-bearing claim on the card reproduced: the wrapper pins
inputTypelast, thekey parses green, the value is discarded at runtime,
BaseSchemais still passthrough, and thehonoured neighbour is
{ "type": "input", "inputType": "…" }. The one thing that did not reproduceis the card's "23 files" figure, corrected above.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
Generated by Claude Code