From f9a4419ae6d13c9283ded10c79097c0d5e5a9250 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:44:55 +0000 Subject: [PATCH 1/6] feat(types): arm element:number with the spec's dataSource waiver on its required object The bag is ComponentPropsMap['element:number'] by reference with `object` alone made optional (`.partial({ object: true })` after the import boundary); a node refinement restores the requiredness wherever the spec gate's waiver does not apply (dataSource.object a non-empty name). The node declares `dataSource` as the spec's ElementDataSourceSchema, by reference. Ledgers: the EXCLUSIONS row in the mirror-parity census, the two IMPORTED rows in the boundary differential, and the one clean import the boundary's lazy arm rebuilds (ElementDataSourceSchema) named in that differential and in the walker docblock. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .../__tests__/imported-defaults-8317.test.ts | 48 ++++++- .../src/__tests__/zod-mirror-parity.test.ts | 4 + packages/types/src/zod/imported-defaults.ts | 11 +- packages/types/src/zod/index.zod.ts | 1 + packages/types/src/zod/public-blocks.zod.ts | 124 ++++++++++++++++-- 5 files changed, 170 insertions(+), 18 deletions(-) diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index f7ffc33a61..01121a1b0e 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -98,6 +98,9 @@ import { RecordAlertProps as SpecRecordAlertProps, ElementTextPropsSchema as SpecElementTextPropsSchema, ElementButtonPropsSchema as SpecElementButtonPropsSchema, + // objectui#10872 batch 2 — `element:number`'s row, and the node's `dataSource`. + ElementNumberPropsSchema as SpecElementNumberPropsSchema, + ElementDataSourceSchema as SpecElementDataSourceSchema, objectNavTargetExclusivity, checkListViewCalendarVisualization, checkPageSourceCompleteness, @@ -250,12 +253,31 @@ const IMPORTED: Array = [ ['RecordAlertProps', SpecRecordAlertProps], ['ElementTextPropsSchema', SpecElementTextPropsSchema], ['ElementButtonPropsSchema', SpecElementButtonPropsSchema], + // objectui#10872 batch 2: the `element:number` arm's bag is its row (with + // `object` made optional after the strip), and its node declares + // `dataSource` as the spec's element binding — two crossings, measured here + // like every other one. + ['ElementNumberPropsSchema', SpecElementNumberPropsSchema], + ['ElementDataSourceSchema', SpecElementDataSourceSchema], ] as const; /** The subset that actually carries an imported default — where the strip does work. */ const CARRIES_DEFAULT = IMPORTED.filter(([, s]) => defaultsIn(s) > 0); -/** …and its complement, where the strip must be the identity function. */ -const CARRIES_NONE = IMPORTED.filter(([, s]) => defaultsIn(s) === 0); +/** + * The clean imports the walker's `lazy` arm rebuilds anyway: no default + * anywhere below them, but a `z.lazy` is, and that arm cannot answer "was + * anything stripped below me?" without forcing the getter, so it always + * rebuilds (the walker docblock in `../zod/imported-defaults.ts`). The identity + * property therefore cannot hold for these, and they are NAMED rather than + * inferred, so the set cannot grow in silence — the lazy test below holds it + * equal to what the graph re-derives. + * + * `ElementDataSourceSchema` (objectui#10872 batch 2, the `element:number` + * arm's `dataSource`) reaches the recursive filter clause through `filter`. + */ +const REBUILT_CLEAN: ReadonlySet = new Set(['ElementDataSourceSchema']); +/** …and the complement of both, where the strip must be the identity function. */ +const CARRIES_NONE = IMPORTED.filter(([n, s]) => defaultsIn(s) === 0 && !REBUILT_CLEAN.has(n)); describe('the import boundary strips every imported default (objectui#8317)', () => { describe('positive controls — the instruments see something', () => { @@ -310,14 +332,26 @@ describe('the import boundary strips every imported default (objectui#8317)', () // must rebuild without forcing the getter, so a clean subtree behind a // `z.lazy` is rebuilt anyway. The module's docblock names FOUR such // nodes (the fourth, `ViewSchema`'s form-field group, since objectui#7928) - // and says the exception costs nothing today because each sits - // inside a schema that is being rebuilt regardless. Both halves are - // measured here, so a spec bump that moves either one is red rather than - // quietly making the docblock false. + // and says the exception costs nothing extra for every owner but the + // ones `REBUILT_CLEAN` names (objectui#10872 batch 2), because each + // other owner sits inside a schema that is being rebuilt regardless. + // Both halves are measured here, so a spec bump or an import that moves + // either one is red rather than quietly making the docblock false. expect(walk(IMPORTED.map(([, s]) => s)).lazies).toBe(4); const lazyOwners = IMPORTED.filter(([, s]) => walk([s]).lazies > 0); expect(lazyOwners.length, 'no schema owns a lazy — the count above found them elsewhere').toBeGreaterThan(0); - for (const [name, schema] of lazyOwners) { + // The clean owners are exactly the named set: rebuilt (so NOT the spec's + // object), and answering every probe as the spec does (the accept-set + // differential below runs over every `IMPORTED` row, these included). + expect( + lazyOwners.filter(([, s]) => defaultsIn(s) === 0).map(([n]) => n).sort(), + 'the clean imports the `lazy` arm rebuilds moved — update `REBUILT_CLEAN` and the docblock in ' + + '`../zod/imported-defaults.ts`', + ).toEqual([...REBUILT_CLEAN].sort()); + for (const [name, schema] of IMPORTED.filter(([n]) => REBUILT_CLEAN.has(n))) { + expect(stripImportedDefaults(schema), `${name} came back reference-equal — it left REBUILT_CLEAN`).not.toBe(schema); + } + for (const [name, schema] of lazyOwners.filter(([n]) => !REBUILT_CLEAN.has(n))) { expect( defaultsIn(schema), `${name} reaches a z.lazy but carries no default — the lazy exception now costs a ` + diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index df97b47fea..b7a039c6e4 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4077,6 +4077,10 @@ const EXCLUSIONS: Readonly> = { "spec-owned BY REFERENCE — `BaseSchema` + the `record:alert` literal + `properties`, which IS `ComponentPropsMap['record:alert']`; no TS declaration in this package restates the node", 'public-blocks.zod.ts#ElementTextBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `element:text` literal + `properties`, which IS `ComponentPropsMap['element:text']`; no TS declaration in this package restates the node", + // objectui#10872 batch 2 — the one arm with a node-level refinement: the + // spec's `dataSource` waiver on the row's required `object`. + 'public-blocks.zod.ts#ElementNumberBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `element:number` literal + `properties`, which IS `ComponentPropsMap['element:number']` with `object` alone made optional (`.partial({ object: true })`), + `dataSource`, which IS the spec's `ElementDataSourceSchema`, + the refinement that restores `object`'s requiredness wherever the spec gate's `dataSource` waiver does not apply; no TS declaration in this package restates the node", 'public-blocks.zod.ts#ElementButtonBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `element:button` literal + `properties`, which IS `ComponentPropsMap['element:button']`; no TS declaration in this package restates the node", 'public-blocks.zod.ts#ElementDividerBlockSchema': diff --git a/packages/types/src/zod/imported-defaults.ts b/packages/types/src/zod/imported-defaults.ts index b9c3e97a59..544dad0fe6 100644 --- a/packages/types/src/zod/imported-defaults.ts +++ b/packages/types/src/zod/imported-defaults.ts @@ -135,9 +135,14 @@ const walk = (schema: z.ZodType): z.ZodType => { // for its `listViews` slot: the recursive form-field group under // `ViewSchema.formViews[…].groups[].fields[]`, walked because the whole // container crosses the boundary, not only the slot. - // Each sits inside a schema that carries a default anyway, so today the - // exception costs no extra rebuild — the pin file re-derives that count and - // goes red if it moves, rather than trusting this sentence. + // Each sits inside a schema that carries a default anyway, so the exception + // costs no extra rebuild — with ONE named exception since objectui#10872 + // batch 2: `ElementDataSourceSchema` (the `element:number` arm's + // `dataSource`) reaches the recursive filter clause and carries no default, + // so it is the one clean import this arm rebuilds — an equal-answering + // clone, not the spec's own object. The pin file re-derives the count and + // that named set (`REBUILT_CLEAN`), and goes red if either moves, rather than + // trusting this sentence. // // ⛔ Rebuilt through `cloneWithDef`, not `z.lazy(…)`: a fresh `z.lazy` would // be a different class with none of this node's own `def.checks` or registry diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index 0a44662875..a6745132d5 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -430,6 +430,7 @@ export { RecordReferenceRailBlockSchema, RecordAlertBlockSchema, ElementTextBlockSchema, + ElementNumberBlockSchema, ElementButtonBlockSchema, ElementDividerBlockSchema, PublicBlockComponentSchema, diff --git a/packages/types/src/zod/public-blocks.zod.ts b/packages/types/src/zod/public-blocks.zod.ts index ac2871ffb6..c848ddf759 100644 --- a/packages/types/src/zod/public-blocks.zod.ts +++ b/packages/types/src/zod/public-blocks.zod.ts @@ -14,7 +14,7 @@ * (`packages/core/src/registry/public-blocks.ts`) — whose props * `@objectstack/spec` declares as a `ComponentPropsMap` row: the `page:` * structure blocks, the `record:` blocks that carry a row, and `element:text`, - * `element:button` and `element:divider`. + * `element:number`, `element:button` and `element:divider`. * * ## Why this module exists (objectui#10872) * @@ -38,10 +38,11 @@ * refinements and retired-key tombstones all arrive from the spec, and a row * the spec changes changes the arm the same day. No member is restated here, * so none can drift — except the one row the spec does not export by name - * (`element:divider`, below). The only node-level member any arm adds is an + * (`element:divider`, below). The node-level members an arm adds are two: an * `on*` key a renderer reads off the node (`page:tabs`'s `onTabChange`), * refused by name with `handlerKeyRefusal` as `check:handler-key-reads` - * requires of every such read. + * requires of every such read, and `element:number`'s `dataSource`, the spec's + * own binding schema by reference (below). * * The bag is the one spelling every block here is read through at runtime: * `SchemaRenderer` hoists each `properties` key onto the node before the @@ -67,14 +68,25 @@ * the spec's own props gate (`validateComponentProps`, `@objectstack/lint`) * judges a bag only when the node carries one, and so does this face. * + * ## The one waiver: `element:number`'s `object` (objectui#10872 batch 2) + * + * `ComponentPropsMap['element:number']` requires `object`, and the spec's props + * gate waives exactly that one member when the node's `dataSource.object` is a + * non-empty name (`DATASOURCE_SUPPLIED_PROP` and `suppliedByDataSource` in + * `@objectstack/lint`'s `validate-component-props.ts`). The row alone would + * refuse that spec-valid node, so this arm mirrors the waiver — and only it — + * in two halves, both by reference: the bag is the row with `object` alone made + * optional (`ElementNumberPropsBag`), and a node refinement puts the + * requiredness back wherever the waiver does not apply + * (`elementNumberObjectIsSupplied`). The node also declares `dataSource`, as the + * spec's `ElementDataSourceSchema` read by reference — the same schema + * `PageComponentSchema.dataSource` is. + * * ## The public blocks NOT armed here, and why * - * Held by objectui#10872 batch 1 with the evidence on that card — each is a - * reading, not an oversight: + * Held by objectui#10872 with the evidence on that card — each is a reading, + * not an oversight: * - * - `element:number` — its row requires `object`, which the spec's props gate - * waives when the node binds through `dataSource` (`DATASOURCE_SUPPLIED_PROP` - * in `@objectstack/lint`). The row alone would refuse that spec-valid node. * - `record:line_items` — the spec carries no row, on purpose: its * `STRING_ARM_REGISTERED_TYPES` ledger records the row as still to be * measured from the renderer's read points. @@ -110,7 +122,9 @@ import { RecordReferenceRailProps as SpecRecordReferenceRailProps, RecordAlertProps as SpecRecordAlertProps, ElementTextPropsSchema as SpecElementTextPropsSchema, + ElementNumberPropsSchema as SpecElementNumberPropsSchema, ElementButtonPropsSchema as SpecElementButtonPropsSchema, + ElementDataSourceSchema as SpecElementDataSourceSchema, } from '@objectstack/spec/ui'; import { BaseSchema } from './base.zod.js'; import { stripImportedDefaults } from './imported-defaults.js'; @@ -263,6 +277,99 @@ export const ElementTextBlockSchema = BaseSchema.extend({ properties: propsBag('element:text', stripImportedDefaults(SpecElementTextPropsSchema)), }); +/** + * The `element:number` bag: `ComponentPropsMap['element:number']` with ONE + * member, `object`, made optional — the half of the spec's `dataSource` waiver + * that lives in the bag (`elementNumberObjectIsSupplied` below is the other). + * + * By reference, and here is how: zod 4's `.partial({ object: true })` clones + * the row's own def — so its strictness (`catchall` never), its unknown-key + * guidance and its `.describe()` text travel — and replaces exactly the masked + * member with a `ZodOptional` wrapped around the row's OWN `object` schema. + * Every other member (`field`, `aggregate`, `filter`, `format`, `prefix`, + * `suffix`, `aria`) is handed through as the very object the row holds. No + * member is restated, so a row the spec widens or narrows moves this bag the + * same day. `.partial()` THROWS on an object that carries refinements, which is + * the loud answer this module wants if the spec ever installs one on the row: + * the waiver then has to be re-decided, not silently lose the check. + * + * Not exported: the zod-mirror parity census reads `export const` out of this + * directory, and this is a derivation of the spec row, not a mirror of a + * declaration in this package. + */ +const ElementNumberPropsBag = stripImportedDefaults(SpecElementNumberPropsSchema).partial({ object: true }); + +/** + * Does this node's `dataSource` name the object `element:number` aggregates + * over? The spec gate's `suppliedByDataSource` answer, read for this one node: + * a `dataSource` that is a record whose `object` is a NON-EMPTY string. An + * empty name, a non-string, a non-record, or no binding supplies nothing — the + * gate's `strName` refuses the same three. + */ +function dataSourceSuppliesObject(node: { dataSource?: unknown }): boolean { + const dataSource = node.dataSource; + if (!dataSource || typeof dataSource !== 'object' || Array.isArray(dataSource)) return false; + const object = (dataSource as { object?: unknown }).object; + return typeof object === 'string' && object.length > 0; +} + +/** + * The node half of the waiver: a bag that omits `object` is refused at + * `properties.object` UNLESS `dataSource.object` supplies it — which is the + * spec row's requiredness, put back everywhere the spec gate does not waive it. + * A node with no bag at all is not judged, exactly as the gate skips a node + * whose `properties` is absent (and as every other arm here leaves its bag + * optional). A bag whose `object` is PRESENT is left to the row's own member + * verdict, so a wrong `object` is reported once, by the row. + * + * ⚠️ `when: () => true` is load-bearing: zod skips a refinement once an earlier + * issue aborts the parse, and the gate reports a missing `object` BESIDE every + * other bag issue. Without it, a bag with a bad `aggregate` and no `object` + * would report only the `aggregate`. So the body reads the raw input + * defensively — the bag may be anything when it runs. + */ +function elementNumberObjectIsSupplied( + node: { properties?: unknown; dataSource?: unknown }, + ctx: z.core.$RefinementCtx, +): void { + const bag = node.properties; + if (!bag || typeof bag !== 'object' || Array.isArray(bag)) return; + if ((bag as { object?: unknown }).object !== undefined) return; + if (dataSourceSuppliesObject(node)) return; + ctx.addIssue({ + code: 'custom', + path: ['properties', 'object'], + params: { code: 'ELEMENT_NUMBER_OBJECT_REQUIRED' }, + message: + '`element:number` names no object to aggregate: set `properties.object`, or bind the node ' + + 'through `dataSource.object` (a non-empty object name). `ComponentPropsMap[\'element:number\']` ' + + 'requires `object`, and the spec waives it only beside `dataSource.object`.', + }); +} + +/** + * `element:number` — `ComponentPropsMap['element:number']`, with the spec's one + * `dataSource` waiver on its required `object` (see the module docblock and the + * two helpers above), and the node's `dataSource` declared as the spec's + * `ElementDataSourceSchema`, by reference. + */ +export const ElementNumberBlockSchema = BaseSchema.extend({ + type: z.literal('element:number'), + properties: ElementNumberPropsBag + .optional() + .describe( + 'The `element:number` props bag — `@objectstack/spec` `ComponentPropsMap[\'element:number\']`, by reference, ' + + 'with `object` optional only because the spec waives it beside `dataSource.object`; without that binding a ' + + 'bag must name `object`. Judged only when present, as the spec\'s props gate judges it.', + ), + dataSource: stripImportedDefaults(SpecElementDataSourceSchema) + .optional() + .describe( + 'Per-element data binding — `@objectstack/spec` `ElementDataSourceSchema`, the schema ' + + '`PageComponentSchema.dataSource` declares, by reference.', + ), +}).superRefine(elementNumberObjectIsSupplied, { when: () => true }); + /** `element:button` — `ComponentPropsMap['element:button']`. */ export const ElementButtonBlockSchema = BaseSchema.extend({ type: z.literal('element:button'), @@ -315,6 +422,7 @@ export const PublicBlockComponentSchema = z.discriminatedUnion('type', [ RecordReferenceRailBlockSchema, RecordAlertBlockSchema, ElementTextBlockSchema, + ElementNumberBlockSchema, ElementButtonBlockSchema, ElementDividerBlockSchema, ]); From dfd6757227083ff842c1a4e1bb0a6064b98ba38b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:52:59 +0000 Subject: [PATCH 2/6] test(types,cli): pin element:number's arm, its dataSource waiver and the ratchet (397) The pins measure the arm against the installed spec: both binding forms accepted on both faces, a bag with neither refused at properties.object, an empty or non-string dataSource.object supplying nothing, a spec-invalid member refused by the row, taught spec documents accepted, a per-probe differential against PageComponentSchema plus the gate's waiver, and the bag and binding read by reference. The namespaced ratchet falls to 397; objectui validate takes element:number in both forms end to end. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- ...tered-types-validate-ratchet-10859.test.ts | 52 ++- .../element-number-arm-10872.test.ts | 300 ++++++++++++++++++ .../__tests__/public-block-arms-10872.test.ts | 9 +- 3 files changed, 357 insertions(+), 4 deletions(-) create mode 100644 packages/types/src/__tests__/element-number-arm-10872.test.ts diff --git a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts index f3212bf59e..4f2bdbd462 100644 --- a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts +++ b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts @@ -73,10 +73,11 @@ const REFUSED_AT_TYPE = 76; /** * The head's refused count over the NAMESPACED keys (objectui#10872 batch 1: * 418 on `main` before it, minus the twenty ADR-0080 public blocks armed from - * their `@objectstack/spec` `ComponentPropsMap` rows). LOWER it when a batch - * arms more keys; never raise it. + * their `@objectstack/spec` `ComponentPropsMap` rows; batch 2: minus + * `element:number`, armed with the spec's `dataSource` waiver). LOWER it when + * a batch arms more keys; never raise it. */ -const NAMESPACED_REFUSED_AT_TYPE = 398; +const NAMESPACED_REFUSED_AT_TYPE = 397; /** The bare registry keys — the population the card measured. */ const BARE_KEYS = KNOWN_SCHEMA_TYPES.filter((key) => !key.includes(':')); @@ -95,6 +96,9 @@ const ARMED_PUBLIC_BLOCKS_10872 = [ 'element:text', 'element:button', 'element:divider', ] as const; +/** The public block objectui#10872 batch 2 armed. */ +const ARMED_PUBLIC_BLOCKS_10872_BATCH_2 = ['element:number'] as const; + /** Is `type` unclaimed by every arm of the validator's root union? */ function refusedAtType(type: string): boolean { const result = safeValidateSchema({ type }); @@ -177,6 +181,13 @@ describe('registered NAMESPACED component types refused at `type` — a ratchet expect(refusedAtType(key), key).toBe(false); } }); + + it('counts the public block objectui#10872 batch 2 armed', () => { + for (const key of ARMED_PUBLIC_BLOCKS_10872_BATCH_2) { + expect(NAMESPACED_KEYS, key).toContain(key); + expect(refusedAtType(key), key).toBe(false); + } + }); }); /* ── End to end: the README document through `objectui validate` ─────────── */ @@ -285,4 +296,39 @@ describe('objectui validate — a page built from ADR-0080 public blocks (object expect(text).toContain('inventedProp10872'); expect(exitCodes).toEqual([1]); }); + + it('validates `element:number` in both of its binding forms (objectui#10872 batch 2)', async () => { + // The props form, and the `dataSource` form the spec's props gate waives + // `properties.object` for. + const page = { + type: 'page', + children: [ + { type: 'element:number', properties: { object: 'order', aggregate: 'count' } }, + { type: 'element:number', dataSource: { object: 'order' }, properties: { aggregate: 'sum', field: 'total' } }, + ], + }; + const file = join(dir, 'element-number-page.json'); + writeFileSync(file, JSON.stringify(page, null, 2), 'utf-8'); + + await validate(file); + + const text = out.join('\n').replace(ANSI, ''); + expect(text).not.toContain('Schema validation failed'); + expect(text).toContain('Schema is valid'); + expect(exitCodes).toEqual([0]); + }); + + it('refuses an `element:number` that names its object nowhere, at `properties.object`', async () => { + // The control that keeps the row above from passing for the wrong reason. + const page = { type: 'page', children: [{ type: 'element:number', properties: { aggregate: 'count' } }] }; + const file = join(dir, 'element-number-page-refused.json'); + writeFileSync(file, JSON.stringify(page, null, 2), 'utf-8'); + + await validate(file); + + const text = out.join('\n').replace(ANSI, ''); + expect(text).toContain('Schema validation failed'); + expect(text).toContain('dataSource.object'); + expect(exitCodes).toEqual([1]); + }); }); diff --git a/packages/types/src/__tests__/element-number-arm-10872.test.ts b/packages/types/src/__tests__/element-number-arm-10872.test.ts new file mode 100644 index 0000000000..d66a9348fa --- /dev/null +++ b/packages/types/src/__tests__/element-number-arm-10872.test.ts @@ -0,0 +1,300 @@ +/** + * 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. + */ + +/** + * `element:number` has a zod arm, with the spec's one `dataSource` waiver on + * its required `object` (objectui#10872, batch 2). + * + * ## The defect these pin + * + * `element:number` is registered, curated by ADR-0080 and declared by the spec + * (`ComponentPropsMap['element:number']`), and `AnyComponentSchema` carried no + * arm for it — so `safeValidateSchema`, and `objectui validate` with it, + * refused every `element:number` document with `invalid_union` at `type`, + * whichever form it bound its object in. + * + * ## Why this block was held from batch 1 + * + * Its row REQUIRES `object`, and the spec's own props gate waives exactly that + * one member when the node binds through `dataSource.object` + * (`DATASOURCE_SUPPLIED_PROP` / `suppliedByDataSource` in `@objectstack/lint`'s + * `validate-component-props.ts`, pinned there by "does not report the required + * `object` prop when `dataSource` supplies it"). The row alone would refuse + * that spec-valid node. The arm mirrors the waiver — the bag is the row with + * `object` alone made optional, and a node refinement restores the + * requiredness wherever the waiver does not apply — so every row below + * measures the arm against the INSTALLED spec rather than a transcription. + */ + +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; +import { ComponentPropsMap, ElementDataSourceSchema, PageComponentSchema } from '@objectstack/spec/ui'; + +import { + ElementNumberBlockSchema, + PublicBlockComponentSchema, + StrictAnyComponentSchema, + safeValidateSchema, +} from '../zod/index.zod.js'; +import * as zodBarrel from '../zod/index.zod.js'; +import { stripImportedDefaults } from '../zod/imported-defaults.js'; + +const TYPE = 'element:number'; + +/** The spec's row, as the published map carries it. */ +const ROW = (ComponentPropsMap as unknown as Record)[TYPE]; + +type Result = { success: boolean; error?: { issues: z.core.$ZodIssue[] } }; + +/** The two faces a document is judged on: the tolerant one `objectui validate` runs, and the strict twin. */ +const FACES: ReadonlyArray Result]> = [ + ['tolerant', (document) => safeValidateSchema(document)], + ['strict', (document) => StrictAnyComponentSchema.safeParse(document)], +]; + +const pathsOf = (result: Result): string[] => + result.success ? [] : result.error!.issues.map((issue) => issue.path.join('.')); + +const issuesAt = (result: Result, path: string): z.core.$ZodIssue[] => + result.success ? [] : result.error!.issues.filter((issue) => issue.path.join('.') === path); + +/** The arm's `properties` member with its `.optional()` peeled off. */ +const bag = (): z.ZodObject => + (ElementNumberBlockSchema.shape.properties as unknown as z.ZodOptional).unwrap() as unknown as z.ZodObject; + +/** A bag that names everything but `object` — valid by the row once `object` is supplied. */ +const BAG_WITHOUT_OBJECT = { aggregate: 'sum', field: 'total' } as const; + +describe('`element:number` validates, with the spec\'s `dataSource` waiver (objectui#10872 batch 2)', () => { + it('a minimal `properties.object` document is accepted on both faces', () => { + const properties = { object: 'order', aggregate: 'count' }; + // Fixture control: the bag is valid by the spec's own row. + expect(ROW.safeParse(properties).success).toBe(true); + for (const [face, parse] of FACES) { + expect(parse({ type: TYPE, properties }).success, face).toBe(true); + } + }); + + it('a `dataSource.object` document with no `properties.object` is accepted on both faces', () => { + const document = { type: TYPE, dataSource: { object: 'order' }, properties: { ...BAG_WITHOUT_OBJECT } }; + for (const [face, parse] of FACES) { + expect(parse(document).success, face).toBe(true); + } + // Control: the row ALONE refuses this bag at `object` — so the acceptance + // above is the waiver's doing, not a bag that never needed it. + const rowAlone = ROW.safeParse(BAG_WITHOUT_OBJECT) as Result; + expect(rowAlone.success).toBe(false); + expect(pathsOf(rowAlone)).toEqual(['object']); + }); + + it('a document with neither is refused on both faces, at `properties.object`', () => { + const document = { type: TYPE, properties: { ...BAG_WITHOUT_OBJECT } }; + for (const [face, parse] of FACES) { + const result = parse(document); + expect(result.success, face).toBe(false); + const [issue] = issuesAt(result, 'properties.object'); + expect(issue?.code, face).toBe('custom'); + expect((issue as { params?: { code?: string } } | undefined)?.params?.code, face) + .toBe('ELEMENT_NUMBER_OBJECT_REQUIRED'); + // The remedy names both subjects the author can set. + expect(issue?.message, face).toContain('properties.object'); + expect(issue?.message, face).toContain('dataSource.object'); + } + }); + + it('the missing `object` is reported BESIDE another bag issue, as the spec gate reports it', () => { + // `when: () => true` on the refinement: without it zod skips the check once + // the bad `aggregate` aborts the parse, and the author learns of `object` + // only after fixing the first issue. + const result = safeValidateSchema({ type: TYPE, properties: { aggregate: 'median' } }); + expect(pathsOf(result).sort()).toEqual(['properties.aggregate', 'properties.object']); + }); + + it('a `dataSource` whose `object` is empty, or not a string, supplies nothing', () => { + for (const dataSource of [{ object: '' }, { object: 42 }, 'order']) { + const document = { type: TYPE, dataSource, properties: { ...BAG_WITHOUT_OBJECT } }; + for (const [face, parse] of FACES) { + const result = parse(document); + expect(result.success, `${face} ${JSON.stringify(dataSource)}`).toBe(false); + expect(issuesAt(result, 'properties.object').map((issue) => issue.code), `${face} ${JSON.stringify(dataSource)}`) + .toEqual(['custom']); + } + } + // Control: the spec's binding schema itself ACCEPTS the empty name — so the + // refusal above is the waiver's non-empty rule (the gate's `strName`), not + // the binding's own verdict. + expect(ElementDataSourceSchema.safeParse({ object: '' }).success).toBe(true); + }); + + it('the waiver covers an OMITTED `object` only — a wrong one beside a binding is the row\'s refusal', () => { + const document = { type: TYPE, dataSource: { object: 'order' }, properties: { object: 7, aggregate: 'count' } }; + for (const [face, parse] of FACES) { + const result = parse(document); + expect(result.success, face).toBe(false); + expect(issuesAt(result, 'properties.object').map((issue) => issue.code), face).toEqual(['invalid_type']); + } + }); + + it('a spec-invalid bag member is refused at the member, by reference', () => { + for (const [properties, path] of [ + [{ object: 'order', aggregate: 'count', format: 'roman' }, 'properties.format'], + // The record-form `filter` the spec's `element-number-filter-rule-array` + // migration retired: refused at `filter` by the row, and so here. + [{ object: 'order', aggregate: 'count', filter: { status: 'won' } }, 'properties.filter'], + ] as const) { + // Control: the spec's own row refuses the same bag, at the same member. + expect(pathsOf(ROW.safeParse(properties) as Result), path).toContain(path.replace('properties.', '')); + for (const [face, parse] of FACES) { + const result = parse({ type: TYPE, properties }); + expect(result.success, `${face} ${path}`).toBe(false); + expect(pathsOf(result), `${face} ${path}`).toContain(path); + } + } + }); + + it('an undeclared member inside the bag is refused by name — the row\'s strictness travels', () => { + const result = safeValidateSchema({ type: TYPE, properties: { object: 'order', aggregate: 'count', inventedKey10872: 1 } }); + const [issue] = issuesAt(result, 'properties'); + expect(issue?.code).toBe('unrecognized_keys'); + expect((issue as { keys?: string[] } | undefined)?.keys).toEqual(['inventedKey10872']); + }); + + it('taught and shipped `element:number` documents validate', () => { + for (const [source, document] of [ + // `@objectstack/spec` "should accept element:number component" — the + // props form, the one form every `element:number` document the platform + // teaches or ships uses (no example app authors one). + ['spec component fixture', { type: TYPE, properties: { object: 'order', aggregate: 'count' } }], + // The same suite's `PageComponent dataSource integration` document — + // BOTH forms at once — with its `dataSource.filter` left out: the + // installed spec's binding still takes the record-form filter there. + [ + 'spec dataSource-integration fixture', + { type: TYPE, properties: { object: 'order', aggregate: 'sum', field: 'total' }, dataSource: { object: 'order', limit: 100 } }, + ], + // The rule-array `filter` the spec's `element-number-filter-rule-array` + // migration prescribes. + [ + 'spec migration prescription', + { type: TYPE, properties: { object: 'task', aggregate: 'count', filter: [{ field: 'status', operator: 'equals', value: 'won' }] } }, + ], + ] as const) { + // Control: the spec's own node schema accepts each one. + expect(PageComponentSchema.safeParse(document).success, source).toBe(true); + for (const [face, parse] of FACES) { + expect(parse(document).success, `${face} ${source}`).toBe(true); + } + } + }); + + it('the arm is a named export of the barrel and a member of the public-block union', () => { + expect(Object.values(zodBarrel as unknown as Record)).toContain(ElementNumberBlockSchema); + const options = (PublicBlockComponentSchema as unknown as { options: z.ZodObject[] }).options; + expect(options).toContain(ElementNumberBlockSchema); + }); +}); + +/** + * The differential: on every probe, the arm answers exactly what the spec + * answers — its node schema (`PageComponentSchema`, which judges `dataSource`) + * together with its props gate over the row, with the gate's one waiver. Each + * probe is its own row, so a regression names the document it broke. + */ +describe('`element:number` answers as the spec does (objectui#10872 batch 2)', () => { + /** + * The spec gate's verdict on a node's bag: the row's issues, less the one the + * gate waives — `object` ABSENT while `dataSource.object` is a non-empty + * string (`DATASOURCE_SUPPLIED_PROP`, documented there as "the one prop whose + * absence this rule does NOT report"). A node with no bag is not judged. + * + * ⚠️ Absence, as that docblock and this card's order state it ("may omit + * `properties.object`"). The gate's `suppliedByDataSource` matches the issue + * by PATH alone, so it also waives a present-but-wrong `object` (a number) + * beside a binding; the arm does not, and the probe that tells the two apart + * is its own row below. + */ + function specGateAccepts(node: { properties?: unknown; dataSource?: unknown }): boolean { + const props = node.properties; + if (!props || typeof props !== 'object' || Array.isArray(props)) return true; + const parsed = ROW.safeParse(props) as Result; + if (parsed.success) return true; + const ds = node.dataSource as { object?: unknown } | undefined; + const supplied = !!ds && typeof ds === 'object' && typeof ds.object === 'string' && ds.object.length > 0; + const absent = (props as { object?: unknown }).object === undefined; + return parsed.error!.issues.every( + (issue) => supplied && absent && issue.path.length === 1 && issue.path[0] === 'object', + ); + } + + const PROBES: ReadonlyArray]> = [ + ['bare node', {}], + ['props form', { properties: { object: 'order', aggregate: 'count' } }], + ['bag without object, no binding', { properties: { ...BAG_WITHOUT_OBJECT } }], + ['bag without object, dataSource.object', { properties: { ...BAG_WITHOUT_OBJECT }, dataSource: { object: 'order' } }], + ['bag without object, empty dataSource.object', { properties: { ...BAG_WITHOUT_OBJECT }, dataSource: { object: '' } }], + ['bag without object, numeric dataSource.object', { properties: { ...BAG_WITHOUT_OBJECT }, dataSource: { object: 42 } }], + ['bag without object, dataSource a string', { properties: { ...BAG_WITHOUT_OBJECT }, dataSource: 'order' }], + ['bag without object, dataSource with an alias key', { properties: { ...BAG_WITHOUT_OBJECT }, dataSource: { object: 'order', objectName: 'order' } }], + ['props form, empty dataSource.object', { properties: { object: 'order', aggregate: 'count' }, dataSource: { object: '' } }], + ['dataSource only, no bag', { dataSource: { object: 'order' } }], + ['empty bag, dataSource.object', { properties: {}, dataSource: { object: 'order' } }], + ['bag with a bad member, dataSource.object', { properties: { ...BAG_WITHOUT_OBJECT, format: 'roman' }, dataSource: { object: 'order' } }], + ['bag with a non-string object, dataSource.object', { properties: { object: 7, aggregate: 'count' }, dataSource: { object: 'order' } }], + ['bag not a record', { properties: 'order' }], + ]; + + it.each(PROBES)('%s', (_name, fields) => { + const node = { type: TYPE, ...fields }; + const spec = PageComponentSchema.safeParse(node).success && specGateAccepts(node); + for (const [face, parse] of FACES) { + expect(parse(node).success, face).toBe(spec); + } + }); + + it('the probes include both verdicts (non-vacuity)', () => { + const verdicts = PROBES.map(([, fields]) => { + const node = { type: TYPE, ...fields }; + return PageComponentSchema.safeParse(node).success && specGateAccepts(node); + }); + expect(verdicts).toContain(true); + expect(verdicts).toContain(false); + }); +}); + +describe('`element:number` reads the spec by reference (objectui#10872 batch 2)', () => { + it('the bag declares exactly the row\'s members, and is closed as the row is', () => { + expect(Object.keys(bag().shape).sort()).toEqual(Object.keys(ROW.shape).sort()); + expect((bag() as unknown as { _zod: { def: { catchall?: { _zod: { def: { type: string } } } } } })._zod.def.catchall?._zod.def.type) + .toBe('never'); + }); + + it('every member but `object` IS the row\'s own member object; `object` is the row\'s own, made optional', () => { + const rowShape = ROW.shape as Record; + const bagShape = bag().shape as Record; + for (const key of Object.keys(rowShape).filter((k) => k !== 'object')) { + expect(bagShape[key], key).toBe(rowShape[key]); + } + expect((bagShape.object as unknown as { _zod: { def: { type: string } } })._zod.def.type).toBe('optional'); + expect((bagShape.object as unknown as z.ZodOptional).unwrap()).toBe(rowShape.object); + // Control: the row's own `object` is required, which is why the waiver exists. + expect(ROW.safeParse({ aggregate: 'count' }).success).toBe(false); + }); + + it('`dataSource` is the spec\'s element binding, through the import boundary', () => { + const member = (ElementNumberBlockSchema.shape.dataSource as unknown as z.ZodOptional).unwrap() as unknown as z.ZodObject; + const spec = ElementDataSourceSchema as unknown as z.ZodObject; + expect(Object.keys(member.shape).sort()).toEqual(Object.keys(spec.shape).sort()); + // The boundary's `lazy` arm rebuilds this one clean schema (the recursive + // filter clause sits under `filter`), so it is an equal-answering clone — + // `imported-defaults-8317.test.ts` names it in `REBUILT_CLEAN`. + expect(stripImportedDefaults(spec)).not.toBe(spec); + for (const probe of [{ object: 'order' }, { object: '' }, { object: 1 }, {}, { object: 'o', limit: 0 }, { object: 'o', objectName: 'o' }]) { + expect(member.safeParse(probe).success, JSON.stringify(probe)).toBe(spec.safeParse(probe).success); + } + }); +}); diff --git a/packages/types/src/__tests__/public-block-arms-10872.test.ts b/packages/types/src/__tests__/public-block-arms-10872.test.ts index 56dab6a53d..90cdb4c664 100644 --- a/packages/types/src/__tests__/public-block-arms-10872.test.ts +++ b/packages/types/src/__tests__/public-block-arms-10872.test.ts @@ -66,6 +66,9 @@ const VALID_BAG: Readonly>> = { 'record:reference_rail': { entries: [{ objectName: 'contact', relationshipField: 'account' }] }, 'record:alert': { severity: 'warning', title: 'Overdue' }, 'element:text': { content: 'Hello' }, + // objectui#10872 batch 2 — the bag that names its own `object`; the + // `dataSource` waiver is pinned in `./element-number-arm-10872.test.ts`. + 'element:number': { object: 'order', aggregate: 'count' }, 'element:button': { label: 'Go' }, 'element:divider': {}, }; @@ -161,7 +164,11 @@ describe('the bag is the spec row, read by reference (objectui#10872)', () => { expect(keysOf(bagOf(type))).toEqual(keysOf(rowOf(type))); }); - it.each(ARMED.filter((type) => type !== 'element:divider'))( + // `element:divider` is restated (below); `element:number`'s bag is its row + // with `object` alone made optional — the spec's `dataSource` waiver — so it + // is a clone by construction, and `./element-number-arm-10872.test.ts` pins + // it member by member against the row instead. + it.each(ARMED.filter((type) => type !== 'element:divider' && type !== 'element:number'))( '%s: the bag is the spec\'s own row object wherever the import boundary has nothing to strip', (type) => { const row = rowOf(type); From 3293f7a084ae00cc74c76d1673e4b72b41ecca01 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:59:55 +0000 Subject: [PATCH 3/6] docs(types): changeset for the element:number arm, README group, and a dated note on batch 1's changeset The batch-1 changeset's "less element:number" and "not armed in this release" item stop describing the release once this lands, so it carries a dated note; its frontmatter is unchanged. The pin's oracle takes the node as a plain record so the test project type-checks. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .changeset/10872-element-number-arm.md | 18 ++++++++++++++++++ .changeset/10872-public-block-zod-arms.md | 2 ++ .../__tests__/element-number-arm-10872.test.ts | 2 +- packages/types/src/zod/README.md | 6 +++--- 4 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 .changeset/10872-element-number-arm.md diff --git a/.changeset/10872-element-number-arm.md b/.changeset/10872-element-number-arm.md new file mode 100644 index 0000000000..eaf399d342 --- /dev/null +++ b/.changeset/10872-element-number-arm.md @@ -0,0 +1,18 @@ +--- +'@object-ui/types': minor +--- + +`safeValidateSchema` — and so `objectui validate` — accepts `element:number`, the ADR-0080 public block held back from batch 1, in both of the binding forms `@objectstack/spec` accepts (objectui#10872, batch 2). + +**Clause-②: yes** — the accept set of `AnyComponentSchema` widens by one `type` literal, and `@object-ui/types/zod` exports one new schema, `ElementNumberBlockSchema`. Nothing that parsed before is refused now. + +**What it was.** `element:number` is registered, curated as platform contract by ADR-0080 and declared by the spec (`ComponentPropsMap['element:number']`), while `AnyComponentSchema` carried no arm for it — so every `element:number` document was refused with `invalid_union` at `type`, and `objectui validate` reported "Schema validation failed" for any page that placed one. + +**What changed, in observable terms.** + +- `@object-ui/types/zod` exports `ElementNumberBlockSchema`, a member of `PublicBlockComponentSchema`; the strict authoring face (`StrictAnyComponentSchema`) accepts the same documents, closed to undeclared keys like every other arm. +- The props live where the spec puts them: `properties`, read by reference from the block's `ComponentPropsMap` row — its members, value types, retired-key refusals and strictness. `{ "type": "element:number", "properties": { "object": "order", "aggregate": "count" } }` is accepted; a `format` outside `number` / `currency` / `percent`, a record-form `filter` (the spec's migration `element-number-filter-rule-array`), or an undeclared prop is refused at that member. +- The spec's one waiver is mirrored: the row requires `object`, and a bag may omit it when the node's `dataSource.object` is a non-empty object name, as the spec's props gate (`validateComponentProps` in `@objectstack/lint`) waives it. `{ "type": "element:number", "dataSource": { "object": "order" }, "properties": { "aggregate": "sum", "field": "total" } }` is accepted. +- A bag that names its object nowhere is refused at `properties.object`, with a message naming both fixes (set `properties.object`, or bind `dataSource.object`). An empty or non-string `dataSource.object` supplies nothing. The waiver covers an omitted `object` only: a wrong one (`"object": 7`) is refused even beside a binding. +- `dataSource` is declared on the node as the spec's `ElementDataSourceSchema`, by reference — the schema `PageComponentSchema.dataSource` is — so its members and its alias refusals (`objectName` → `object`) are the spec's, and the strict face judges a `dataSource` rather than refusing it as undeclared. +- A node with no `properties` bag is not judged against the row, as for every other public block and as the spec's props gate does. diff --git a/.changeset/10872-public-block-zod-arms.md b/.changeset/10872-public-block-zod-arms.md index 6d69f2ef9f..406498c74b 100644 --- a/.changeset/10872-public-block-zod-arms.md +++ b/.changeset/10872-public-block-zod-arms.md @@ -15,3 +15,5 @@ - A prop written directly on the node rather than inside `properties` is not judged against the block's row. A key the shared node base (`BaseSchema`) does not declare, such as `{ "type": "record:details", "columns": "2" }`, passes the tolerant face and is refused by the strict face. A key the base does declare is judged by the base instead. `children` (`page:card`, `page:section`, `page:footer`, `page:sidebar`), `label` and `disabled` (`element:button`) and `visible` (`record:alert`) are judged against the base's own types. `body` (`page:card`, `record:alert`) is refused on both faces by the base's objectui#6771 retirement: write a `record:alert` banner's `body` inside `properties`. - `page:tabs` also refuses an authored `onTabChange`, by name: it is the callback `@object-ui/app-shell` injects onto the node to write `?tab=` back, a runtime slot a React host fills (objectui#6124), and a JSON value there was never a working handler — delete the key. - Not armed in this release, and still refused at `type`: `element:number` (its row's required `object` is waived by the spec when the node binds through `dataSource`, which the row alone would refuse), `record:line_items` (the spec has no row for it yet), `element:definition-list` and `element:repeater` (the spec's `element:` vocabulary does not declare them), and `action:button`, `action:group`, `action:menu` and `action:icon` (no spec row yet; one is being measured from the renderers' read points upstream, objectstack-ai/objectstack#20371). + +⚠️ **Dated note, 2026-09-28 — `element:number` armed — objectui#10872 batch 2.** Later in this same release `element:number` is armed as well (`ElementNumberBlockSchema`, with the spec's `dataSource` waiver on its required `object`), so the lead sentence's "less `element:number`" and the first item of the "Not armed in this release" bullet no longer describe the release as a whole; the other held blocks are still refused at `type`. The rest of this entry is kept as the reading of this change. diff --git a/packages/types/src/__tests__/element-number-arm-10872.test.ts b/packages/types/src/__tests__/element-number-arm-10872.test.ts index d66a9348fa..4b2293e84e 100644 --- a/packages/types/src/__tests__/element-number-arm-10872.test.ts +++ b/packages/types/src/__tests__/element-number-arm-10872.test.ts @@ -218,7 +218,7 @@ describe('`element:number` answers as the spec does (objectui#10872 batch 2)', ( * beside a binding; the arm does not, and the probe that tells the two apart * is its own row below. */ - function specGateAccepts(node: { properties?: unknown; dataSource?: unknown }): boolean { + function specGateAccepts(node: Readonly>): boolean { const props = node.properties; if (!props || typeof props !== 'object' || Array.isArray(props)) return true; const parsed = ROW.safeParse(props) as Result; diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md index 77768c3838..5d022b8d0b 100644 --- a/packages/types/src/zod/README.md +++ b/packages/types/src/zod/README.md @@ -234,14 +234,14 @@ function validateComponent(config: unknown) { ### AI Components (3) - `AIFormAssistSchema`, `AIRecommendationsSchema`, `NLQuerySchema` -### ADR-0080 Public Blocks (20) -Each arm's `properties` is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference. +### ADR-0080 Public Blocks (21) +Each arm's `properties` is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference. `ElementNumberBlockSchema` also declares the spec's `dataSource` binding, and mirrors the spec gate's one waiver: its bag may omit `object` when `dataSource.object` names the object. - `PageHeaderBlockSchema`, `PageTabsBlockSchema`, `PageCardBlockSchema`, `PageAccordionBlockSchema` - `PageSectionBlockSchema`, `PageFooterBlockSchema`, `PageSidebarBlockSchema` - `RecordDetailsBlockSchema`, `RecordHighlightsBlockSchema`, `RecordRelatedListBlockSchema` - `RecordPathBlockSchema`, `RecordActivityBlockSchema`, `RecordDiscussionBlockSchema` - `RecordHistoryBlockSchema`, `RecordQuickActionsBlockSchema`, `RecordReferenceRailBlockSchema` -- `RecordAlertBlockSchema`, `ElementTextBlockSchema`, `ElementButtonBlockSchema`, `ElementDividerBlockSchema` +- `RecordAlertBlockSchema`, `ElementTextBlockSchema`, `ElementNumberBlockSchema`, `ElementButtonBlockSchema`, `ElementDividerBlockSchema` ## Schema Structure From 9f1ddf2e8df9d7e9e93c06a0e1e03e515bfe7b0d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:10:00 +0000 Subject: [PATCH 4/6] =?UTF-8?q?docs(changeset):=20dated=20note=20on=20the?= =?UTF-8?q?=208317=20boundary=20entry=20=E2=80=94=20one=20clean=20import?= =?UTF-8?q?=20is=20now=20rebuilt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ElementDataSourceSchema carries no default but reaches a z.lazy, so the boundary returns an equal-answering clone of it; the entry's "identity function on a subtree with nothing to strip" no longer holds for that one import. Frontmatter unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .changeset/8317-strip-imported-defaults.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/8317-strip-imported-defaults.md b/.changeset/8317-strip-imported-defaults.md index d07f524c95..39eb611229 100644 --- a/.changeset/8317-strip-imported-defaults.md +++ b/.changeset/8317-strip-imported-defaults.md @@ -57,3 +57,5 @@ any of the 107 component arms. repository's release train). This is reversible into it — every strip becomes a no-op the day the spec adopts the same principle, because the boundary is the identity function on a subtree with nothing to strip. + +⚠️ **Dated note, 2026-09-28 — one clean import is rebuilt — objectui#10872 batch 2.** Later in this same release the `element:number` arm imports the spec's `ElementDataSourceSchema` through this boundary. It carries no default but reaches a `z.lazy` (the recursive filter clause), and the walker always rebuilds a `lazy`, so for that one import the boundary returns an equal-answering clone rather than the spec's own object: "the identity function on a subtree with nothing to strip" holds for every other clean import, not that one. Nothing is stripped from it and its accept set does not move. The rest of this entry is kept as the reading of this change. From 278ee02ff2caedcb8db4628a1fa787e4c9ee7cef Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:26:07 +0000 Subject: [PATCH 5/6] docs(types): say in the module docblock that the waiver covers an omitted object only Comment only. The spec gate's code waives any issue at `object` beside a binding; its docblock and this arm waive absence only. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- packages/types/src/zod/public-blocks.zod.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/types/src/zod/public-blocks.zod.ts b/packages/types/src/zod/public-blocks.zod.ts index c848ddf759..895cf2e116 100644 --- a/packages/types/src/zod/public-blocks.zod.ts +++ b/packages/types/src/zod/public-blocks.zod.ts @@ -78,7 +78,11 @@ * in two halves, both by reference: the bag is the row with `object` alone made * optional (`ElementNumberPropsBag`), and a node refinement puts the * requiredness back wherever the waiver does not apply - * (`elementNumberObjectIsSupplied`). The node also declares `dataSource`, as the + * (`elementNumberObjectIsSupplied`). The waiver covers an OMITTED `object`, as + * the gate's own docblock states it ("the one prop whose absence this rule does + * NOT report"); the gate's code matches the issue by path alone and so also + * passes a wrong-typed `object` beside a binding, which this arm leaves to the + * row to refuse. The node also declares `dataSource`, as the * spec's `ElementDataSourceSchema` read by reference — the same schema * `PageComponentSchema.dataSource` is. * From fe8f6a6aade17aa3e1d5cff716daa27faee7be38 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:54:34 +0000 Subject: [PATCH 6/6] =?UTF-8?q?test(types):=20fix=20round=201=20=E2=80=94?= =?UTF-8?q?=20filter=20probes=20through=20the=20rebuilt=20binding,=20chang?= =?UTF-8?q?eset=20wording,=20docblock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The changeset says an undeclared prop is refused by name (`unrecognized_keys` on `properties`), not at a member. - The `.partial()` docblock no longer claims the row's `.describe()` text travels: a clone sets no parent, so registry metadata does not inherit. - Four probes reach the recursive filter clause through the rebuilt ElementDataSourceSchema, each held to the spec's own verdict on the member and on the node through both faces. - The differential is titled as what it is: the spec node schema plus this file's model of the gate's documented reading. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .changeset/10872-element-number-arm.md | 2 +- .../element-number-arm-10872.test.ts | 39 ++++++++++++++++--- packages/types/src/zod/public-blocks.zod.ts | 6 +-- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/.changeset/10872-element-number-arm.md b/.changeset/10872-element-number-arm.md index eaf399d342..30fae5e40c 100644 --- a/.changeset/10872-element-number-arm.md +++ b/.changeset/10872-element-number-arm.md @@ -11,7 +11,7 @@ **What changed, in observable terms.** - `@object-ui/types/zod` exports `ElementNumberBlockSchema`, a member of `PublicBlockComponentSchema`; the strict authoring face (`StrictAnyComponentSchema`) accepts the same documents, closed to undeclared keys like every other arm. -- The props live where the spec puts them: `properties`, read by reference from the block's `ComponentPropsMap` row — its members, value types, retired-key refusals and strictness. `{ "type": "element:number", "properties": { "object": "order", "aggregate": "count" } }` is accepted; a `format` outside `number` / `currency` / `percent`, a record-form `filter` (the spec's migration `element-number-filter-rule-array`), or an undeclared prop is refused at that member. +- The props live where the spec puts them: `properties`, read by reference from the block's `ComponentPropsMap` row — its members, value types, retired-key refusals and strictness. `{ "type": "element:number", "properties": { "object": "order", "aggregate": "count" } }` is accepted; a `format` outside `number` / `currency` / `percent`, or a record-form `filter` (the spec's migration `element-number-filter-rule-array`), is refused at that member, and an undeclared prop is refused by name (`unrecognized_keys` on `properties`). - The spec's one waiver is mirrored: the row requires `object`, and a bag may omit it when the node's `dataSource.object` is a non-empty object name, as the spec's props gate (`validateComponentProps` in `@objectstack/lint`) waives it. `{ "type": "element:number", "dataSource": { "object": "order" }, "properties": { "aggregate": "sum", "field": "total" } }` is accepted. - A bag that names its object nowhere is refused at `properties.object`, with a message naming both fixes (set `properties.object`, or bind `dataSource.object`). An empty or non-string `dataSource.object` supplies nothing. The waiver covers an omitted `object` only: a wrong one (`"object": 7`) is refused even beside a binding. - `dataSource` is declared on the node as the spec's `ElementDataSourceSchema`, by reference — the schema `PageComponentSchema.dataSource` is — so its members and its alias refusals (`objectName` → `object`) are the spec's, and the strict face judges a `dataSource` rather than refusing it as undeclared. diff --git a/packages/types/src/__tests__/element-number-arm-10872.test.ts b/packages/types/src/__tests__/element-number-arm-10872.test.ts index 4b2293e84e..7408e5306b 100644 --- a/packages/types/src/__tests__/element-number-arm-10872.test.ts +++ b/packages/types/src/__tests__/element-number-arm-10872.test.ts @@ -200,12 +200,14 @@ describe('`element:number` validates, with the spec\'s `dataSource` waiver (obje }); /** - * The differential: on every probe, the arm answers exactly what the spec - * answers — its node schema (`PageComponentSchema`, which judges `dataSource`) - * together with its props gate over the row, with the gate's one waiver. Each - * probe is its own row, so a regression names the document it broke. + * The differential: on every probe, the arm answers exactly what the spec's + * node schema (`PageComponentSchema`, which judges `dataSource`) answers, + * together with THIS FILE'S MODEL of the spec gate's documented reading over + * the row (`specGateAccepts` below — the gate's docblock, not the gate's code, + * which is not importable here). Each probe is its own row, so a regression + * names the document it broke. */ -describe('`element:number` answers as the spec does (objectui#10872 batch 2)', () => { +describe('`element:number` answers as the spec node schema plus the spec gate\'s documented reading do (objectui#10872 batch 2)', () => { /** * The spec gate's verdict on a node's bag: the row's issues, less the one the * gate waives — `object` ABSENT while `dataSource.object` is a non-empty @@ -297,4 +299,31 @@ describe('`element:number` reads the spec by reference (objectui#10872 batch 2)' expect(member.safeParse(probe).success, JSON.stringify(probe)).toBe(spec.safeParse(probe).success); } }); + + /** + * Measured WHERE the rebuild happens: `filter` is the member whose subtree + * reaches the recursive filter clause's `z.lazy`, the one node the boundary's + * walker rebuilds in this clean schema. Every probe above stops short of it. + * Each probe here goes through `filter`, the nested ones through the lazy's + * recursion, and each verdict is held to the spec's own schema — on the + * member, and on the node through both faces (the strict face rebuilds the + * same lazy a second time, as a fresh `z.lazy`). + */ + it.each([ + ['record filter', { object: 'o', filter: { status: 'won' } }, true], + ['nested $and / $or filter', { object: 'o', filter: { $and: [{ status: 'won' }, { $or: [{ amount: { $gt: 100 } }, { stage: { $in: ['a', 'b'] } }] }] } }, true], + ['non-object filter', { object: 'o', filter: 5 }, false], + ['non-object clause inside $and', { object: 'o', filter: { $and: [5] } }, false], + ] as const)('`dataSource.filter` through the rebuilt binding answers as the spec does: %s', (_name, probe, expected) => { + const member = (ElementNumberBlockSchema.shape.dataSource as unknown as z.ZodOptional).unwrap() as unknown as z.ZodObject; + const spec = ElementDataSourceSchema as unknown as z.ZodObject; + // Non-vacuity: the spec's own verdict is the one this row names. + expect(spec.safeParse(probe).success).toBe(expected); + expect(member.safeParse(probe).success).toBe(expected); + const node = { type: TYPE, properties: { object: 'o', aggregate: 'count' }, dataSource: probe }; + expect(PageComponentSchema.safeParse(node).success).toBe(expected); + for (const [face, parse] of FACES) { + expect(parse(node).success, face).toBe(expected); + } + }); }); diff --git a/packages/types/src/zod/public-blocks.zod.ts b/packages/types/src/zod/public-blocks.zod.ts index 895cf2e116..812a6d1c81 100644 --- a/packages/types/src/zod/public-blocks.zod.ts +++ b/packages/types/src/zod/public-blocks.zod.ts @@ -287,9 +287,9 @@ export const ElementTextBlockSchema = BaseSchema.extend({ * that lives in the bag (`elementNumberObjectIsSupplied` below is the other). * * By reference, and here is how: zod 4's `.partial({ object: true })` clones - * the row's own def — so its strictness (`catchall` never), its unknown-key - * guidance and its `.describe()` text travel — and replaces exactly the masked - * member with a `ZodOptional` wrapped around the row's OWN `object` schema. + * the row's own def — so its strictness (`catchall` never) and its unknown-key + * guidance travel — and replaces exactly the masked member with a `ZodOptional` + * wrapped around the row's OWN `object` schema. * Every other member (`field`, `aggregate`, `filter`, `format`, `prefix`, * `suffix`, `aria`) is handed through as the very object the row holds. No * member is restated, so a row the spec widens or narrows moves this bag the