From 85c44117421383bf9cc98bc6d87125771cb7a21d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 09:00:23 +0000 Subject: [PATCH] types(zod): widen the BaseSchema mirror to match its TypeScript declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@object-ui/types/zod` is a published runtime validator hand-written to mirror the `BaseSchema` interface, and five keys had drifted narrower than the declaration they mirror: `visible`, `disabled`, `ariaLabel`, `label` and `description`. Each refused at parse time a spelling the published types invite and the renderer implements. `.passthrough()` rescued none of them — it admits UNDECLARED keys, and all five are explicitly declared. `visible`/`disabled` take `boolean | string`; `ariaLabel` takes the KEYED reference via a new exported `KeyedI18nLabelSchema`; `label`/`description` take the spec's own `I18nLabelSchema` by reference. The two i18n vocabularies stay apart — merging them would reproduce objectui#4167's confusability hazard inside the validator meant to catch it — and both cross pairings are pinned red. The new pin is derived from the mirror's own `.shape`, so the next widening of `base.ts` that forgets this file turns it red with no key list to maintain. Part of #4605 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012u2pRjcqAYtoEjgr3wwhnK --- .../zod-base-schema-mirror-parity-4605.md | 46 ++++ .../base-schema-zod-mirror-parity.test.ts | 197 ++++++++++++++++++ packages/types/src/zod/base.zod.ts | 74 ++++++- 3 files changed, 307 insertions(+), 10 deletions(-) create mode 100644 .changeset/zod-base-schema-mirror-parity-4605.md create mode 100644 packages/types/src/__tests__/base-schema-zod-mirror-parity.test.ts diff --git a/.changeset/zod-base-schema-mirror-parity-4605.md b/.changeset/zod-base-schema-mirror-parity-4605.md new file mode 100644 index 0000000000..1d0ba8c9fe --- /dev/null +++ b/.changeset/zod-base-schema-mirror-parity-4605.md @@ -0,0 +1,46 @@ +--- +'@object-ui/types': minor +--- + +The zod `BaseSchema` mirror now accepts everything its TypeScript declaration +declares — five keys had drifted narrower (objectui#4605). + +`@object-ui/types/zod` is a published runtime validator hand-written to mirror the +`BaseSchema` interface. As the interface widened, the mirror did not, so five keys +refused at parse time a spelling the published types invite and the renderer +implements — "declared = enforced" inverted. `.passthrough()` rescued none of them: +passthrough admits UNDECLARED keys, and all five are explicitly declared, so the +narrow declaration won. + +Measured against the unmodified mirror before the change, these were the refusals: + +| key | authored input | old mirror said | +|---|---|---| +| `visible` | `'${data.status === "open"}'` | `expected boolean, received string` | +| `disabled` | `'${data.status === "locked"}'` | `expected boolean, received string` | +| `ariaLabel` | `{ key, defaultValue }` | `expected string, received object` | +| `label` | `{ en: 'Owner', 'zh-CN': '负责人' }` | `expected string, received object` | +| `description` | `{ en: 'The record owner' }` | `expected string, received object` | + +`visible`/`disabled` now take `boolean | string` — what `evaluateCondition` accepts, +no wider. `ariaLabel` takes the KEYED reference through a new exported +`KeyedI18nLabelSchema`; `label`/`description` take the spec's own `I18nLabelSchema` +BY REFERENCE, so a change to the spec's label contract is picked up rather than +re-typed. Every spelling that parsed before still parses. + +The two i18n vocabularies are kept apart rather than merged into "some object". +`label`/`description` are the spec's INLINE locale map (resolved by +`resolveI18nLabel(label, locale)`); `ariaLabel` is the KEYED reference (resolved by +`resolveKeyedI18nLabel`, which returns `undefined` for a locale map and would render +an EMPTY aria-label). Widening both slots to accept either shape would have +reproduced objectui#4167's confusability hazard inside the validator that exists to +catch it, so each slot admits only its own vocabulary and both cross pairings are +pinned as rejections. + +The new pin is DERIVED rather than a hand-written key list: it reads the mirror's own +`.shape` and compares each key against the declaration, so the next widening of +`base.ts` that forgets this file turns it red with no list to maintain. It reads +`.shape` and not `keyof z.input<…>` because that spelling was measured vacuous — +`.passthrough()` collapses the inferred key union to bare `string`, and a pin written +over it resolved `never` while five keys were demonstrably narrow. Two guards pin the +derivation against both degenerations (`never` and `string`). diff --git a/packages/types/src/__tests__/base-schema-zod-mirror-parity.test.ts b/packages/types/src/__tests__/base-schema-zod-mirror-parity.test.ts new file mode 100644 index 0000000000..50b0c494a1 --- /dev/null +++ b/packages/types/src/__tests__/base-schema-zod-mirror-parity.test.ts @@ -0,0 +1,197 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The zod `BaseSchema` mirror accepts everything its TypeScript declaration + * declares (objectui#4605). + * + * `packages/types/src/zod/base.zod.ts` is a hand-written runtime validator + * mirroring the `BaseSchema` interface in `../base.ts`. It is a PUBLISHED + * surface (`@object-ui/types/zod`), and it had drifted NARROWER than the + * declaration it mirrors on five keys — so a spelling the published types + * invite, and the renderer implements, was refused at parse time. That is + * "declared = enforced" inverted: the type says yes, the validator says no. + * + * `.passthrough()` rescues none of it. Passthrough admits UNDECLARED keys; + * all five are explicitly declared, so the declared narrow validator wins. + * + * ## The drift, measured against `origin/main` (`d7573b3f4`) BEFORE the fix + * + * Each of these was fed to the unmodified mirror and its rejection recorded — + * a widened validator accepts everything it accepted before, so a pin that + * only feeds it currently-valid input passes identically before and after and + * proves nothing. These are the inputs the OLD mirror really did refuse: + * + * | key | authored input | old mirror said | + * |---------------|--------------------------------------------|----------------------------------------------------| + * | `visible` | `'${data.status === "open"}'` | `expected boolean, received string` | + * | `disabled` | `'${data.status === "locked"}'` | `expected boolean, received string` | + * | `ariaLabel` | `{ key, defaultValue }` | `expected string, received object` | + * | `label` | `{ en: 'Owner', 'zh-CN': '负责人' }` | `expected string, received object` | + * | `description` | `{ en: 'The record owner' }` | `expected string, received object` | + * + * `visible`/`disabled` widened on the TS side by #4581 (#4580 ruling Q3-A for + * `disabled`); `ariaLabel` by #4580's Q2-B ruling; `label`/`description` by + * #4580's revised Q1-A ruling — the last two are the census growth that + * ruling recorded for this card. + * + * ## Two vocabularies, two properties apart — pinned as REJECTIONS + * + * `label`/`description` declare the spec's INLINE locale map (`I18nLabel`, + * `{ en: 'Owner' }`, resolved by `resolveI18nLabel(label, locale)`), while + * `ariaLabel` declares the KEYED reference (`{ key, defaultValue?, params? }`, + * resolved by `resolveKeyedI18nLabel`). They are structurally confusable and + * answer wrongly for each other's input — objectui#4167's hazard, live on this + * one interface since #4580's revised Q1. Widening both slots to "some object" + * would have reproduced that defect in a new place, so each slot admits only + * its own vocabulary and the cross pairings are pinned red below. + * + * ## Why the type-level pin is derived rather than a hand-written key list + * + * A hand-written list drifts exactly the way the mirror just did. The pin + * below reads the mirror's OWN `.shape` and compares each key against the + * declaration, so the NEXT widening of `../base.ts` that forgets this file + * turns it red with no list to maintain. + * + * It reads `.shape` and not `keyof z.input` because that + * spelling is vacuous — measured: `.passthrough()` collapses the inferred key + * union to bare `string`, and a pin written over it resolved `never` while + * five keys were demonstrably narrow. + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; +import { BaseSchema as Mirror } from '../zod/base.zod.js'; +import type { BaseSchema } from '../base'; + +/* ── Type-level helpers ──────────────────────────────────────────────────── */ + +/** Invariant equality — `extends` both ways would accept a narrowing. */ +type Equal< A, B > = + (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; +type Expect< T extends true > = T; + +/* ── The derived parity invariant ────────────────────────────────────────── */ + +/** The mirror's DECLARED keys, read from its own shape. */ +type MirroredKeys = keyof typeof Mirror.shape & string; + +/** What the mirror ACCEPTS for key `K` (input side, so `.optional()` shows). */ +type Accepts< K extends MirroredKeys > = z.input< (typeof Mirror.shape)[K] >; + +/** + * Every key whose DECLARED type the mirror would refuse. Must be `never`. + * + * The tuple wrappers keep the check non-distributive: `BaseSchema['visible']` + * is a union, and a bare `extends` would ask the question limb-by-limb and + * pass as long as ONE limb fit. + */ +type NarrowerThanDeclared = { + [K in MirroredKeys]: [BaseSchema[K]] extends [Accepts< K >] ? never : K +}[MirroredKeys]; + +/** The invariant this card exists to establish. */ +export type assertionMirrorIsNotNarrower = Expect< + Equal< NarrowerThanDeclared, never > +>; + +/** + * Non-vacuity guard for the pin above. + * + * If `MirroredKeys` ever resolved to `never` — the `.passthrough()` failure + * mode, or a refactor that stops exposing `.shape` — the mapped type would be + * `never` and `assertionMirrorIsNotNarrower` would pass while enforcing + * nothing. This asserts the six keys are really reachable through `.shape`. + */ +export type assertionShapeKeysResolve = Expect< + Equal< + Exclude< 'type' | 'label' | 'description' | 'visible' | 'disabled' | 'ariaLabel', MirroredKeys >, + never + > +>; + +/** + * The OTHER half of that guard — `MirroredKeys` must be a union of LITERALS. + * + * The guard above catches `MirroredKeys` degenerating to `never`; it cannot + * catch it degenerating to bare `string`, because every literal is `Exclude`d + * by `string` and the guard would stay green. That is not a hypothetical + * shape: `keyof z.input` IS `string` here, since + * `.passthrough()` puts an index signature on the inferred type. So this pins + * that a key the mirror does NOT declare stays outside the union. + */ +export type assertionShapeKeysAreLiteral = Expect< + Equal< Exclude< 'notAMirroredKey_4605', MirroredKeys >, 'notAMirroredKey_4605' > +>; + +/* ── Runtime: the spellings the OLD mirror refused ───────────────────────── */ + +describe('zod BaseSchema mirror — the widened spellings parse', () => { + it('visible accepts the predicate string the renderer evaluates', () => { + const r = Mirror.safeParse({ type: 'test-component', visible: '${data.status === "open"}' }); + expect(r.success).toBe(true); + expect(r.success && r.data.visible).toBe('${data.status === "open"}'); + }); + + it('disabled accepts the predicate string the renderer evaluates', () => { + const r = Mirror.safeParse({ type: 'test-component', disabled: '${data.status === "locked"}' }); + expect(r.success).toBe(true); + expect(r.success && r.data.disabled).toBe('${data.status === "locked"}'); + }); + + it('ariaLabel accepts the KEYED i18n reference, params included', () => { + const ariaLabel = { key: 'dialog.close', defaultValue: 'Close dialog', params: { name: 'Owner' } }; + const r = Mirror.safeParse({ type: 'test-component', ariaLabel }); + expect(r.success).toBe(true); + expect(r.success && r.data.ariaLabel).toEqual(ariaLabel); + }); + + it('label accepts the spec inline locale map', () => { + const label = { en: 'Owner', 'zh-CN': '负责人' }; + const r = Mirror.safeParse({ type: 'test-component', label }); + expect(r.success).toBe(true); + expect(r.success && r.data.label).toEqual(label); + }); + + it('description accepts the spec inline locale map', () => { + const description = { en: 'The record owner' }; + const r = Mirror.safeParse({ type: 'test-component', description }); + expect(r.success).toBe(true); + expect(r.success && r.data.description).toEqual(description); + }); +}); + +/* ── Runtime: the narrow spellings a widening must not lose ──────────────── */ + +describe('zod BaseSchema mirror — the pre-existing spellings still parse', () => { + it.each([ + ['visible: boolean', { visible: true }], + ['disabled: boolean', { disabled: false }], + ['ariaLabel: string', { ariaLabel: 'Close dialog' }], + ['label: string', { label: 'Owner' }], + ['description: string', { description: 'The record owner' }], + ])('%s', (_name, patch) => { + expect(Mirror.safeParse({ type: 'test-component', ...patch }).success).toBe(true); + }); +}); + +/* ── Runtime: the two vocabularies stay apart ────────────────────────────── */ + +describe('zod BaseSchema mirror — keyed and inline i18n do not cross', () => { + it('ariaLabel REFUSES an inline locale map (resolveKeyedI18nLabel returns undefined for it)', () => { + const r = Mirror.safeParse({ type: 'test-component', ariaLabel: { en: 'Owner' } }); + expect(r.success).toBe(false); + expect(!r.success && r.error.issues.some((i) => i.path[0] === 'ariaLabel')).toBe(true); + }); + + it.each([['label'], ['description']])( + '%s REFUSES the keyed reference (the spec resolver reads locale tags, not `key`)', + (key) => { + const r = Mirror.safeParse({ + type: 'test-component', + [key]: { key: 'dialog.close', defaultValue: 'Close dialog' }, + }); + expect(r.success).toBe(false); + expect(!r.success && r.error.issues.some((i) => i.path[0] === key)).toBe(true); + }, + ); +}); diff --git a/packages/types/src/zod/base.zod.ts b/packages/types/src/zod/base.zod.ts index 78caa38307..7773c28230 100644 --- a/packages/types/src/zod/base.zod.ts +++ b/packages/types/src/zod/base.zod.ts @@ -17,6 +17,33 @@ */ import { z } from 'zod'; +import { I18nLabelSchema } from '@objectstack/spec/ui'; + +/** + * A KEYED i18n label — the runtime mirror of `KeyedI18nLabel` in `../base.ts`. + * + * ⚠️ This is objectui's OWN label vocabulary, and it is NOT the spec's + * `I18nLabelSchema` that `label` / `description` below declare. The two are + * structurally confusable and answer wrongly for each other's input — the + * objectui#4167 hazard, which #4580's Q2-B ruling turned on: + * + * - KEYED (this schema): `{ key, defaultValue?, params? }`, a reference INTO a + * translation bundle, resolved by `resolveKeyedI18nLabel` + * (`packages/react/src/utils/i18n.ts`). `ariaLabel` declares this one. + * - INLINE (`I18nLabelSchema`): a locale MAP like `{ en: 'Owner' }`, resolved + * against a BCP-47 locale by the spec's own `resolveI18nLabel(label, + * locale)`. `label` and `description` declare that one. + * + * Hand-written rather than taken from the spec because the spec has no keyed + * form: `I18nLabelSchema` REJECTS `{ key, defaultValue }` at parse time, and + * objectstack#9925 made both limbs `never` on its type axis for the same + * reason. The shape here mirrors `KeyedI18nLabel` limb-for-limb. + */ +export const KeyedI18nLabelSchema = z.object({ + key: z.string().describe('Translation-bundle key, e.g. `dialog.close`'), + defaultValue: z.string().optional().describe('Rendered when the key is missing from the bundle'), + params: z.record(z.string(), z.any()).optional().describe("Interpolation values for the key's placeholders"), +}); /** * Schema Node - Can be a schema object or primitive value @@ -55,14 +82,22 @@ const BaseSchemaCore = z.object({ name: z.string().optional().describe('Component name'), /** - * Display label + * Display label. + * + * The spec's INLINE locale map (`string | Record`), embedded + * BY REFERENCE so a change to the spec's own label contract is picked up + * here rather than re-typed — the same property `specFieldsExcept` below + * relies on. Mirrors `BaseSchema.label: string | I18nLabel` (`../base.ts`), + * widened by #4580's revised Q1-A ruling. */ - label: z.string().optional().describe('Display label'), + label: I18nLabelSchema.optional().describe('Display label (plain string or inline locale map)'), /** - * Description text + * Description text. + * + * Same vocabulary and same resolver as `label` above — see `BaseSchema.description`. */ - description: z.string().optional().describe('Description text'), + description: I18nLabelSchema.optional().describe('Description text (plain string or inline locale map)'), /** * Placeholder text @@ -95,9 +130,17 @@ const BaseSchemaCore = z.object({ children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Child components (React-style)'), /** - * Visibility control + * Visibility control — a boolean, or the predicate STRING the renderer + * evaluates. + * + * Mirrors `BaseSchema.visible: boolean | string` (`../base.ts`), widened by + * #4581. `SchemaRenderer.tsx` passes this key to + * `evaluator.evaluateCondition`, which is declared + * `(condition: string | boolean | undefined, …) => boolean` — so the string + * form is an implemented, evaluated capability, and this validator was the + * one surface still refusing it. */ - visible: z.boolean().optional().describe('Visibility control'), + visible: z.union([z.boolean(), z.string()]).optional().describe('Visibility control (boolean or predicate expression)'), /** * Canonical conditional-visibility predicate (ADR-0089) — shown when truthy. @@ -122,9 +165,14 @@ const BaseSchemaCore = z.object({ hiddenOn: z.string().optional().describe('Expression for conditional hiding'), /** - * Disabled state + * Disabled state — a boolean, or the predicate STRING the renderer evaluates. + * + * Mirrors `BaseSchema.disabled: boolean | string` (`../base.ts`), widened by + * #4581 under #4580's Q3-A ruling: the renderer reads this key through the + * same `evaluateCondition` as `visible`, and the asymmetry between the two + * was accidental rather than deliberate. */ - disabled: z.boolean().optional().describe('Disabled state'), + disabled: z.union([z.boolean(), z.string()]).optional().describe('Disabled state (boolean or predicate expression)'), /** * Conditional disabled expression @@ -137,9 +185,15 @@ const BaseSchemaCore = z.object({ testId: z.string().optional().describe('Test identifier'), /** - * Accessibility label + * Accessibility label — a plain string, or the KEYED i18n reference. + * + * Mirrors `BaseSchema.ariaLabel: string | KeyedI18nLabel` (`../base.ts`). + * ⚠️ KEYED, NOT the spec's inline locale map two properties up: the renderer + * reads this slot with `resolveKeyedI18nLabel`, which returns `undefined` + * for a locale map and would render an EMPTY aria-label. #4580's Q2-B ruling + * withdrew the `I18nLabel` spelling as measured-wrong for exactly that. */ - ariaLabel: z.string().optional().describe('Accessibility label'), + ariaLabel: z.union([z.string(), KeyedI18nLabelSchema]).optional().describe('Accessibility label (plain string or keyed i18n reference)'), }).passthrough(); // Allow additional properties for type-specific extensions /**