Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/zod-base-schema-mirror-parity-4605.md
Original file line number Diff line number Diff line change
@@ -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`).
197 changes: 197 additions & 0 deletions packages/types/src/__tests__/base-schema-zod-mirror-parity.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Mirror>` 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<typeof Mirror>` 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);
},
);
});
74 changes: 64 additions & 10 deletions packages/types/src/zod/base.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, string>`), 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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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

/**
Expand Down
Loading