From b46d8b37e562dfc70a1afed8a67b86ff308b541f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 23:45:40 +0000 Subject: [PATCH 1/6] =?UTF-8?q?test(types):=20tripwire=20=E2=80=94=20the?= =?UTF-8?q?=20spec's=20ListView=20calendar-visualization=20refusal=20must?= =?UTF-8?q?=20reach=20objectui's=20mirror=20(red=20at=20base)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The objectui#7122 item-6 option-A control was not on main. It is added here first, asserting parity: the spec refuses `appearance.allowedVisualizations: ['calendar']` with no `calendar:` block, and objectui's `ListViewSchema` and `safeValidateSchema` must refuse it with the spec's own issue. At this commit the objectui leg is red (the mirror accepts), which is the divergence the next commit closes. Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN Co-authored-by: Claude --- .../spec-object-refinements-7715.test.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 packages/types/src/__tests__/spec-object-refinements-7715.test.ts diff --git a/packages/types/src/__tests__/spec-object-refinements-7715.test.ts b/packages/types/src/__tests__/spec-object-refinements-7715.test.ts new file mode 100644 index 0000000000..7927be56e6 --- /dev/null +++ b/packages/types/src/__tests__/spec-object-refinements-7715.test.ts @@ -0,0 +1,73 @@ +/** + * 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 spec-derived mirrors carry the spec's OBJECT-LEVEL checks (objectui#7715, + * ruling B1, director seat, decision batch #67, maintainer 「同意」). + * + * ## The defect + * + * A mirror built as `specFieldsExcept(SpecX.shape, …)` is a fresh `ZodObject`: + * it takes the spec's FIELDS by reference and leaves behind every check the + * spec attached to the OBJECT (`superRefine` / `refine`). So objectui's + * authoring door accepted a document the spec's publish door refuses — the + * direction that gives an author, human or AI, no signal until publish. + * + * ## The tripwire (objectui#7122 item 6, option A — the control this card flips) + * + * The measured divergence: `appearance.allowedVisualizations: ['calendar']` + * with no `calendar:` block. The spec refuses it (its + * `checkListViewCalendarVisualization`); objectui's `ListViewSchema` accepted + * it. The pin below asserts PARITY, so it was red on the base this card + * branched from and turns green only when the mirror re-attaches the spec's + * own exported check. Both legs are asserted: the spec-side refusal is re-read + * live on every run, so a spec release that drops the rule turns this red + * rather than leaving a pin that compares two acceptances. + */ +import { describe, it, expect } from 'vitest'; +import { ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui'; +import { ListViewSchema, safeValidateSchema } from '../zod/index.zod'; + +/** The spec-shaped view: the spec requires `columns`, objectui does not. */ +const specView = (extra: Record) => ({ columns: ['name'], ...extra }); +/** The objectui node for the same view: component discriminator + object binding. */ +const node = (extra: Record) => ({ type: 'list-view', objectName: 'accounts', ...extra }); + +const CALENDAR_OFFERED_NO_BLOCK = { appearance: { allowedVisualizations: ['calendar'] } }; + +describe('objectui#7715 — tripwire: the ListView calendar-visualization refusal reaches objectui', () => { + it('the spec refuses the document, at `calendar` (the premise, re-read live)', () => { + const r = SpecListViewSchema.safeParse(specView(CALENDAR_OFFERED_NO_BLOCK)); + expect(r.success).toBe(false); + const issue = r.error!.issues.find((i) => i.path.join('.') === 'calendar'); + expect(issue?.code).toBe('custom'); + }); + + it('objectui refuses the same document with the spec\'s own issue — mirror and published door', () => { + const spec = SpecListViewSchema.safeParse(specView(CALENDAR_OFFERED_NO_BLOCK)); + const specIssue = spec.error!.issues.find((i) => i.path.join('.') === 'calendar')!; + + for (const r of [ListViewSchema.safeParse(node(CALENDAR_OFFERED_NO_BLOCK)), safeValidateSchema(node(CALENDAR_OFFERED_NO_BLOCK))]) { + expect(r.success).toBe(false); + const issues = r.error!.issues.map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message })); + // The spec's issue, derived from the spec's own parse — not a restated + // message — so this proves the spec's check runs here, not a copy of it. + expect(issues).toContainEqual({ code: 'custom', path: 'calendar', message: specIssue.message }); + } + }); + + it('controls: a declared `calendar` block, and a list that does not offer the calendar, pass on both doors', () => { + const withBlock = { ...CALENDAR_OFFERED_NO_BLOCK, calendar: { startDateField: 'starts_at' } }; + const noCalendar = { appearance: { allowedVisualizations: ['grid', 'kanban'] } }; + for (const extra of [withBlock, noCalendar]) { + expect(SpecListViewSchema.safeParse(specView(extra)).success).toBe(true); + expect(ListViewSchema.safeParse(node(extra)).success).toBe(true); + expect(safeValidateSchema(node(extra)).success).toBe(true); + } + }); +}); From 0731fa0a8742067aed1b544ceddd13ede8331f02 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 23:49:55 +0000 Subject: [PATCH 2/6] fix(types): ListViewSchema and PageNodeSchema re-attach the spec's exported object-level checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The six `specFieldsExcept(...)` mirrors rebuild a fresh object from the spec's `.shape`, carrying fields and dropping the checks the spec attaches to the object. Per ruling B1 each site now attaches the spec's exported check for the fields it carries: - ListViewSchema: `.superRefine(checkListViewCalendarVisualization)`. `checkListViewPageMount` is not attachable: it reads `type`, which the list-view node spends on its component discriminator (the spec's view kind rides as `viewType`), so as-is it would refuse every `pageName`. - PageNodeSchema: `.superRefine(checkPageSourceCompleteness)` — `kind` and `source` are both carried by reference. - NavigationArea, App, DashboardWidget, Dashboard: the spec objects carry no object-level check at the resolved release; nothing to attach. The tripwire from the previous commit turns green. A census pin accounts for every object-level check on the six spec objects and every exported `check*`, so a check the spec adds later fails by site name. Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN Co-authored-by: Claude --- .../7715-mirrors-carry-spec-object-checks.md | 16 +++ .../spec-object-refinements-7715.test.ts | 125 +++++++++++++++++- packages/types/src/zod/layout.zod.ts | 13 +- packages/types/src/zod/objectql.zod.ts | 29 +++- 4 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 .changeset/7715-mirrors-carry-spec-object-checks.md diff --git a/.changeset/7715-mirrors-carry-spec-object-checks.md b/.changeset/7715-mirrors-carry-spec-object-checks.md new file mode 100644 index 0000000000..e350a66747 --- /dev/null +++ b/.changeset/7715-mirrors-carry-spec-object-checks.md @@ -0,0 +1,16 @@ +--- +'@object-ui/types': minor +--- + +fix(types): the spec-derived `ListViewSchema` and `PageNodeSchema` refuse what the spec's publish door refuses — they now run the spec's own object-level checks (objectui#7715) + +**Breaking (accept-set narrowing, shipped as `minor` per this repository's version-alignment rule):** `safeValidateSchema` / `validateSchema` and the two schemas below now refuse two document shapes they used to accept. Both were already refused by `@objectstack/spec` at publish; objectui's authoring door said yes and the author heard no until publish. + +A mirror built from the spec's `.shape` (`specFieldsExcept(...)`) takes the spec's fields by reference and drops every check the spec attaches to the object itself. `@objectstack/spec` exports those checks as named functions, and each mirror now attaches the ones whose fields it carries — the spec's function, not a copy, so the refusal an author sees is the spec's own message: + +- **`ListViewSchema`** attaches `checkListViewCalendarVisualization`: a `list-view` node whose `appearance.allowedVisualizations` includes `'calendar'` with no `calendar:` block is refused at `calendar`. Fix a refused document by declaring `calendar: { startDateField: '' }`, or by removing `'calendar'` from `allowedVisualizations`. +- **`PageNodeSchema`** attaches `checkPageSourceCompleteness`: a `page` node of `kind: 'html'`, `'react'` or `'jsx'` with no non-empty `source` is refused at `source`. Fix it by supplying the page's `source`. + +The spec's other `ListViewSchema` check, `checkListViewPageMount`, is **not** attached: it reads `type`, which on a `list-view` node is the component discriminator rather than the spec's view kind (that rides as `viewType`), so attaching it would refuse every `pageName`, a valid page mount included. The navigation-area, app, dashboard-widget and dashboard mirrors attach nothing because their spec objects carry no object-level check at the resolved spec release. `GlobalFilterSchema` already ran the spec's date-default check through its delegating re-parse and is unchanged. + +`packages/types/src/__tests__/spec-object-refinements-7715.test.ts` accounts for every object-level check each of the six spec objects carries, so a check the spec adds in a later release fails there by site name instead of being dropped. diff --git a/packages/types/src/__tests__/spec-object-refinements-7715.test.ts b/packages/types/src/__tests__/spec-object-refinements-7715.test.ts index 7927be56e6..206fc8c0f2 100644 --- a/packages/types/src/__tests__/spec-object-refinements-7715.test.ts +++ b/packages/types/src/__tests__/spec-object-refinements-7715.test.ts @@ -30,8 +30,13 @@ * rather than leaving a pin that compares two acceptances. */ import { describe, it, expect } from 'vitest'; -import { ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui'; -import { ListViewSchema, safeValidateSchema } from '../zod/index.zod'; +import { z } from 'zod'; +import * as specUi from '@objectstack/spec/ui'; +import { ListViewSchema, PageNodeSchema, safeValidateSchema } from '../zod/index.zod'; +import { GlobalFilterSchema } from '../zod/complex.zod'; + +const SpecListViewSchema = specUi.ListViewSchema; +const SpecPageSchema = specUi.PageSchema; /** The spec-shaped view: the spec requires `columns`, objectui does not. */ const specView = (extra: Record) => ({ columns: ['name'], ...extra }); @@ -71,3 +76,119 @@ describe('objectui#7715 — tripwire: the ListView calendar-visualization refusa } }); }); + +// ── The Page site ──────────────────────────────────────────────────────────── + +const specPage = (extra: Record) => ({ name: 'home_page', label: 'Home', ...extra }); +const pageNode = (extra: Record) => ({ type: 'page', ...extra }); + +describe('objectui#7715 — the Page source-completeness refusal reaches objectui', () => { + it.each([ + ['no `source` at all', { kind: 'html' }], + ['a whitespace-only `source`', { kind: 'react', source: ' ' }], + ])('%s: the spec refuses at `source`, and objectui refuses with the spec\'s own issue', (_label, extra) => { + const spec = SpecPageSchema.safeParse(specPage(extra)); + expect(spec.success).toBe(false); + const specIssue = spec.error!.issues.find((i) => i.path.join('.') === 'source'); + expect(specIssue?.code).toBe('custom'); + + for (const r of [PageNodeSchema.safeParse(pageNode(extra)), safeValidateSchema(pageNode(extra))]) { + expect(r.success).toBe(false); + const issues = r.error!.issues.map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message })); + expect(issues).toContainEqual({ code: 'custom', path: 'source', message: specIssue!.message }); + } + }); + + it('controls: a sourced page, and a page whose kind carries no source, pass on both doors', () => { + for (const extra of [{ kind: 'html', source: '
x
' }, {}, { kind: 'full' }]) { + expect(SpecPageSchema.safeParse(specPage(extra)).success).toBe(true); + expect(PageNodeSchema.safeParse(pageNode(extra)).success).toBe(true); + expect(safeValidateSchema(pageNode(extra)).success).toBe(true); + } + }); +}); + +// ── The census: every object-level check the spec runs is accounted for ───── + +/** + * The six `specFieldsExcept(...)` derivation sites the ruling names, each with + * the spec object it rebuilds and the spec's exported checks it re-attaches or + * declares not attachable (with the reason, which the pin below measures where + * it can). A site's two lists must account for EVERY object-level check the + * spec object carries today, read off `_zod.def.checks` — so a check the spec + * adds on a later release reddens this row by name instead of being dropped. + */ +const SITES = [ + { site: 'NavigationAreaSchema (app.zod.ts)', spec: specUi.NavigationAreaSchema, attached: [], notAttachable: [] }, + { site: 'SpecAppFields → AppComponentSchema (app.zod.ts)', spec: specUi.AppSchema, attached: [], notAttachable: [] }, + { site: 'DashboardWidgetSchema (complex.zod.ts)', spec: specUi.DashboardWidgetSchema, attached: [], notAttachable: [] }, + { site: 'SpecDashboardFields → DashboardComponentSchema (complex.zod.ts)', spec: specUi.DashboardSchema, attached: [], notAttachable: [] }, + { site: 'SpecPageFields → PageNodeSchema (layout.zod.ts)', spec: specUi.PageSchema, attached: ['checkPageSourceCompleteness'], notAttachable: [] }, + { + site: 'ListViewSchema (objectql.zod.ts)', + spec: specUi.ListViewSchema, + attached: ['checkListViewCalendarVisualization'], + // Reads `type`, which on this node is the component discriminator — see the + // measurement below. + notAttachable: ['checkListViewPageMount'], + }, +] as const; + +/** + * Exported checks that belong to NO `specFieldsExcept` site. `GlobalFilterSchema` + * is mirrored by a `.shape` spread in `complex.zod.ts` whose own `superRefine` + * re-parses the spec-owned keys through the spec schema, so its check already + * runs there — measured below rather than assumed. + */ +const CARRIED_ELSEWHERE = ['checkGlobalFilterDateDefaultValue'] as const; + +const objectLevelChecks = (schema: unknown): number => + ((schema as { _zod: { def: { checks?: unknown[] } } })._zod.def.checks ?? []).length; + +/** Run one exported check directly, outside any schema. */ +const runCheck = (check: unknown, value: unknown) => + z.any().superRefine(check as (v: unknown, ctx: z.RefinementCtx) => void).safeParse(value); + +describe('objectui#7715 — census: the spec\'s object-level checks at the six derivation sites', () => { + it.each(SITES.map((s) => [s.site, s] as const))('%s accounts for every object-level check the spec object carries', (_site, s) => { + expect({ + site: s.site, + specObjectLevelChecks: objectLevelChecks(s.spec), + }).toEqual({ + site: s.site, + specObjectLevelChecks: s.attached.length + s.notAttachable.length, + }); + }); + + it('every `check*` the spec exports is named by exactly one site or carried elsewhere', () => { + const exported = Object.keys(specUi).filter((k) => /^check[A-Z]/.test(k)).sort(); + const named = [...SITES.flatMap((s) => [...s.attached, ...s.notAttachable]), ...CARRIED_ELSEWHERE].sort(); + expect(exported).toEqual(named); + for (const name of named) expect(typeof (specUi as Record)[name]).toBe('function'); + }); + + it('`checkListViewPageMount` is not attachable: it reads `type`, which the list-view node spends on its discriminator', () => { + // The same page mount, spelled once per face. The spec accepts its own. + expect(SpecListViewSchema.safeParse({ type: 'page', pageName: 'home_page', columns: [] }).success).toBe(true); + // Attached as-is, the check would refuse objectui's spelling of it, at + // `pageName` — the verdict a mirror must never add. + const asIs = runCheck(specUi.checkListViewPageMount, { type: 'list-view', viewType: 'page', pageName: 'home_page', columns: [] }); + expect(asIs.success).toBe(false); + expect(asIs.error!.issues.map((i) => i.path.join('.'))).toEqual(['pageName']); + }); + + it('`checkGlobalFilterDateDefaultValue` already runs on objectui\'s GlobalFilterSchema, with the spec\'s own issue', () => { + const bad = { field: 'created_at', type: 'date', defaultValue: 'last_7_dayz' }; + const spec = specUi.GlobalFilterSchema.safeParse(bad); + expect(spec.success).toBe(false); + const specIssue = spec.error!.issues.find((i) => i.path.join('.') === 'defaultValue')!; + const mirror = GlobalFilterSchema.safeParse(bad); + expect(mirror.success).toBe(false); + expect(mirror.error!.issues.map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message }))) + .toContainEqual({ code: 'custom', path: 'defaultValue', message: specIssue.message }); + // Control: a preset name resolves on both. + const good = { ...bad, defaultValue: 'last_7_days' }; + expect(specUi.GlobalFilterSchema.safeParse(good).success).toBe(true); + expect(GlobalFilterSchema.safeParse(good).success).toBe(true); + }); +}); diff --git a/packages/types/src/zod/layout.zod.ts b/packages/types/src/zod/layout.zod.ts index 938f8ad7d1..2ade6eeb93 100644 --- a/packages/types/src/zod/layout.zod.ts +++ b/packages/types/src/zod/layout.zod.ts @@ -22,6 +22,7 @@ import { PageSchema as SpecPageSchema, PageTypeSchema as SpecPageTypeSchema, PageVariableSchema as SpecPageVariableSchema, + checkPageSourceCompleteness, } from '@objectstack/spec/ui'; import { BaseSchema, SchemaNodeSchema, specFieldsExcept } from './base.zod.js'; import { stripImportedDefaults } from './imported-defaults.js'; @@ -842,7 +843,17 @@ export const PageNodeSchema = BaseSchema.extend(SpecPageFields.shape).extend({ .describe('Main content — one node or a list of nodes'), isDefault: z.boolean().optional().describe('Whether this is the default page'), assignedProfiles: z.array(z.string()).optional().describe('Profiles that can access this page'), -}); +}) + // ⭐ THE SPEC'S OBJECT-LEVEL CHECK, re-attached (objectui#7715, ruling B1). + // {@link SpecPageFields} rebuilds a fresh object from the spec's `.shape`, so + // it drops the one check the spec's `PageSchema` carries on the OBJECT: an + // `html` / `react` / `jsx` page with no non-empty `source` renders nothing and + // is refused at `source`. The spec exports that check (objectstack#16489) and + // it is attached here as-is: it reads `kind` and `source`, and this node + // carries both by reference — neither is in {@link PAGE_SPEC_EXCLUDED} nor + // overridden above. `__tests__/spec-object-refinements-7715.test.ts` re-derives + // the count, so a check the spec adds to `PageSchema` later reddens there. + .superRefine(checkPageSourceCompleteness); /** * Semantic Element Schema — the seven HTML sectioning tags diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 35562a2f78..4f7e91951f 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -39,6 +39,7 @@ import { I18nLabelSchema as SpecI18nLabelSchema, DashboardWidgetSchema as SpecDashboardWidgetSchema, UserFilterFieldSchema as SpecUserFilterFieldSchema, + checkListViewCalendarVisualization, } from '@objectstack/spec/ui'; import { BaseSchema, specFieldsExcept } from './base.zod.js'; import { aliasKeyRefusal, handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; @@ -1553,7 +1554,33 @@ export const ListViewSchema = BaseSchema onDensityChange: handlerKeyRefusal('onDensityChange', 'runtime-slot', 'Row density change handler'), onNavigate: handlerKeyRefusal('onNavigate', 'runtime-slot', 'Record navigation handler'), onPageSizeChange: handlerKeyRefusal('onPageSizeChange', 'runtime-slot', 'Page size change handler'), - }); + }) + // ⭐ THE SPEC'S OBJECT-LEVEL CHECKS, re-attached (objectui#7715, ruling B1). + // + // `specFieldsExcept` above rebuilds a fresh object from the spec's `.shape`, + // so it carries the spec's FIELDS and drops every check the spec attached to + // the OBJECT. The spec exports each such check as a named function + // (objectstack#16489) precisely so a mirror can attach the one the spec runs + // instead of restating it. The spec's `ListViewSchema` carries two; each is + // judged by whether this node carries the fields the check reads: + // + // `checkListViewCalendarVisualization` — ATTACHED. It reads + // `appearance.allowedVisualizations` (a spec field, by reference) and only + // whether `calendar` is PRESENT; the local `CalendarConfig` override keeps + // `calendar` omissible, so "absent" means the same thing on both faces and + // the refusal is the spec's own, message included. + // `checkListViewPageMount` — NOT ATTACHABLE. It reads `type`, and on this + // node `type` is the component discriminator `'list-view'`; the spec's view + // kind rides as `viewType`. Attached as-is it would refuse EVERY `pageName`, + // a valid page mount included, and its remedy tells the author to write + // `type: 'page'`, which this node's literal refuses. The spec kind it binds + // is retired upstream (objectstack#17063 — see `UNDRAWABLE_VIEW_KINDS` in + // `@object-ui/core`), so no adapter is written for it here. + // + // `__tests__/spec-object-refinements-7715.test.ts` re-derives that split from + // the spec object's own check count, so a check the spec adds to this object + // later reddens there instead of being dropped here in silence. + .superRefine(checkListViewCalendarVisualization); /** * TS type for the ListView component node (spec-derived; issue #2231). From c86292686ffef2ef26f58d0b82754ca77a5a299f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 23:53:21 +0000 Subject: [PATCH 3/6] test(types): the import-boundary census lists the two spec checks the mirrors now chain `checkListViewCalendarVisualization` and `checkPageSourceCompleteness` are spec CHECK FUNCTIONS mounted with `.superRefine`, not schemas crossing the boundary: they have no Zod graph and write no value into a document, the case `REFINEMENT_EXCEPTIONS` exists for (objectui#8563). Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN Co-authored-by: Claude --- .../types/src/__tests__/imported-defaults-8317.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index d4e269f133..615bfe17c9 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -77,6 +77,8 @@ import { ChartDrillDownSchema as SpecChartDrillDownSchema, UserFilterFieldSchema as SpecUserFilterFieldSchema, objectNavTargetExclusivity, + checkListViewCalendarVisualization, + checkPageSourceCompleteness, } from '@objectstack/spec/ui'; import { SelectOptionSchema as SpecSelectOptionSchema } from '@objectstack/spec/data'; import { stripImportedDefaults } from '../zod/imported-defaults.js'; @@ -420,6 +422,11 @@ describe('the import boundary strips every imported default (objectui#8317)', () */ const REFINEMENT_EXCEPTIONS = new Map([ ['objectNavTargetExclusivity', objectNavTargetExclusivity], + // objectui#7715 (ruling B1): the spec's exported object-level checks that + // `ListViewSchema` and `PageNodeSchema` re-attach after `specFieldsExcept` + // rebuilt them without the spec object's own checks. + ['checkListViewCalendarVisualization', checkListViewCalendarVisualization], + ['checkPageSourceCompleteness', checkPageSourceCompleteness], ]); const isSpecModule = (m: string): boolean => From 843e125026da3a126c9b59961b17f229ab2cfdec Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 00:05:02 +0000 Subject: [PATCH 4/6] test(types): the refinement census reads the spec's published export surface, not a restricted namespace import A namespace import of `@objectstack/spec/ui` is refused by this repository's `no-restricted-imports` rule. The census now imports each schema and check by name and reads the `check*` function list from the spec's own `api-surface/ui.json`, with a control that the surface file was read. Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN Co-authored-by: Claude --- .../spec-object-refinements-7715.test.ts | 71 +++++++++++++++---- 1 file changed, 56 insertions(+), 15 deletions(-) diff --git a/packages/types/src/__tests__/spec-object-refinements-7715.test.ts b/packages/types/src/__tests__/spec-object-refinements-7715.test.ts index 206fc8c0f2..849553c877 100644 --- a/packages/types/src/__tests__/spec-object-refinements-7715.test.ts +++ b/packages/types/src/__tests__/spec-object-refinements-7715.test.ts @@ -30,14 +30,26 @@ * rather than leaving a pin that compares two acceptances. */ import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; import { z } from 'zod'; -import * as specUi from '@objectstack/spec/ui'; +import { + AppSchema as SpecAppSchema, + DashboardSchema as SpecDashboardSchema, + DashboardWidgetSchema as SpecDashboardWidgetSchema, + GlobalFilterSchema as SpecGlobalFilterSchema, + ListViewSchema as SpecListViewSchema, + NavigationAreaSchema as SpecNavigationAreaSchema, + PageSchema as SpecPageSchema, + checkGlobalFilterDateDefaultValue, + checkListViewCalendarVisualization, + checkListViewPageMount, + checkPageSourceCompleteness, +} from '@objectstack/spec/ui'; import { ListViewSchema, PageNodeSchema, safeValidateSchema } from '../zod/index.zod'; import { GlobalFilterSchema } from '../zod/complex.zod'; -const SpecListViewSchema = specUi.ListViewSchema; -const SpecPageSchema = specUi.PageSchema; - /** The spec-shaped view: the spec requires `columns`, objectui does not. */ const specView = (extra: Record) => ({ columns: ['name'], ...extra }); /** The objectui node for the same view: component discriminator + object binding. */ @@ -119,14 +131,14 @@ describe('objectui#7715 — the Page source-completeness refusal reaches objectu * adds on a later release reddens this row by name instead of being dropped. */ const SITES = [ - { site: 'NavigationAreaSchema (app.zod.ts)', spec: specUi.NavigationAreaSchema, attached: [], notAttachable: [] }, - { site: 'SpecAppFields → AppComponentSchema (app.zod.ts)', spec: specUi.AppSchema, attached: [], notAttachable: [] }, - { site: 'DashboardWidgetSchema (complex.zod.ts)', spec: specUi.DashboardWidgetSchema, attached: [], notAttachable: [] }, - { site: 'SpecDashboardFields → DashboardComponentSchema (complex.zod.ts)', spec: specUi.DashboardSchema, attached: [], notAttachable: [] }, - { site: 'SpecPageFields → PageNodeSchema (layout.zod.ts)', spec: specUi.PageSchema, attached: ['checkPageSourceCompleteness'], notAttachable: [] }, + { site: 'NavigationAreaSchema (app.zod.ts)', spec: SpecNavigationAreaSchema, attached: [], notAttachable: [] }, + { site: 'SpecAppFields → AppComponentSchema (app.zod.ts)', spec: SpecAppSchema, attached: [], notAttachable: [] }, + { site: 'DashboardWidgetSchema (complex.zod.ts)', spec: SpecDashboardWidgetSchema, attached: [], notAttachable: [] }, + { site: 'SpecDashboardFields → DashboardComponentSchema (complex.zod.ts)', spec: SpecDashboardSchema, attached: [], notAttachable: [] }, + { site: 'SpecPageFields → PageNodeSchema (layout.zod.ts)', spec: SpecPageSchema, attached: ['checkPageSourceCompleteness'], notAttachable: [] }, { site: 'ListViewSchema (objectql.zod.ts)', - spec: specUi.ListViewSchema, + spec: SpecListViewSchema, attached: ['checkListViewCalendarVisualization'], // Reads `type`, which on this node is the component discriminator — see the // measurement below. @@ -142,6 +154,34 @@ const SITES = [ */ const CARRIED_ELSEWHERE = ['checkGlobalFilterDateDefaultValue'] as const; +/** The bindings behind every name above, so each is proven a live function. */ +const SPEC_CHECK_BINDINGS: Record = { + checkGlobalFilterDateDefaultValue, + checkListViewCalendarVisualization, + checkListViewPageMount, + checkPageSourceCompleteness, +}; + +/** + * The `check*` FUNCTIONS `@objectstack/spec/ui` exports, read from the spec's own + * published export surface (`api-surface/ui.json`, every `name (kind)` of the + * entry point, generated from the built `dist`) rather than from a runtime + * namespace import — a namespace import of `@objectstack/spec/ui` is restricted + * in this repository, for reasons that have nothing to do with this census. + */ +function specCheckExports(): string[] { + const require = createRequire(import.meta.url); + const pkgDir = dirname(require.resolve('@objectstack/spec/package.json')); + const surface = JSON.parse(readFileSync(join(pkgDir, 'api-surface', 'ui.json'), 'utf8')) as { entry: string; exports: string[] }; + expect(surface.entry).toBe('./ui'); + // Control: the surface lists a schema this file imports, so a reading of zero + // checks would be a real zero and not an unreadable file. + expect(surface.exports).toContain('ListViewSchema (const)'); + return surface.exports + .map((e) => /^(check[A-Z]\w*) \(function\)$/.exec(e)?.[1]) + .filter((n): n is string => Boolean(n)); +} + const objectLevelChecks = (schema: unknown): number => ((schema as { _zod: { def: { checks?: unknown[] } } })._zod.def.checks ?? []).length; @@ -161,10 +201,11 @@ describe('objectui#7715 — census: the spec\'s object-level checks at the six d }); it('every `check*` the spec exports is named by exactly one site or carried elsewhere', () => { - const exported = Object.keys(specUi).filter((k) => /^check[A-Z]/.test(k)).sort(); + const exported = specCheckExports().sort(); const named = [...SITES.flatMap((s) => [...s.attached, ...s.notAttachable]), ...CARRIED_ELSEWHERE].sort(); expect(exported).toEqual(named); - for (const name of named) expect(typeof (specUi as Record)[name]).toBe('function'); + expect(Object.keys(SPEC_CHECK_BINDINGS).sort()).toEqual(named); + for (const name of named) expect(typeof SPEC_CHECK_BINDINGS[name], name).toBe('function'); }); it('`checkListViewPageMount` is not attachable: it reads `type`, which the list-view node spends on its discriminator', () => { @@ -172,14 +213,14 @@ describe('objectui#7715 — census: the spec\'s object-level checks at the six d expect(SpecListViewSchema.safeParse({ type: 'page', pageName: 'home_page', columns: [] }).success).toBe(true); // Attached as-is, the check would refuse objectui's spelling of it, at // `pageName` — the verdict a mirror must never add. - const asIs = runCheck(specUi.checkListViewPageMount, { type: 'list-view', viewType: 'page', pageName: 'home_page', columns: [] }); + const asIs = runCheck(checkListViewPageMount, { type: 'list-view', viewType: 'page', pageName: 'home_page', columns: [] }); expect(asIs.success).toBe(false); expect(asIs.error!.issues.map((i) => i.path.join('.'))).toEqual(['pageName']); }); it('`checkGlobalFilterDateDefaultValue` already runs on objectui\'s GlobalFilterSchema, with the spec\'s own issue', () => { const bad = { field: 'created_at', type: 'date', defaultValue: 'last_7_dayz' }; - const spec = specUi.GlobalFilterSchema.safeParse(bad); + const spec = SpecGlobalFilterSchema.safeParse(bad); expect(spec.success).toBe(false); const specIssue = spec.error!.issues.find((i) => i.path.join('.') === 'defaultValue')!; const mirror = GlobalFilterSchema.safeParse(bad); @@ -188,7 +229,7 @@ describe('objectui#7715 — census: the spec\'s object-level checks at the six d .toContainEqual({ code: 'custom', path: 'defaultValue', message: specIssue.message }); // Control: a preset name resolves on both. const good = { ...bad, defaultValue: 'last_7_days' }; - expect(specUi.GlobalFilterSchema.safeParse(good).success).toBe(true); + expect(SpecGlobalFilterSchema.safeParse(good).success).toBe(true); expect(GlobalFilterSchema.safeParse(good).success).toBe(true); }); }); From a4835b65b6ddadb898546581e24fa7bb0af1fb88 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 00:10:46 +0000 Subject: [PATCH 5/6] docs(changeset): spell the calendar remedy's field placeholder as a word, not an angle-bracket tag Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN Co-authored-by: Claude --- .changeset/7715-mirrors-carry-spec-object-checks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/7715-mirrors-carry-spec-object-checks.md b/.changeset/7715-mirrors-carry-spec-object-checks.md index e350a66747..8226a93608 100644 --- a/.changeset/7715-mirrors-carry-spec-object-checks.md +++ b/.changeset/7715-mirrors-carry-spec-object-checks.md @@ -8,7 +8,7 @@ fix(types): the spec-derived `ListViewSchema` and `PageNodeSchema` refuse what t A mirror built from the spec's `.shape` (`specFieldsExcept(...)`) takes the spec's fields by reference and drops every check the spec attaches to the object itself. `@objectstack/spec` exports those checks as named functions, and each mirror now attaches the ones whose fields it carries — the spec's function, not a copy, so the refusal an author sees is the spec's own message: -- **`ListViewSchema`** attaches `checkListViewCalendarVisualization`: a `list-view` node whose `appearance.allowedVisualizations` includes `'calendar'` with no `calendar:` block is refused at `calendar`. Fix a refused document by declaring `calendar: { startDateField: '' }`, or by removing `'calendar'` from `allowedVisualizations`. +- **`ListViewSchema`** attaches `checkListViewCalendarVisualization`: a `list-view` node whose `appearance.allowedVisualizations` includes `'calendar'` with no `calendar:` block is refused at `calendar`. Fix a refused document by declaring `calendar: { startDateField: 'DATE_FIELD' }` (naming the date field), or by removing `'calendar'` from `allowedVisualizations`. - **`PageNodeSchema`** attaches `checkPageSourceCompleteness`: a `page` node of `kind: 'html'`, `'react'` or `'jsx'` with no non-empty `source` is refused at `source`. Fix it by supplying the page's `source`. The spec's other `ListViewSchema` check, `checkListViewPageMount`, is **not** attached: it reads `type`, which on a `list-view` node is the component discriminator rather than the spec's view kind (that rides as `viewType`), so attaching it would refuse every `pageName`, a valid page mount included. The navigation-area, app, dashboard-widget and dashboard mirrors attach nothing because their spec objects carry no object-level check at the resolved spec release. `GlobalFilterSchema` already ran the spec's date-default check through its delegating re-parse and is unchanged. From 65df5b381c57e402e6e406968bbd6a692c331e6e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 00:34:32 +0000 Subject: [PATCH 6/6] test(types): the refinement census reads the spec's check functions by name at run time, so a spec that drops one still compiles `Spec Main Shape Gate` compiles objectui against @objectstack/spec built from objectstack main, where `checkListViewPageMount` is gone (objectstack#17063). The census imported it by name, so that compile failed with TS2305 before any census row could report which site moved. The census now imports schemas only. It reads every `check*` function from the entry point's runtime namespace, by name, through a dynamic `import()` (check functions are values, so the namespace sees them; the static namespace form stays a restricted import). Against spec main this file now compiles, and at run time the DashboardWidget row, the ListView row, the export-set pin and the page-mount measurement fail by name. Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN Co-authored-by: Claude --- .../spec-object-refinements-7715.test.ts | 78 ++++++++++--------- 1 file changed, 41 insertions(+), 37 deletions(-) diff --git a/packages/types/src/__tests__/spec-object-refinements-7715.test.ts b/packages/types/src/__tests__/spec-object-refinements-7715.test.ts index 849553c877..9bcd142d94 100644 --- a/packages/types/src/__tests__/spec-object-refinements-7715.test.ts +++ b/packages/types/src/__tests__/spec-object-refinements-7715.test.ts @@ -30,10 +30,9 @@ * rather than leaving a pin that compares two acceptances. */ import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'node:fs'; -import { createRequire } from 'node:module'; -import { dirname, join } from 'node:path'; import { z } from 'zod'; +// ⛔ Schemas only. No spec `check*` function is imported by name in this file; +// they are read at RUN time by `specUiChecks()` below, and why is written there. import { AppSchema as SpecAppSchema, DashboardSchema as SpecDashboardSchema, @@ -42,10 +41,6 @@ import { ListViewSchema as SpecListViewSchema, NavigationAreaSchema as SpecNavigationAreaSchema, PageSchema as SpecPageSchema, - checkGlobalFilterDateDefaultValue, - checkListViewCalendarVisualization, - checkListViewPageMount, - checkPageSourceCompleteness, } from '@objectstack/spec/ui'; import { ListViewSchema, PageNodeSchema, safeValidateSchema } from '../zod/index.zod'; import { GlobalFilterSchema } from '../zod/complex.zod'; @@ -154,32 +149,36 @@ const SITES = [ */ const CARRIED_ELSEWHERE = ['checkGlobalFilterDateDefaultValue'] as const; -/** The bindings behind every name above, so each is proven a live function. */ -const SPEC_CHECK_BINDINGS: Record = { - checkGlobalFilterDateDefaultValue, - checkListViewCalendarVisualization, - checkListViewPageMount, - checkPageSourceCompleteness, -}; - /** - * The `check*` FUNCTIONS `@objectstack/spec/ui` exports, read from the spec's own - * published export surface (`api-surface/ui.json`, every `name (kind)` of the - * entry point, generated from the built `dist`) rather than from a runtime - * namespace import — a namespace import of `@objectstack/spec/ui` is restricted - * in this repository, for reasons that have nothing to do with this census. + * Every `check*` FUNCTION `@objectstack/spec/ui` exports, name → binding, read + * at RUN time from the entry point's own module namespace. + * + * ⛔ Never a static named import of a check in this file. The census exists to + * go red when the spec's set of checks MOVES, and a move is exactly when a + * static import of one stops compiling: the `Spec Main Shape Gate` compiles + * this repository against `@objectstack/spec` built from objectstack `main`, + * where `checkListViewPageMount` is gone (objectstack#17063), and a named + * import of it failed that compile with TS2305 before any row here could say + * which site moved. Read by name at run time, an added or a removed check + * compiles on every spec and fails below by row name instead. + * + * The runtime namespace is the right instrument here because these are VALUES: + * a namespace cannot see a TYPE, which is why the repository's type-name + * tripwires read `.d.ts` through the checker, but every name this census + * counts is a function. A dynamic `import()` rather than `import * as`, because + * the static namespace form of this entry point is a restricted import in this + * repository (objectui#3090's `FormField` rule), which this read never touches. */ -function specCheckExports(): string[] { - const require = createRequire(import.meta.url); - const pkgDir = dirname(require.resolve('@objectstack/spec/package.json')); - const surface = JSON.parse(readFileSync(join(pkgDir, 'api-surface', 'ui.json'), 'utf8')) as { entry: string; exports: string[] }; - expect(surface.entry).toBe('./ui'); - // Control: the surface lists a schema this file imports, so a reading of zero - // checks would be a real zero and not an unreadable file. - expect(surface.exports).toContain('ListViewSchema (const)'); - return surface.exports - .map((e) => /^(check[A-Z]\w*) \(function\)$/.exec(e)?.[1]) - .filter((n): n is string => Boolean(n)); +async function specUiChecks(): Promise> { + const mod: object = await import('@objectstack/spec/ui'); + // Control: the namespace read is the SAME module the schemas above come + // from, so an empty map would be a real absence and not a wrong module. + expect(Reflect.get(mod, 'ListViewSchema')).toBe(SpecListViewSchema); + return new Map( + Object.keys(mod) + .filter((name) => /^check[A-Z]/.test(name)) + .map((name) => [name, Reflect.get(mod, name)] as const), + ); } const objectLevelChecks = (schema: unknown): number => @@ -200,15 +199,20 @@ describe('objectui#7715 — census: the spec\'s object-level checks at the six d }); }); - it('every `check*` the spec exports is named by exactly one site or carried elsewhere', () => { - const exported = specCheckExports().sort(); + it('every `check*` the spec exports is named by exactly one site or carried elsewhere', async () => { + const checks = await specUiChecks(); const named = [...SITES.flatMap((s) => [...s.attached, ...s.notAttachable]), ...CARRIED_ELSEWHERE].sort(); - expect(exported).toEqual(named); - expect(Object.keys(SPEC_CHECK_BINDINGS).sort()).toEqual(named); - for (const name of named) expect(typeof SPEC_CHECK_BINDINGS[name], name).toBe('function'); + expect([...checks.keys()].sort()).toEqual(named); + for (const [name, binding] of checks) expect(typeof binding, name).toBe('function'); }); - it('`checkListViewPageMount` is not attachable: it reads `type`, which the list-view node spends on its discriminator', () => { + it('`checkListViewPageMount` is not attachable: it reads `type`, which the list-view node spends on its discriminator', async () => { + const checkListViewPageMount = (await specUiChecks()).get('checkListViewPageMount'); + expect( + typeof checkListViewPageMount, + 'the spec no longer exports `checkListViewPageMount` — remove it from the ListView row\'s ' + + '`notAttachable` list, and this measurement with it', + ).toBe('function'); // The same page mount, spelled once per face. The spec accepts its own. expect(SpecListViewSchema.safeParse({ type: 'page', pageName: 'home_page', columns: [] }).success).toBe(true); // Attached as-is, the check would refuse objectui's spelling of it, at