diff --git a/.changeset/11022-strict-widget-slot-registered-inputs.md b/.changeset/11022-strict-widget-slot-registered-inputs.md new file mode 100644 index 0000000000..45e3ce6cf7 --- /dev/null +++ b/.changeset/11022-strict-widget-slot-registered-inputs.md @@ -0,0 +1,13 @@ +--- +'@object-ui/types': minor +--- + +The strict authoring face accepts a correctly authored `metric-card` in a dashboard's widget slot (objectui#11022). This widens a published accept set; nothing that parsed before is refused now. + +`StrictAnyComponentSchema` refused every `metric-card` widget that carried `value`, its required input, and `icon`, `trend` and `trendValue` with it: each came back as `unrecognized_keys` from both arms of the widget slot's union. The widget slot holds `metric-card` as a component node whose props are its registration's `inputs`. The tolerant face admits them through `BaseSchema`'s passthrough, and the strict face closes that passthrough, so it refused them as undeclared. + +- **What changed.** The slot's component-node arm now records the input names the `metric-card` registration declares (`title`, `value`, `icon`, `trend`, `trendValue`, `description`). `deriveStrictAuthoringSchema` admits those names on that node before closing it. Each one is judged the way the tolerant face judges it: the passthrough admits any value, so the strict face checks which KEYS appear and judges no value the tolerant face does not. A name the node already declares, like `description`, keeps its declared type. +- **What still refuses.** A key no registration declares is still refused by name, as `unrecognized_keys` (`{ type: 'metric-card', bogus: 1 }`). `children` and `body` on `metric-card` are still refused with the objectui#9256 message. +- **What does not move.** The tolerant face (`AnyComponentSchema`, `DashboardComponentSchema` and every other mirror): its accept set, output and inferred types are unchanged, and the slot arm's shape and catchall are what they were. The legacy `{ id, component, layout }` widget envelope is untouched, and its `component` is still judged as a plain `BaseSchema` node on both faces. `metric-card` is still not a root-level arm of `AnyComponentSchema`, so a root `{ type: 'metric-card' }` document is refused at `type` on both faces, as before. + +No migration: a document that parsed on either face still parses there. diff --git a/packages/plugin-dashboard/src/__tests__/metricCardRegisteredInputsStrictFace-11022.test.ts b/packages/plugin-dashboard/src/__tests__/metricCardRegisteredInputsStrictFace-11022.test.ts new file mode 100644 index 0000000000..5d323b37de --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/metricCardRegisteredInputsStrictFace-11022.test.ts @@ -0,0 +1,135 @@ +/** + * objectui#11022 — the strict authoring face admits what the widget slot's + * REGISTRATIONS declare, measured against the live `ComponentRegistry`. + * + * `@object-ui/types` records, on its private widget-slot arm, the input names + * of each component type in the closed `DASHBOARD_COMPONENT_WIDGET_TYPES`, and + * the strict walker admits exactly those (see `strict-authoring-face.ts`, + * "Registered inputs"). That package depends on no registry, so the names are + * transcribed there — and a transcription is only as good as the check that + * re-reads its source. The source is here: this package's barrel runs the + * registration. So this file holds the record to the registration in BOTH + * directions, and holds no copy of either list: + * + * 1. UNDER-admission — every input a widget-slot type's registration + * declares parses on `StrictAnyComponentSchema` in the widget slot, with + * no `unrecognized_keys` naming it. The population is every member of the + * closed set, read from the set, so a member added later is walked too. + * 2. OVER-admission — every key the strict slot arm admits beyond the + * tolerant arm's own members is a key some widget-slot registration + * declares. Read off the derived arm's shape, so a name added to the + * record that no registration declares turns this red. + * + * The instrument's controls: a key no registration declares is refused by + * name (so walk 1 can fail), and the population is non-empty and holds the + * card that motivated the repair. + */ +import { describe, expect, it } from 'vitest'; +import { ComponentRegistry } from '@object-ui/core'; +import { DASHBOARD_COMPONENT_WIDGET_TYPES } from '@object-ui/types'; +import { + DashboardComponentSchema, + deriveStrictAuthoringSchema, + StrictAnyComponentSchema, +} from '@object-ui/types/zod'; +// Side-effect import: the package barrel runs the `ComponentRegistry.register` +// calls, `metric-card`'s among them. +import '../index'; + +type Issue = { code: string; path: PropertyKey[]; keys?: string[]; errors?: Issue[][] }; +type Def = { + type: string; + shape?: Record; + innerType?: unknown; + element?: unknown; + options?: unknown[]; +}; +type Input = { name: string; type?: unknown; enum?: Array<{ value: unknown } | string> }; + +const defOf = (schema: unknown): Def => (schema as { _zod: { def: Def } })._zod.def; + +/** Every key named by an `unrecognized_keys` issue anywhere in the refusal. */ +const unrecognized = (issues: readonly Issue[]): string[] => + issues.flatMap((issue) => [ + ...(issue.code === 'unrecognized_keys' ? (issue.keys ?? []) : []), + ...(issue.errors ?? []).flatMap((group) => unrecognized(group)), + ]); + +/** A value of the input's registered kind — the key is what is measured, not the value. */ +const sampleFor = (input: Input): unknown => { + if (input.type === 'enum' && input.enum?.length) { + const first = input.enum[0]; + return typeof first === 'string' ? first : first.value; + } + if (input.type === 'number') return 1; + if (input.type === 'boolean') return true; + return 'sample'; +}; + +const dashboard = (widget: Record) => ({ type: 'dashboard', widgets: [widget] }); + +const registeredInputs = (type: string): Input[] => + (ComponentRegistry.getConfig(type)?.inputs ?? []) as Input[]; + +/** The widget slot's component-node arm: the union option whose `type` is the closed set. */ +const slotArm = (dashboardSchema: unknown): unknown => { + let widgets = defOf(dashboardSchema).shape?.widgets; + while (defOf(widgets).innerType) widgets = defOf(widgets).innerType; + const options = defOf(defOf(widgets).element).options ?? []; + const arm = options.find((option) => { + const type = defOf(defOf(option).shape?.type); + return type.type === 'enum'; + }); + if (!arm) throw new Error('no component-node arm found in the widget slot — the slot moved; re-read it before trusting this file'); + return arm; +}; + +describe('objectui#11022 — widget-slot registrations vs the strict authoring face', () => { + it('the population is the closed set, and it is not empty', () => { + expect(DASHBOARD_COMPONENT_WIDGET_TYPES).toContain('metric-card'); + for (const type of DASHBOARD_COMPONENT_WIDGET_TYPES) { + expect(registeredInputs(type).length, `\`${type}\` has no registered inputs to walk`).toBeGreaterThan(0); + } + // The registration the defect was measured on: `value` is its REQUIRED input. + expect(registeredInputs('metric-card').find((i) => i.name === 'value')).toMatchObject({ required: true }); + }); + + describe.each([...DASHBOARD_COMPONENT_WIDGET_TYPES])('`%s`', (type) => { + it.each(registeredInputs(type).map((input) => [input.name, input] as const))( + 'UNDER-admission: its registered input `%s` parses in the widget slot on the strict face', + (name, input) => { + const result = StrictAnyComponentSchema.safeParse(dashboard({ type, [name]: sampleFor(input) })); + const issues = result.success ? [] : (result.error.issues as unknown as Issue[]); + expect(unrecognized(issues), `the strict face refuses the registered input \`${name}\``).not.toContain(name); + expect(result.success).toBe(true); + }, + ); + + it('CONTROL — a key no registration declares is refused by name', () => { + const result = StrictAnyComponentSchema.safeParse(dashboard({ type, notARegisteredInput11022: 1 })); + expect(result.success).toBe(false); + expect(unrecognized(result.success ? [] : (result.error.issues as unknown as Issue[]))).toContain( + 'notARegisteredInput11022', + ); + }); + }); + + it('OVER-admission: every key the strict slot arm admits beyond the tolerant arm is a registered input', () => { + const tolerantKeys = new Set(Object.keys(defOf(slotArm(DashboardComponentSchema)).shape ?? {})); + const strictKeys = Object.keys(defOf(slotArm(deriveStrictAuthoringSchema(DashboardComponentSchema))).shape ?? {}); + const admitted = strictKeys.filter((key) => !tolerantKeys.has(key)).sort(); + const registered = new Set( + DASHBOARD_COMPONENT_WIDGET_TYPES.flatMap((type) => registeredInputs(type).map((input) => input.name)), + ); + // Non-vacuity: the derived arm does admit something the tolerant arm does not declare. + expect(admitted).toContain('value'); + expect( + admitted.filter((key) => !registered.has(key)), + 'the strict slot arm admits a key no widget-slot registration declares — remove it from ' + + '`DASHBOARD_WIDGET_SLOT_REGISTERED_INPUTS` in `@object-ui/types`, or register it first', + ).toEqual([]); + // And the other half, read the same way: nothing registered is left out. + const missing = [...registered].filter((key) => !tolerantKeys.has(key) && !admitted.includes(key)); + expect(missing).toEqual([]); + }); +}); diff --git a/packages/types/README.md b/packages/types/README.md index 56de4617af..da55f5ff2f 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -159,6 +159,22 @@ Opaque `custom` / `function` / `transform` validators have no shape to close; `deriveStrictAuthoringSchema` reports each one it meets through the optional `onOpaqueShape` callback. +One node spells its props through the passthrough by design: a `metric-card` +sitting directly in a dashboard's `widgets` slot, whose props are its +registration's `inputs`. The strict face admits exactly the input names that +registration declares on that node, each judged as the tolerant face judges it, +and still refuses any other key by name (objectui#11022; the names are held to +the live registration by a test in `@object-ui/plugin-dashboard`): + +```typescript +import { StrictAnyComponentSchema } from '@object-ui/types/zod'; + +const card = (widget: object) => ({ type: 'dashboard', widgets: [widget] }); + +StrictAnyComponentSchema.safeParse(card({ type: 'metric-card', value: 42 })).success; // true +StrictAnyComponentSchema.safeParse(card({ type: 'metric-card', bogus: 1 })).success; // false — `bogus` is named +``` + ## Type Categories ### Base Types diff --git a/packages/types/src/__tests__/strict-widget-slot-registered-inputs-11022.test.ts b/packages/types/src/__tests__/strict-widget-slot-registered-inputs-11022.test.ts new file mode 100644 index 0000000000..62df682b1d --- /dev/null +++ b/packages/types/src/__tests__/strict-widget-slot-registered-inputs-11022.test.ts @@ -0,0 +1,225 @@ +/** + * 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 strict authoring face admits the widget-slot `metric-card`'s REGISTERED + * inputs, and still refuses a key no registration declares (objectui#11022). + * + * ## The defect + * + * `metric-card` sits in a dashboard's widget slot as a component node whose + * props are its registry `inputs` (`title`, `value`, `icon`, `trend`, + * `trendValue`, `description`), admitted on the tolerant face by `BaseSchema`'s + * `.passthrough()` catchall, by ruling (objectstack#8593) — the private slot arm + * in `../zod/complex.zod.ts` declares none of them. The strict walker closes + * every object's catchall, so `StrictAnyComponentSchema` refused `value` on + * both arms of the slot's union, as `unrecognized_keys`: every correctly + * authored `metric-card` that carried its required input. + * + * ## The repair, and what each block below holds + * + * The arm records its registration's input names (`declareRegisteredInputs`), + * and the walker admits exactly those, judged by the arm's own catchall, before + * closing the object. The blocks pin, in order: the grade's two pins; the + * content channels objectui#9256 refused staying refused; the admitted keys + * being judged as the tolerant face judges them and no narrower; the tolerant + * face not moving; and the derivation itself on a hand-built node, so the rule + * is pinned apart from the one card that motivated it. + * + * ⚠️ Where the grade's pin sits. `metric-card` is deliberately NOT an arm of + * `AnyComponentSchema` (the ruling keeps it a property of the widget slot), so a + * bare `{ type: 'metric-card', value: 42 }` at the ROOT is refused at `type` on + * both faces, before and after this change — the control below says so. The + * grade's widget therefore sits in a dashboard's `widgets` slot here. + * + * The parity of the recorded names with the LIVE registration, both + * directions, is measured by the registering package, which holds the + * registry: `metricCardRegisteredInputsStrictFace-11022.test.ts` in + * `@object-ui/plugin-dashboard`'s `__tests__`. + */ + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { + AnyComponentSchema, + BaseSchema, + DashboardComponentSchema, + deriveStrictAuthoringSchema, + StrictAnyComponentSchema, +} from '../zod/index.zod.js'; +import { declareRegisteredInputs, internals, registeredInputsOf } from '../zod/node-derivation.js'; + +/* ── Reading a refusal ───────────────────────────────────────────────────── */ + +type Issue = { + code: string; + path: PropertyKey[]; + message: string; + keys?: string[]; + errors?: Issue[][]; +}; + +const issuesOf = (schema: z.ZodType, doc: unknown): Issue[] | null => { + const result = schema.safeParse(doc); + return result.success ? null : (result.error.issues as unknown as Issue[]); +}; + +const dashboard = (widget: Record) => ({ type: 'dashboard', widgets: [widget] }); + +/** The ONE issue a widget refusal surfaces as: an `invalid_union` at the widget. */ +const widgetUnion = (doc: unknown): Issue | undefined => + issuesOf(StrictAnyComponentSchema, doc)?.find((i) => i.path.join('.') === 'widgets.0'); + +/* ── 1. the grade's pins ─────────────────────────────────────────────────── */ + +describe('objectui#11022 — the grade\'s pins, in the widget slot', () => { + it('`{ type: \'metric-card\', value: 42 }` parses under StrictAnyComponentSchema', () => { + const doc = dashboard({ type: 'metric-card', value: 42 }); + expect(issuesOf(StrictAnyComponentSchema, doc)).toBeNull(); + // Control: the tolerant face always accepted it — the two faces now agree. + expect(issuesOf(AnyComponentSchema, doc)).toBeNull(); + }); + + it('`{ type: \'metric-card\', bogus: 1 }` is still refused, by name, on the slot arm', () => { + const issue = widgetUnion(dashboard({ type: 'metric-card', bogus: 1 })); + expect(issue?.code).toBe('invalid_union'); + // Arm 1 is the component-node arm; its path is relative to the widget. + const [slotArm, widgetArm] = issue?.errors ?? []; + expect(slotArm).toEqual([expect.objectContaining({ code: 'unrecognized_keys', path: [], keys: ['bogus'] })]); + // The strict widget schema, which the union falls through to, refuses it too. + expect(widgetArm).toEqual([expect.objectContaining({ code: 'unrecognized_keys', path: [], keys: ['bogus'] })]); + }); + + it('CONTROL — `metric-card` is not a ROOT arm: refused at `type` on both faces, unchanged', () => { + for (const face of [AnyComponentSchema, StrictAnyComponentSchema]) { + const issues = issuesOf(face, { type: 'metric-card', value: 42 }); + expect(issues).toEqual([expect.objectContaining({ code: 'invalid_union', path: ['type'] })]); + } + }); +}); + +/* ── 2. objectui#9256's refusals are untouched ───────────────────────────── */ + +describe('objectui#11022 — the content channels objectui#9256 refused stay refused on the strict face', () => { + it.each(['children', 'body'])('`%s` on `metric-card` is refused with the by-name message', (key) => { + const issue = widgetUnion(dashboard({ type: 'metric-card', value: 42, [key]: [] })); + expect(issue?.code).toBe('invalid_union'); + const refusal = (issue?.errors ?? []).flat().find((i) => i.path.join('.') === key && i.code === 'invalid_type'); + expect(refusal?.message).toContain('`metric-card` reads NEITHER content channel'); + expect(refusal?.message).toContain('objectui#9256'); + }); +}); + +/* ── 3. admitted by key, judged as the tolerant face judges ──────────────── */ + +describe('objectui#11022 — the registered inputs are admitted by KEY; the strict face judges no value the tolerant face does not', () => { + const FULL = { + type: 'metric-card', + title: 'Revenue', + value: '$24k', + icon: 'dollar-sign', + trend: 'up', + trendValue: '+5%', + description: 'vs last month', + }; + + it('a card carrying every registered input parses on both faces', () => { + expect(issuesOf(StrictAnyComponentSchema, dashboard(FULL))).toBeNull(); + expect(issuesOf(AnyComponentSchema, dashboard(FULL))).toBeNull(); + }); + + it('a value the tolerant face admits unjudged is admitted unjudged — the strict face is the tolerant one minus undeclared KEYS', () => { + // `trend` outside the registration's enum and a numeric `title`: the + // catchall judges both on the tolerant face, so the strict face does too. + const loose = { type: 'metric-card', value: 42, trend: 'sideways', title: 7 }; + expect(issuesOf(AnyComponentSchema, dashboard(loose))).toBeNull(); + expect(issuesOf(StrictAnyComponentSchema, dashboard(loose))).toBeNull(); + }); + + it('a registered input the base already declares keeps the base member\'s judgment', () => { + // `description` is a `BaseSchema` member (a string or an inline locale map); + // its declared type still refuses a number on BOTH faces, so the record did + // not replace the member with the catchall. + const bad = dashboard({ type: 'metric-card', value: 1, description: 42 }); + expect(issuesOf(AnyComponentSchema, bad)).not.toBeNull(); + expect(issuesOf(StrictAnyComponentSchema, bad)).not.toBeNull(); + }); +}); + +/* ── 4. the tolerant face does not move ──────────────────────────────────── */ + +describe('objectui#11022 — the tolerant slot arm is untouched', () => { + /** The slot's component-node arm, read off the exported tolerant schema. */ + const tolerantSlotArm = (): z.ZodType => { + const widgets = internals(DashboardComponentSchema)._zod.def.shape?.widgets; + let node = widgets as z.ZodType; + while (internals(node)._zod.def.innerType) node = internals(node)._zod.def.innerType as z.ZodType; + const element = internals(node)._zod.def.element as z.ZodType; + return (internals(element)._zod.def.options ?? [])[0] as z.ZodType; + }; + + it('its shape declares none of the registered inputs the base does not — the record is a side table', () => { + const arm = tolerantSlotArm(); + // The arm overrides `type`, `body` and `children`, all three base members, + // so its key set IS the base's: no registered input became a member. + const baseKeys = Object.keys(internals(BaseSchema)._zod.def.shape ?? {}).sort(); + const armKeys = Object.keys(internals(arm)._zod.def.shape ?? {}).sort(); + expect(armKeys).toEqual(baseKeys); + for (const input of ['title', 'value', 'icon', 'trend', 'trendValue']) expect(armKeys).not.toContain(input); + // Non-vacuity: this IS the arm that carries the record. + expect(registeredInputsOf(arm)).toContain('value'); + }); + + it('its catchall is still the passthrough, so an undeclared key is still accepted there', () => { + const arm = tolerantSlotArm(); + expect(internals(internals(arm)._zod.def.catchall as z.ZodType)._zod.def.type).toBe('unknown'); + expect(issuesOf(AnyComponentSchema, dashboard({ type: 'metric-card', bogus: 1 }))).toBeNull(); + }); +}); + +/* ── 5. the derivation, apart from the card ──────────────────────────────── */ + +describe('objectui#11022 — the rule lives in the derivation: a hand-built passthrough node', () => { + const node = () => z.object({ type: z.literal('probe'), n: z.number().optional() }).passthrough(); + + it('WITHOUT a record, the strict twin refuses the would-be input — the pre-repair behaviour', () => { + const strict = deriveStrictAuthoringSchema(node()); + expect(issuesOf(strict, { type: 'probe', a: 1 })).toEqual([ + expect.objectContaining({ code: 'unrecognized_keys', keys: ['a'] }), + ]); + }); + + it('WITH a record, the named key is admitted with any value the catchall admits, and any other key is refused by name', () => { + const strict = deriveStrictAuthoringSchema(declareRegisteredInputs(node(), ['a'])); + expect(issuesOf(strict, { type: 'probe', a: { anything: true } })).toBeNull(); + expect(issuesOf(strict, { type: 'probe' })).toBeNull(); + expect(issuesOf(strict, { type: 'probe', a: 1, b: 2 })).toEqual([ + expect.objectContaining({ code: 'unrecognized_keys', keys: ['b'] }), + ]); + }); + + it('a recorded name the shape already declares keeps its declared member', () => { + const strict = deriveStrictAuthoringSchema(declareRegisteredInputs(node(), ['n'])); + expect(issuesOf(strict, { type: 'probe', n: 'not a number' })).toEqual([ + expect.objectContaining({ code: 'invalid_type', path: ['n'] }), + ]); + }); + + it('the source node is left as it was — the record moves the twin, not the tolerant node', () => { + const source = declareRegisteredInputs(node(), ['a']); + deriveStrictAuthoringSchema(source); + expect(Object.keys(internals(source)._zod.def.shape ?? {}).sort()).toEqual(['n', 'type']); + expect(issuesOf(source, { type: 'probe', a: 1, b: 2 })).toBeNull(); + }); + + it('⛔ a node with no catchall cannot carry a record — there is no tolerant judgment to copy', () => { + expect(() => declareRegisteredInputs(z.object({ type: z.literal('probe') }), ['a'])).toThrow(TypeError); + expect(() => declareRegisteredInputs(z.string(), ['a'])).toThrow(TypeError); + }); +}); diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index b79ca45d8a..16f1b44072 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -2088,7 +2088,11 @@ export interface FloatingChatbotConfig { * retired `type` would keep validating. Adding a member is a deliberate act * with a registration behind it — `examples/schema-catalog/test/ * plugin-dashboard-component-schema.test.ts` is the standing gate, and - * `__tests__/report-chart-query-spec-parity.test.ts` pins the closure. + * `__tests__/report-chart-query-spec-parity.test.ts` pins the closure. The + * member also needs its row of registered input names in the zod slot arm's + * `DASHBOARD_WIDGET_SLOT_REGISTERED_INPUTS` (`zod/complex.zod.ts`), or the + * strict authoring face refuses its props (objectui#11022); `tsc` refuses the + * build without the row. */ export const DASHBOARD_COMPONENT_WIDGET_TYPES = ['metric-card'] as const; diff --git a/packages/types/src/strict-authoring-face.ts b/packages/types/src/strict-authoring-face.ts index 3b0dcb6527..0148925554 100644 --- a/packages/types/src/strict-authoring-face.ts +++ b/packages/types/src/strict-authoring-face.ts @@ -70,6 +70,20 @@ * tolerant `BaseSchemaCore.shape.body`. That direction is conservative: such a * check can only ADD refusals, never accept something the strict shape refused. * + * ## Registered inputs: where "declared" is the registration, not the shape + * + * One node on the face spells its props with its CATCHALL rather than its + * shape, by ruling: the widget-slot component node (`metric-card`, + * objectstack#8593) is `BaseSchema` plus a closed `type`, and its props are the + * component's registered `inputs`, admitted by `.passthrough()`. Closing that + * catchall alone refused the node's own inputs — every `metric-card` carrying + * `value` (objectui#11022). Such a node records its registered input names + * through `declareRegisteredInputs` (`./zod/node-derivation.ts`), and the + * `object` arm below admits exactly those names, each judged by the node's own + * catchall, before closing the object. So on this face a key is "declared" when + * the shape declares it OR the node's registration does; any other key is + * refused by name, as everywhere else. The tolerant node is not touched. + * * ## The recursion point, and why this card waited for it * * A child slot is `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])`, and @@ -93,7 +107,14 @@ import { SchemaNodeSchema } from './zod/base.zod.js'; // recursion-point fill is installed. The pin file asserts that end state from // the published barrel rather than trusting this paragraph. import { AnyComponentSchema } from './zod/index.zod.js'; -import { carryRegistryMeta, cloneWithDef, internals, isZodType, type WalkableDef } from './zod/node-derivation.js'; +import { + carryRegistryMeta, + cloneWithDef, + internals, + isZodType, + registeredInputsOf, + type WalkableDef, +} from './zod/node-derivation.js'; /** * One shape the strict walker could not close, reported as it is met. @@ -206,6 +227,19 @@ function createStrictWalker(options: DeriveStrictAuthoringOptions = {}): ; + /** * A COMPONENT node sitting directly in a dashboard's widget slot — the * `metric-card` extension the 2026-08-14 ruling (objectstack#8593) admits: @@ -1282,15 +1309,23 @@ const METRIC_CARD_NEITHER_CHANNEL = * unrecognized, and the author gets one `invalid_union` at the widget's path * with each arm's issues under `errors` — this arm's message among them, which * `objectui validate` prints as one arm of two. + * + * The strict authoring face (objectui#11022): the passthrough that admits the + * registry `inputs` here is exactly what that face closes, so this arm RECORDS + * the input names ({@link DASHBOARD_WIDGET_SLOT_REGISTERED_INPUTS}, through + * `declareRegisteredInputs`) and the strict walker admits them — each judged by + * the catchall, as on this face — while still refusing any key no registration + * declares. The record is a side table keyed by this node: this arm's shape, + * catchall and accept set are what they were. */ -const DashboardWidgetSlotComponentSchema = BaseSchema.extend({ +const DashboardWidgetSlotComponentSchema = declareRegisteredInputs(BaseSchema.extend({ type: z.enum(DASHBOARD_COMPONENT_WIDGET_TYPES) .describe('objectui component type legal in a widget slot (closed set)'), // objectui#9256: `MetricCard` reads NEITHER content channel, so both are refused by name, each // kept a MEMBER, as on the TypeScript twin. body: retirementTombstone(METRIC_CARD_NEITHER_CHANNEL), children: retirementTombstone(METRIC_CARD_NEITHER_CHANNEL), -}); +}), Object.values(DASHBOARD_WIDGET_SLOT_REGISTERED_INPUTS).flat()); /** * Global Filter Schema — a dashboard-level filter definition: `@objectstack/spec/ui`'s diff --git a/packages/types/src/zod/node-derivation.ts b/packages/types/src/zod/node-derivation.ts index 7ddf40c50b..b3ab08eee0 100644 --- a/packages/types/src/zod/node-derivation.ts +++ b/packages/types/src/zod/node-derivation.ts @@ -274,3 +274,63 @@ export const cloneWithDef = (schema: z.ZodType, patch: Partial): z. const Ctor = internals(schema).constructor; return carryRegistryMeta(schema, new Ctor({ ...internals(schema)._zod.def, ...patch })); }; + +/** + * REGISTERED INPUTS — the keys a component's REGISTRATION declares as `inputs`, + * recorded on the passthrough object that carries them (objectui#11022). + * + * A node whose props the ruling routes to `BaseSchema`'s `.passthrough()` + * rather than to members of its own — the widget-slot component node, + * `metric-card` (objectstack#8593) — has an accept set the tolerant face spells + * with its catchall, not with its shape: `value` is admitted because the + * catchall admits every key. The strict authoring face closes that catchall, so + * without this record it refused the node's own registered inputs as + * unrecognized — every correctly authored `metric-card` that carried `value`. + * + * A record here is what the strict walker reads instead: it admits each named + * key the shape does not already declare, judged by the object's OWN catchall + * (the judgment the tolerant face gave it), and still closes the object, so a + * key no registration declares is refused by name. The tolerant object is not + * touched — not its shape, not its catchall, not its registry metadata — so the + * rendering face's accept set, its output and its inferred type do not move. + * + * ⛔ A plain `WeakMap` keyed by NODE IDENTITY, and ⛔ not `.meta()` / + * `z.globalRegistry`: a registry entry is published by `z.toJSONSchema` and + * inherited down zod's `clone()` parent chain, and this record is neither a + * description of the node nor something a copy of it should claim. + * + * ⚠️ The names are declared where the node is (`@object-ui/types` has no + * dependency on the registry that holds the registration). Their parity with + * the registration is measured by the registering package's own test, which + * reads the live `ComponentRegistry` — see the arm that records them. + */ +const REGISTERED_INPUTS = new WeakMap(); + +/** + * Record the registered input names of a PASSTHROUGH object node, for the + * strict authoring face to admit (see {@link REGISTERED_INPUTS}). Returns the + * node unchanged, so it can wrap a declaration in place. + * + * ⛔ Throws on a node that is not an object with a catchall: on a stripping + * object the tolerant face DROPS an undeclared key, so there is no tolerant + * judgment for the strict face to copy, and admitting the key there would make + * the strict face keep what the tolerant one throws away. + */ +export const declareRegisteredInputs = (schema: T, names: readonly string[]): T => { + const def = internals(schema)._zod.def; + if (def.type !== 'object' || def.catchall === undefined) { + throw new TypeError( + `declareRegisteredInputs: registered inputs can only be recorded on a passthrough object node (got \`${def.type}\`` + + `${def.type === 'object' ? ' with no catchall' : ''}). The strict face admits them with the catchall's own judgment, ` + + 'and a node without one has no judgment to copy.', + ); + } + REGISTERED_INPUTS.set(schema, Object.freeze([...names])); + return schema; +}; + +/** + * The registered input names recorded on this exact node, or `undefined` when + * none are. Identity-keyed: a clone or a wrapper of the node carries none. + */ +export const registeredInputsOf = (schema: z.ZodType): readonly string[] | undefined => REGISTERED_INPUTS.get(schema);