From 6f924a7b5d99657be937becd8a9fc5d9caf90794 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:11:36 +0000 Subject: [PATCH 1/8] fix(spec): JoinedReportBlock, ViewItem config and the overlay viewKind carry their shapes - JoinedReportBlockSchema drops its z.ZodTypeAny annotation, so JoinedReportBlock and Report/ReportParsed blocks[] are typed. - viewItemArmShape takes its config schema as a generic parameter, so ViewItem / ViewItemWire config is the arm's ListView / FormView type. - flattenedViewOverlayFields is generic in its kind, so each flattened overlay member's static viewKind keeps its own literal. - ViewMetadataParsed / AssembledViewArtifactParsed TSDoc names the list overlay type default the parse applies and the output type lacks. - Type-level pins for each. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- packages/spec/src/ui/assembled-views.zod.ts | 7 ++ .../src/ui/joined-report-block-type.test.ts | 81 +++++++++++++ packages/spec/src/ui/report.zod.ts | 19 ++- .../spec/src/ui/view-item-config-type.test.ts | 110 ++++++++++++++++++ .../src/ui/view-overlay-viewkind-type.test.ts | 102 ++++++++++++++++ packages/spec/src/ui/view.zod.ts | 26 ++++- 6 files changed, 342 insertions(+), 3 deletions(-) create mode 100644 packages/spec/src/ui/joined-report-block-type.test.ts create mode 100644 packages/spec/src/ui/view-item-config-type.test.ts create mode 100644 packages/spec/src/ui/view-overlay-viewkind-type.test.ts diff --git a/packages/spec/src/ui/assembled-views.zod.ts b/packages/spec/src/ui/assembled-views.zod.ts index 2ad85131f62..e35cd06702a 100644 --- a/packages/spec/src/ui/assembled-views.zod.ts +++ b/packages/spec/src/ui/assembled-views.zod.ts @@ -118,6 +118,13 @@ export type AssembledViewArtifact = z.input<(typeof VIEW_METADATA_MEMBERS)[Exclu /** * Post-parse shape of {@link AssembledViewArtifact} — defaults applied, transforms run (ADR-0122): * the union of the same members' OUTPUT types, for the same reason. + * + * [#19920] One default is applied by the parse but absent from this type, the one + * `ViewMetadataParsed` (`view.zod.ts`) names: the flattened list overlay member declares `type` + * without the list shape's `.default('grid')` and re-applies it in an `.overwrite()`, which + * returns the member's own output type. So on that member `type` stays optional here (and, like + * its `columns`, typed `unknown`), while every body it parses comes back with `type` set: + * `'grid'` when the body named none. */ export type AssembledViewArtifactParsed = z.infer<(typeof VIEW_METADATA_MEMBERS)[Exclude]>; diff --git a/packages/spec/src/ui/joined-report-block-type.test.ts b/packages/spec/src/ui/joined-report-block-type.test.ts new file mode 100644 index 00000000000..0dbe4eeab84 --- /dev/null +++ b/packages/spec/src/ui/joined-report-block-type.test.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19920] The published type `JoinedReportBlock` names one sub-report of a joined report; it is + * not `unknown`, and neither is a `blocks[]` element of `Report` / `ReportParsed`. + * + * `JoinedReportBlockSchema` was annotated `z.ZodTypeAny`, so `z.input` of it WAS + * `unknown`, and `ReportSchema`'s `blocks: z.array(JoinedReportBlockSchema)` was `unknown[]`: any + * value type-checked as a block. The schema now carries its inferred type. + * + * Two halves, judged by two programs (the `view-metadata-type.test.ts` shape): + * + * - The TYPE half is judged by `tsc -p tsconfig.test.json` (the package's `typecheck` script, via + * `check:test-typecheck`), not by vitest. Each `@ts-expect-error` below asserts that its line + * does NOT compile. While the block type was `unknown` every one of them compiled, so each + * directive was unused: TS2578 in a file with no `test-typecheck-debt.json` entry, which reds the + * gate. + * - The RUNTIME half ties the typed bodies to the doors: each one parses, and the joined report's + * parsed `blocks` are values of the block's output type. + */ + +import { describe, it, expect } from 'vitest'; +import { + JoinedReportBlockSchema, + ReportSchema, + type JoinedReportBlock, + type Report, + type ReportParsed, +} from './report.zod'; + +type ParsedBlock = NonNullable[number]; + +// ── Real bodies, each typed through the published names ────────────────────────────────────── + +const openBlock: JoinedReportBlock = { + name: 'open_block', + label: 'Open Tasks', + type: 'summary', + dataset: 'task_metrics', + rows: ['status'], + values: ['est_hours'], + order: [{ by: 'est_hours', direction: 'desc' }], +}; +const listBlock: JoinedReportBlock = { name: 'done_block', dataset: 'task_metrics', values: ['task_count'] }; +const joined: Report = { name: 'task_overview', label: 'Task Overview', type: 'joined', blocks: [openBlock, listBlock] }; + +// ── What the block type refuses at compile time ────────────────────────────────────────────── + +const someValue: unknown = JSON.parse('{"nope":1}'); +// @ts-expect-error -- `unknown` is not a block; it was assignable while JoinedReportBlock was `unknown`. +const fromUnknown: JoinedReportBlock = someValue; +// @ts-expect-error -- a block is an object. +const scalar: JoinedReportBlock = 42; +// @ts-expect-error -- `notABlockKey` is declared by no block (TS2353). +const undeclaredKey: JoinedReportBlock = { name: 'b', dataset: 'task_metrics', notABlockKey: 1 }; +// @ts-expect-error -- `chart` was removed from the block (#20161); the closed shape refuses it too. +const retiredChart: JoinedReportBlock = { name: 'b', dataset: 'task_metrics', chart: { type: 'bar' } }; +// @ts-expect-error -- `joined` is excluded from a block's type enum (no recursion). +const nestedJoined: JoinedReportBlock = { name: 'b', type: 'joined' }; +// @ts-expect-error -- a `blocks[]` element of Report is a block, not any value. +const reportWithScalarBlock: Report = { name: 'r', label: 'R', type: 'joined', blocks: [42] }; +// @ts-expect-error -- nor is a parsed one. +const parsedScalarBlock: ParsedBlock = 42; +void [fromUnknown, scalar, undeclaredKey, retiredChart, nestedJoined, reportWithScalarBlock, parsedScalarBlock]; + +describe('[#19920] JoinedReportBlock is a joined-report block, not unknown', () => { + it('each block typed as JoinedReportBlock parses at the block door', () => { + for (const block of [openBlock, listBlock]) { + expect(JoinedReportBlockSchema.safeParse(block).success).toBe(true); + } + }); + + it('a joined report typed as Report parses, and its parsed blocks are block outputs', () => { + const parsed: ReportParsed = ReportSchema.parse(joined); + const blocks: ParsedBlock[] = parsed.blocks ?? []; + expect(blocks.map((b) => [b.name, b.type])).toEqual([ + ['open_block', 'summary'], + ['done_block', 'tabular'], + ]); + }); +}); diff --git a/packages/spec/src/ui/report.zod.ts b/packages/spec/src/ui/report.zod.ts index 007fe3e539e..33ed9f6dcac 100644 --- a/packages/spec/src/ui/report.zod.ts +++ b/packages/spec/src/ui/report.zod.ts @@ -203,8 +203,15 @@ const JOINED_CONTAINER_CHART_REFUSED = * - A block is drawn as a table and has no `chart` key: #20161 removed it, * because nothing ever drew it. Writing it is refused with the upgrade * prescription (the `guidance` entry below). + * + * [#19920] Carries its inferred type, not a `z.ZodTypeAny` annotation. That + * annotation erased the block's shape, so {@link JoinedReportBlock} and every + * `blocks[]` element of {@link Report} / {@link ReportParsed} were `unknown` + * and any value type-checked against them. It dodged no TS7056 (measured: none + * without it); what it bought was declaration size, the block's shape being + * emitted once here and once inside `ReportSchema`'s `blocks`. */ -export const JoinedReportBlockSchema: z.ZodTypeAny = lazySchema(() => strictObject({ +export const JoinedReportBlockSchema = lazySchema(() => strictObject({ surface: 'this joined report block', history: 'Until this shape was closed these were dropped silently — the block still rendered, ' @@ -543,6 +550,16 @@ export const ReportSchema = lazySchema(() => strictObject({ } })); +/** + * One sub-report of a `type: 'joined'` report (input shape): the input type of + * {@link JoinedReportBlockSchema}. + * + * [#19920] Was `unknown` while that schema was annotated `z.ZodTypeAny`. + * `joined-report-block-type.test.ts` pins that `unknown`, an undeclared key and + * the retired `chart` are refused here. A static type, not the schema's + * verdict: the `order` check against the selected dimensions and measures is a + * refinement, not a type, so `JoinedReportBlockSchema` remains the only judge. + */ export type JoinedReportBlock = z.input; /** diff --git a/packages/spec/src/ui/view-item-config-type.test.ts b/packages/spec/src/ui/view-item-config-type.test.ts new file mode 100644 index 00000000000..29a2b406645 --- /dev/null +++ b/packages/spec/src/ui/view-item-config-type.test.ts @@ -0,0 +1,110 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19920] A ViewItem's `config` is typed by its arm: a list item carries a `ListView` config and a + * form item a `FormView` config, on `ViewItem`, `ViewItemWire`, `defineViewItem`'s parameter, and + * the `viewItem` member of every union type read off `VIEW_METADATA_MEMBERS`. + * + * `viewItemArmShape(viewKind, config)` took `config: z.ZodTypeAny`, so each arm's static `config` + * WAS `unknown`: `config: 42` type-checked as every one of those names while both doors refuse it. + * The parameter is now generic, so each arm keeps its config schema's type. + * + * Two halves, judged by two programs (the `view-metadata-type.test.ts` shape): + * + * - The TYPE half is judged by `tsc -p tsconfig.test.json` (the package's `typecheck` script, via + * `check:test-typecheck`), not by vitest. Each `@ts-expect-error` below asserts that its line + * does NOT compile. While `config` was `unknown` every one of them compiled, so each directive + * was unused: TS2578 in a file with no `test-typecheck-debt.json` entry, which reds the gate. + * - The RUNTIME half ties the typed bodies to the doors: each one parses, and each refused body is + * refused at `config` by the doors too. + */ + +import { describe, it, expect } from 'vitest'; +import { + ViewItemSchema, + ViewItemWireSchema, + defineViewItem, + type ViewItem, + type ViewItemWire, + type ViewMetadata, + type ViewMetadataParsed, +} from './view.zod'; +import type { AssembledViewArtifact, AssembledViewArtifactParsed } from './assembled-views.zod'; + +// ── Real bodies, each typed through the published names ────────────────────────────────────── + +const listItem: ViewItem = { + name: 'crm_lead.pipeline', + object: 'crm_lead', + viewKind: 'list', + config: { type: 'kanban', data: { provider: 'object', object: 'crm_lead' }, columns: ['name', 'stage'] }, +}; +const formItem: ViewItem = { + name: 'crm_lead.intake', + object: 'crm_lead', + viewKind: 'form', + config: { type: 'simple', sections: [{ label: 'Main', fields: ['name'] }] }, +}; +const wireItem: ViewItemWire = { ...listItem, isPinned: true, sortOrder: 2 }; + +// ── What the arm-typed `config` refuses at compile time ────────────────────────────────────── + +const LIST_CONFIG = { type: 'grid', data: { provider: 'object', object: 'crm_lead' }, columns: ['name'] } as const; +// @ts-expect-error -- a list item's config is a ListView config, not a scalar. +const scalarConfig: ViewItem = { name: 'crm_lead.x', object: 'crm_lead', viewKind: 'list', config: 42 }; +// @ts-expect-error -- the form arm's config is a FormView config: `grid` is not a form type. +const listConfigOnFormArm: ViewItem = { name: 'crm_lead.x', object: 'crm_lead', viewKind: 'form', config: LIST_CONFIG }; +// @ts-expect-error -- the same on the wire member. +const wireScalarConfig: ViewItemWire = { name: 'crm_lead.x', object: 'crm_lead', viewKind: 'list', config: 42 }; +// @ts-expect-error -- and on `defineViewItem`'s parameter. +const definedScalarConfig = defineViewItem({ name: 'crm_lead.x', object: 'crm_lead', viewKind: 'list', config: 42 }); +// @ts-expect-error -- the `viewItem` member of ViewMetadata carries the same config type. +const metadataScalarConfig: ViewMetadata = { name: 'crm_lead.x', object: 'crm_lead', viewKind: 'list', config: 42 }; +// @ts-expect-error -- …and of ViewMetadataParsed. +const parsedScalarConfig: ViewMetadataParsed = { name: 'crm_lead.x', object: 'crm_lead', viewKind: 'list', config: 42 }; +// @ts-expect-error -- …and of AssembledViewArtifact. +const artifactScalarConfig: AssembledViewArtifact = { name: 'crm_lead.x', object: 'crm_lead', viewKind: 'list', config: 42 }; +// @ts-expect-error -- …and of AssembledViewArtifactParsed. +const parsedArtifactScalarConfig: AssembledViewArtifactParsed = { name: 'crm_lead.x', object: 'crm_lead', viewKind: 'list', config: 42 }; +void [ + scalarConfig, + listConfigOnFormArm, + wireScalarConfig, + definedScalarConfig, + metadataScalarConfig, + parsedScalarConfig, + artifactScalarConfig, + parsedArtifactScalarConfig, +]; + +describe('[#19920] a ViewItem config is typed by its arm, not unknown', () => { + it('each body typed through the published names parses at its door', () => { + expect(ViewItemSchema.safeParse(listItem).success).toBe(true); + expect(ViewItemSchema.safeParse(formItem).success).toBe(true); + expect(ViewItemWireSchema.safeParse(wireItem).success).toBe(true); + }); + + it('the bodies the type refuses are refused at `config` by both doors', () => { + const scalar = { name: 'crm_lead.x', object: 'crm_lead', viewKind: 'list', config: 42 }; + const formWithListConfig = { name: 'crm_lead.x', object: 'crm_lead', viewKind: 'form', config: LIST_CONFIG }; + for (const door of [ViewItemSchema, ViewItemWireSchema]) { + for (const body of [scalar, formWithListConfig]) { + const result = door.safeParse(body); + expect(result.success).toBe(false); + expect(result.error?.issues.every((issue) => issue.path[0] === 'config')).toBe(true); + } + } + }); + + it("the parsed config is the arm's parsed config (the list default `type` applied)", () => { + const parsed = ViewItemSchema.parse({ + name: 'crm_lead.all', + object: 'crm_lead', + viewKind: 'list', + config: { data: { provider: 'object', object: 'crm_lead' }, columns: ['name'] }, + }); + if (parsed.viewKind !== 'list') throw new Error('the list arm must accept a list body'); + const type: string = parsed.config.type; + expect(type).toBe('grid'); + }); +}); diff --git a/packages/spec/src/ui/view-overlay-viewkind-type.test.ts b/packages/spec/src/ui/view-overlay-viewkind-type.test.ts new file mode 100644 index 00000000000..e719f41d352 --- /dev/null +++ b/packages/spec/src/ui/view-overlay-viewkind-type.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19920] Each flattened overlay member's static `viewKind` is its own arm's literal: `'list'` on + * the list overlay, `'form'` on the form overlay. And the list overlay's `type` default, which the + * parse applies and the output type does not carry, is what the `ViewMetadataParsed` / + * `AssembledViewArtifactParsed` TSDoc says it is. + * + * `flattenedViewOverlayFields(kind)` took `kind: 'list' | 'form'`, so `z.enum([kind])` widened to + * that union on BOTH members: a list-shaped body naming `viewKind: 'form'` type-checked, through + * the list member, as `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and + * `AssembledViewArtifactParsed`, while both doors refuse it (the list member: the arm mismatch; the + * form member: `type` and `columns`). The function is now generic, so each member keeps its literal. + * + * Two halves, judged by two programs (the `view-metadata-type.test.ts` shape): + * + * - The TYPE half is judged by `tsc -p tsconfig.test.json` (the package's `typecheck` script, via + * `check:test-typecheck`), not by vitest. Each `@ts-expect-error` below asserts that its line + * does NOT compile. While `viewKind` was `'list' | 'form'` on both members every one of them + * compiled, so each directive was unused: TS2578 in a file with no `test-typecheck-debt.json` + * entry, which reds the gate. + * - The RUNTIME half ties it to the doors: the refused body is refused by every door that + * judges it, and a column-less list patch comes back from the parse with `type: 'grid'`. + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; +import { + VIEW_METADATA_MEMBERS, + ViewMetadataSchema, + type ViewMetadata, + type ViewMetadataParsed, +} from './view.zod'; +import { + AssembledViewArtifactSchema, + type AssembledViewArtifact, + type AssembledViewArtifactParsed, +} from './assembled-views.zod'; + +type ListOverlayIn = z.input; +type ListOverlayOut = z.output; +type FormOverlayIn = z.input; +type FormOverlayOut = z.output; + +// ── Each member's `viewKind` is its own arm's literal ──────────────────────────────────────── + +const listKindIn: ListOverlayIn['viewKind'] = 'list'; +const listKindOut: ListOverlayOut['viewKind'] = 'list'; +const formKindIn: FormOverlayIn['viewKind'] = 'form'; +const formKindOut: FormOverlayOut['viewKind'] = 'form'; +// @ts-expect-error -- the list overlay member judges `viewKind: 'list'` only. +const listKindInForm: ListOverlayIn['viewKind'] = 'form'; +// @ts-expect-error -- on its output too. +const listKindOutForm: ListOverlayOut['viewKind'] = 'form'; +// @ts-expect-error -- the form overlay member judges `viewKind: 'form'` only. +const formKindInList: FormOverlayIn['viewKind'] = 'list'; +// @ts-expect-error -- on its output too. +const formKindOutList: FormOverlayOut['viewKind'] = 'list'; + +// ── The body both doors refuse no longer type-checks as any union type ─────────────────────── + +// @ts-expect-error -- a list-shaped body naming `viewKind: 'form'` is no view artifact. +const artifact: AssembledViewArtifact = { type: 'grid', columns: ['name'], object: 'crm_lead', viewKind: 'form' }; +// @ts-expect-error -- nor a parsed one. +const parsedArtifact: AssembledViewArtifactParsed = { type: 'grid', columns: ['name'], object: 'crm_lead', viewKind: 'form' }; +// @ts-expect-error -- nor a view body. +const metadata: ViewMetadata = { type: 'grid', columns: ['name'], object: 'crm_lead', viewKind: 'form' }; +// @ts-expect-error -- nor a parsed one. +const parsedMetadata: ViewMetadataParsed = { type: 'grid', columns: ['name'], object: 'crm_lead', viewKind: 'form' }; +void [listKindIn, listKindOut, formKindIn, formKindOut, listKindInForm, listKindOutForm, formKindInList, formKindOutList]; +void [artifact, parsedArtifact, metadata, parsedMetadata]; + +// ── The list overlay's `type` default: applied by the parse, absent from the output type ───── +// +// The TSDoc on `ViewMetadataParsed` / `AssembledViewArtifactParsed` says both things in words, and +// these two lines compile only while each one holds. The day a change carries the `.overwrite()` +// default into the output type, or types the member's `type` / `columns`, a line stops compiling +// and the TSDoc sentences are then false: correct them with it. +type IsOptionalKey = {} extends Pick ? true : false; +type IsUnknown = unknown extends T ? true : false; +const typeIsOptionalOnListOverlayOutput: IsOptionalKey = true; +const typeAndColumnsAreUnknownThere: [IsUnknown, IsUnknown] = [true, true]; +void [typeIsOptionalOnListOverlayOutput, typeAndColumnsAreUnknownThere]; + +describe('[#19920] the flattened overlay members keep their own viewKind literal', () => { + it('the list-shaped `viewKind: "form"` body is refused by every door that judges it', () => { + const body = { type: 'grid', columns: ['name'], object: 'crm_lead', viewKind: 'form' }; + expect(VIEW_METADATA_MEMBERS.listOverlay.safeParse(body).success).toBe(false); + expect(VIEW_METADATA_MEMBERS.formOverlay.safeParse(body).success).toBe(false); + expect(ViewMetadataSchema.safeParse(body).success).toBe(false); + expect(AssembledViewArtifactSchema.safeParse(body).success).toBe(false); + }); + + it("a column-less list patch parses with `type: 'grid'`, which its output type leaves optional", () => { + const patch = { object: 'crm_lead', viewKind: 'list', sort: [{ field: 'name', order: 'asc' }] }; + for (const door of [VIEW_METADATA_MEMBERS.listOverlay, ViewMetadataSchema, AssembledViewArtifactSchema]) { + const result = door.safeParse(patch); + expect(result.success).toBe(true); + expect(result.data).toMatchObject({ type: 'grid', viewKind: 'list' }); + } + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 2609367870c..9b9c6e7237e 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -4895,8 +4895,15 @@ function viewItemBaseShape() { * derive-by-reference; the fork PD#12 exists to prevent). The two differ in * exactly two ways, both visible at the call site: the unknown-key posture, and * the round-trip keys the wire arm additionally declares. + * + * [#19920] `config` is generic, like `viewKind`, so each arm's static type + * carries its own config schema's type. Typed `z.ZodTypeAny`, it erased + * `config` to `unknown` on both arms of {@link ViewItem} and + * {@link ViewItemWire}, and through the `viewItem` member on every union type + * read off {@link VIEW_METADATA_MEMBERS}: `config: 42` type-checked while both + * doors refuse it. */ -function viewItemArmShape(viewKind: K, config: z.ZodTypeAny) { +function viewItemArmShape(viewKind: K, config: C) { return { viewKind: z.literal(viewKind), config, @@ -5255,8 +5262,14 @@ function overlayViewKindArmMismatch(kind: 'list' | 'form'): string { * unjudged; the list member likewise accepted a `viewKind: 'form'` body that * carried list `columns`. The conversions walk (`mapViewPayloads`) already * picked an overlay's family from `viewKind`; now the parse does too. + * + * [#19920] …and so does the static type: generic in `K`, so `z.enum([kind])` + * keeps the arm's literal. With a `'list' | 'form'` parameter it widened to + * that union on both members, and a list-shaped body naming + * `viewKind: 'form'` type-checked, through the list member, as every union + * type read off {@link VIEW_METADATA_MEMBERS}, while both doors refuse it. */ -function flattenedViewOverlayFields(kind: 'list' | 'form') { +function flattenedViewOverlayFields(kind: K) { return { // No grammar, deliberately: the write path stamps this name rather than an // author writing it, so a flat overlay name is legal here while the SAME @@ -6700,6 +6713,15 @@ export type ViewMetadata = z.input<(typeof VIEW_METADATA_MEMBERS)[ViewMetadataBr * union's `.check()` transforms nothing), so every parse result is a value of this type. * `view-metadata-type.test.ts` pins that `unknown` is refused here and that a parsed body of each * member type-checks. + * + * [#19920] One default is applied by the parse but absent from this type. The flattened list + * overlay member (`VIEW_METADATA_MEMBERS.listOverlay`) declares `type` without the list shape's + * `.default('grid')`, so its checks can tell a column-less patch from a full config, and + * re-applies the default in `.overwrite(applyListOverlayTypeDefault)`. An `.overwrite()` returns + * the member's own output type, so on that member `type` stays optional here, while every body + * that member parses comes back with `type` set: `'grid'` when the body named none. On that + * member `type` and `columns` are also typed `unknown`: `listOverlayPatchFields` reads both off + * the list shape through an untyped cast. */ export type ViewMetadataParsed = z.infer<(typeof VIEW_METADATA_MEMBERS)[ViewMetadataBranch]>; export type ViewScope = z.input; From 53b34611de765224fd2e9adbe21399ce3de07494 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:22:07 +0000 Subject: [PATCH 2/8] fix(spec): name the ViewItem arm shapes so the typed config is emitted by reference Inferred, ViewItemSchema, ViewItemWireSchema and the viewItem member of VIEW_METADATA_MEMBERS each spelled both config types out in full, +170 KB of view.zod.d.ts. Both schemas are now annotated through ViewItemArmShape / ViewItemWireArmShape (read off viewItemArmShape itself, identical to the inferred types), and the member is asserted to its schema's own type, so the declaration file names them instead. Adds the changeset, and corrects the pending note that said JoinedReportBlock still resolves to unknown. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- .../19920-exported-types-not-unknown.md | 2 +- .changeset/19920-exported-types-remainder.md | 23 +++++++++++ packages/spec/src/ui/view.zod.ts | 39 +++++++++++++++++-- 3 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 .changeset/19920-exported-types-remainder.md diff --git a/.changeset/19920-exported-types-not-unknown.md b/.changeset/19920-exported-types-not-unknown.md index bce741afb85..e67716ee4d5 100644 --- a/.changeset/19920-exported-types-not-unknown.md +++ b/.changeset/19920-exported-types-not-unknown.md @@ -18,6 +18,6 @@ Four published type aliases were derived from a schema whose own static type era The types are the members' declared shapes, not the schemas' verdicts. Each schema still accepts some bodies its type refuses (the preprocess folds and strips) and still refuses some bodies its type admits (refinements are not types), so the schema remains the only judge. -`JoinedReportBlock` is not changed by this change, and still resolves to `unknown`. +`JoinedReportBlock` is not changed by this change. It stops resolving to `unknown` in its own entry (#19920). diff --git a/.changeset/19920-exported-types-remainder.md b/.changeset/19920-exported-types-remainder.md new file mode 100644 index 00000000000..e3f14bcc7af --- /dev/null +++ b/.changeset/19920-exported-types-remainder.md @@ -0,0 +1,23 @@ +--- +'@objectstack/spec': minor +--- + +fix(spec): `JoinedReportBlock`, a ViewItem's `config` and a flattened overlay's `viewKind` carry the shapes their doors accept (#19920) + +Clause-②: no (narrowing) + +**BREAKING for TypeScript code that annotates with `JoinedReportBlock`, `Report`, `ReportParsed`, `ViewItem`, `ViewItemWire`, `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` or `AssembledViewArtifactParsed`, or that passes an unchecked value to `defineReport` / `defineViewItem`**: a narrowing of published TYPES, landing in the launch window as `minor` (the lockstep convention: the bump level is not the carrier, this banner and the disposition below are). The runtime accept set does not move at all: no schema's parse, no export and no value changes. + +Three places in the published types were wider than the doors that judge the same bodies, so values those doors refuse type-checked: + +- `JoinedReportBlock`: FROM `unknown` TO the input shape of `JoinedReportBlockSchema`. The schema was annotated `z.ZodTypeAny`, which erased its shape; it now carries its inferred type. The same erasure made every `blocks[]` element of `Report` / `ReportParsed` (and so of `defineReport`'s parameter) `unknown`; each is now a block. +- A ViewItem's `config`: FROM `unknown` TO the arm's own config type, a `ListView` config on the `list` arm and a `FormView` config on the `form` arm. This holds on `ViewItem`, `ViewItemWire`, `defineViewItem`'s parameter and return, and the `viewItem` member of `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`. The arm builder took `config` as `z.ZodTypeAny`; it is now a generic parameter. +- A flattened overlay member's `viewKind`: FROM `'list' | 'form'` on both members TO `'list'` on the list overlay and `'form'` on the form overlay, the one value each member accepts. A list-shaped body naming `viewKind: 'form'` used to type-check, through the list overlay member, as `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`. + +**If your code stops compiling.** A value you annotated with one of these names, or passed to `defineReport` / `defineViewItem`, is not the shape the door accepts: correct it, or type a value that is still unvalidated as `unknown` and let the schema's `safeParse` decide. A ViewItem's `config` must match its `viewKind`: a `ListView` config under `viewKind: 'list'`, a `FormView` config under `viewKind: 'form'`. + +One default is applied by the parse and is absent from `ViewMetadataParsed` / `AssembledViewArtifactParsed`, and their TSDoc now says so: the flattened list overlay member re-applies `type: 'grid'` in an `.overwrite()`, so every body it parses carries `type`, while its output type leaves `type` optional. + +The types are the members' declared shapes, not the schemas' verdicts: refinements are not types, so each schema remains the only judge. + + diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 9b9c6e7237e..74c5eabd4e5 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -4911,6 +4911,30 @@ function viewItemArmShape(vie }; } +/** + * [#19920] The static shape of one ViewItem arm, read off + * {@link viewItemArmShape} itself, so it cannot drift from what the arm is + * built from. It exists for the declaration emitter: {@link ViewItemSchema} and + * {@link ViewItemWireSchema} are annotated through it with + * `typeof ListViewSchema` / `typeof FormViewSchema`, which the `.d.ts` then + * names instead of spelling each config type out in full. Inferred, the two + * schemas and the `viewItem` member of {@link VIEW_METADATA_MEMBERS} each + * carried a full copy of both config types (+170 KB of `view.zod.d.ts`, + * measured); annotated, the file is smaller than when `config` was erased. + */ +type ViewItemArmShape = ReturnType>; + +/** + * [#19920] {@link ViewItemArmShape} plus the wire arm's round-trip keys: the + * object spread {@link ViewItemWireSchema} builds each arm from, as one mapped + * object type rather than an intersection, so the annotation is IDENTICAL to + * the type the spread infers, not merely assignable to it. + */ +type ViewItemWireArmShape = { + [P in keyof (ViewItemArmShape & ReturnType)]: + (ViewItemArmShape & ReturnType)[P]; +}; + /** * [#9933] The per-user column layout the console's grid persists through the * `view` metadata door — an **explicitly runtime-only overlay key**, admitted @@ -5003,7 +5027,10 @@ const VIEW_ITEM_SURFACE = { * at all**, parsed clean. That is #1535's `workflows: [...]` replayed on the * surface with the highest author density in the file. */ -export const ViewItemSchema = lazySchema(() => +export const ViewItemSchema: z.ZodDiscriminatedUnion<[ + z.ZodObject, z.core.$strict>, + z.ZodObject, z.core.$strict>, +], 'viewKind'> = lazySchema(() => z.discriminatedUnion('viewKind', [ strictObject(VIEW_ITEM_SURFACE, viewItemArmShape('list', ListViewSchema.describe('List-family view configuration.'))), strictObject(VIEW_ITEM_SURFACE, viewItemArmShape('form', FormViewSchema.describe('Form view configuration.'))), @@ -5051,7 +5078,10 @@ function viewItemWireFields() { * {@link stripViewConsoleDecorations} on the wire door — see that function for * why a recursive strip is the piece a posture flip cannot provide. */ -export const ViewItemWireSchema = lazySchema(() => +export const ViewItemWireSchema: z.ZodDiscriminatedUnion<[ + z.ZodObject, z.core.$strip>, + z.ZodObject, z.core.$strip>, +], 'viewKind'> = lazySchema(() => z.discriminatedUnion('viewKind', [ z.object({ ...viewItemArmShape('list', ListViewSchema.describe('List-family view configuration.')), @@ -5893,7 +5923,10 @@ export type ViewMetadataBranch = (typeof VIEW_METADATA_BRANCHES)[number]; export const VIEW_METADATA_MEMBERS = { // 1. Standalone ViewItem record — nested config validated genuinely, and the // WIRE variant, so Studio's round-trip keys have a declared home. - viewItem: ViewItemWireSchema, + // [#19920] The assertion changes no type (it is the schema's own); it makes + // the declaration emitter write `typeof ViewItemWireSchema` here instead of a + // third full copy of both config types (see {@link ViewItemArmShape}). + viewItem: ViewItemWireSchema as typeof ViewItemWireSchema, // 2. Non-empty defineView container. container: ViewContainerWireSchema, // 3/4. Flattened runtime overlay — inline ListView / FormView config + identity, From da40ead6ba2b1566ade67cc04615e1ac477910dd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:30:28 +0000 Subject: [PATCH 3/8] fix(spec): declare the Parsed siblings the typed schemas now need (ADR-0122) JoinedReportBlockSchema, ViewItemSchema and ViewItemWireSchema were pinned input === infer only because their erased types were the same unknown on both sides. Typed, each carries defaults, so the pins are false: they are deleted and JoinedReportBlockParsed, ViewItemParsed and ViewItemWireParsed are declared, as the convention prescribes. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- packages/spec/src/type-alias-convention.pin.test.ts | 11 ++++++++--- packages/spec/src/ui/joined-report-block-type.test.ts | 5 +++-- packages/spec/src/ui/report.zod.ts | 2 ++ packages/spec/src/ui/view-item-config-type.test.ts | 3 ++- packages/spec/src/ui/view.zod.ts | 4 ++++ 5 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index f51e1475899..db0e412f6e0 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -1551,7 +1551,10 @@ export type Iso_ui_page__PageComponentType = Assert, z.infer< typeof M163.PageTypeSchema > >>; // ui/report.zod.ts -export type Iso_ui_report__JoinedReportBlockSchema = Assert, z.infer< typeof M164.JoinedReportBlockSchema > >>; +// `JoinedReportBlockSchema` left the family on #19920: its `z.ZodTypeAny` +// annotation made input and infer the same `unknown`, and with the annotation +// gone its `type` default makes them differ, so `JoinedReportBlockParsed` is +// declared and the pin deleted. export type Iso_ui_report__ReportType = Assert, z.infer< typeof M164.ReportType > >>; // ui/responsive.zod.ts @@ -1566,6 +1569,10 @@ export type Iso_ui_responsive__StyleMapSchema = Assert, z.infer< typeof M167.TreeConfigSchema > >>; export type Iso_ui_view__UserFilterFieldSchema = Assert, z.infer< typeof M167.UserFilterFieldSchema > >>; export type Iso_ui_view__ViewItemNameSchema = Assert, z.infer< typeof M167.ViewItemNameSchema > >>; -export type Iso_ui_view__ViewItemSchema = Assert, z.infer< typeof M167.ViewItemSchema > >>; -export type Iso_ui_view__ViewItemWireSchema = Assert, z.infer< typeof M167.ViewItemWireSchema > >>; export type Iso_ui_view__ViewKindSchema = Assert, z.infer< typeof M167.ViewKindSchema > >>; export type Iso_ui_view__ViewScopeSchema = Assert, z.infer< typeof M167.ViewScopeSchema > >>; export type Iso_ui_view__VisualizationTypeSchema = Assert, z.infer< typeof M167.VisualizationTypeSchema > >>; diff --git a/packages/spec/src/ui/joined-report-block-type.test.ts b/packages/spec/src/ui/joined-report-block-type.test.ts index 0dbe4eeab84..5efe1e7f6f1 100644 --- a/packages/spec/src/ui/joined-report-block-type.test.ts +++ b/packages/spec/src/ui/joined-report-block-type.test.ts @@ -24,6 +24,7 @@ import { JoinedReportBlockSchema, ReportSchema, type JoinedReportBlock, + type JoinedReportBlockParsed, type Report, type ReportParsed, } from './report.zod'; @@ -70,9 +71,9 @@ describe('[#19920] JoinedReportBlock is a joined-report block, not unknown', () } }); - it('a joined report typed as Report parses, and its parsed blocks are block outputs', () => { + it('a joined report typed as Report parses, and its parsed blocks are JoinedReportBlockParsed', () => { const parsed: ReportParsed = ReportSchema.parse(joined); - const blocks: ParsedBlock[] = parsed.blocks ?? []; + const blocks: JoinedReportBlockParsed[] = parsed.blocks ?? []; expect(blocks.map((b) => [b.name, b.type])).toEqual([ ['open_block', 'summary'], ['done_block', 'tabular'], diff --git a/packages/spec/src/ui/report.zod.ts b/packages/spec/src/ui/report.zod.ts index 33ed9f6dcac..bd8f519206c 100644 --- a/packages/spec/src/ui/report.zod.ts +++ b/packages/spec/src/ui/report.zod.ts @@ -561,6 +561,8 @@ export const ReportSchema = lazySchema(() => strictObject({ * refinement, not a type, so `JoinedReportBlockSchema` remains the only judge. */ export type JoinedReportBlock = z.input; +/** Post-parse shape of {@link JoinedReportBlock} — defaults applied, transforms run (ADR-0122). */ +export type JoinedReportBlockParsed = z.infer; /** * Report Types diff --git a/packages/spec/src/ui/view-item-config-type.test.ts b/packages/spec/src/ui/view-item-config-type.test.ts index 29a2b406645..8a48c10756f 100644 --- a/packages/spec/src/ui/view-item-config-type.test.ts +++ b/packages/spec/src/ui/view-item-config-type.test.ts @@ -25,6 +25,7 @@ import { ViewItemWireSchema, defineViewItem, type ViewItem, + type ViewItemParsed, type ViewItemWire, type ViewMetadata, type ViewMetadataParsed, @@ -97,7 +98,7 @@ describe('[#19920] a ViewItem config is typed by its arm, not unknown', () => { }); it("the parsed config is the arm's parsed config (the list default `type` applied)", () => { - const parsed = ViewItemSchema.parse({ + const parsed: ViewItemParsed = ViewItemSchema.parse({ name: 'crm_lead.all', object: 'crm_lead', viewKind: 'list', diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 74c5eabd4e5..7c7d504d8d7 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -6713,8 +6713,12 @@ export type View = z.input; /** Post-parse shape of {@link View} — defaults applied, transforms run (ADR-0122). */ export type ViewParsed = z.infer; export type ViewItem = z.input; +/** Post-parse shape of {@link ViewItem} — defaults applied, transforms run (ADR-0122). */ +export type ViewItemParsed = z.infer; /** A ViewItem record as it travels the WIRE — the authoring shape plus Studio's round-trip keys (#5074). */ export type ViewItemWire = z.input; +/** Post-parse shape of {@link ViewItemWire} — defaults applied, transforms run (ADR-0122). */ +export type ViewItemWireParsed = z.infer; /** * Any persisted `view` metadata body: container | ViewItem record | flattened overlay (#3095) — * the union of the INPUT types of the members {@link ViewMetadataSchema}'s union runs, read off From f18529a28d16d1c4eb423539dba43abe32ad810f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:41:44 +0000 Subject: [PATCH 4/8] chore(spec): regenerate api-surface, export-origins and the test-typecheck ledger The three Parsed aliases enter the ui entry's surface. The report.test.ts ledger shrinks by two signatures: blocks[] is no longer unknown there. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- packages/spec/api-surface/ui.json | 3 +++ packages/spec/export-origins/ui.json | 3 +++ packages/spec/test-typecheck-debt.json | 4 +--- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index d90dcff4eee..d5bbfb0a06b 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -216,6 +216,7 @@ "InterfacePageConfigParsed (type)", "InterfacePageConfigSchema (const)", "JoinedReportBlock (type)", + "JoinedReportBlockParsed (type)", "JoinedReportBlockSchema (const)", "KNOWN_COMPONENT_TYPES (const)", "KNOWN_COMPONENT_TYPE_CANDIDATES (const)", @@ -420,8 +421,10 @@ "ViewItem (type)", "ViewItemName (type)", "ViewItemNameSchema (const)", + "ViewItemParsed (type)", "ViewItemSchema (const)", "ViewItemWire (type)", + "ViewItemWireParsed (type)", "ViewItemWireSchema (const)", "ViewKeyCollision (interface)", "ViewKind (type)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index b9e4ff35012..08486d318ca 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -212,6 +212,7 @@ "InterfacePageConfigParsed": "src/ui/page.zod.ts#InterfacePageConfigParsed (type)", "InterfacePageConfigSchema": "src/ui/page.zod.ts#InterfacePageConfigSchema (const)", "JoinedReportBlock": "src/ui/report.zod.ts#JoinedReportBlock (type)", + "JoinedReportBlockParsed": "src/ui/report.zod.ts#JoinedReportBlockParsed (type)", "JoinedReportBlockSchema": "src/ui/report.zod.ts#JoinedReportBlockSchema (const)", "KNOWN_COMPONENT_TYPES": "src/ui/component-type-vocabulary.ts#KNOWN_COMPONENT_TYPES (const)", "KNOWN_COMPONENT_TYPE_CANDIDATES": "src/ui/component-type-vocabulary.ts#KNOWN_COMPONENT_TYPE_CANDIDATES (const)", @@ -406,8 +407,10 @@ "ViewItem": "src/ui/view.zod.ts#ViewItem (type)", "ViewItemName": "src/ui/view.zod.ts#ViewItemName (type)", "ViewItemNameSchema": "src/ui/view.zod.ts#ViewItemNameSchema (const)", + "ViewItemParsed": "src/ui/view.zod.ts#ViewItemParsed (type)", "ViewItemSchema": "src/ui/view.zod.ts#ViewItemSchema (const)", "ViewItemWire": "src/ui/view.zod.ts#ViewItemWire (type)", + "ViewItemWireParsed": "src/ui/view.zod.ts#ViewItemWireParsed (type)", "ViewItemWireSchema": "src/ui/view.zod.ts#ViewItemWireSchema (const)", "ViewKeyCollision": "src/ui/view.zod.ts#ViewKeyCollision (interface)", "ViewKind": "src/ui/view.zod.ts#ViewKind (type)", diff --git a/packages/spec/test-typecheck-debt.json b/packages/spec/test-typecheck-debt.json index a83ef5a6d08..3a1b0cb93f0 100644 --- a/packages/spec/test-typecheck-debt.json +++ b/packages/spec/test-typecheck-debt.json @@ -236,9 +236,7 @@ "TS6133: 'measureDoors' is declared but its value is never read.": 1 }, "src/ui/report.test.ts": { - "TS18046: 'b' is of type 'unknown'.": 1, - "TS18048: 'r.blocks' is possibly 'undefined'.": 1, - "TS2571: Object is of type 'unknown'.": 1 + "TS18048: 'r.blocks' is possibly 'undefined'.": 1 }, "src/ui/view.test.ts": { "TS2322: Type 'string' is not assignable to type '…'.": 1, From 665a94f2901397edcabba0d22b738ce0802ec22b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:23:34 +0000 Subject: [PATCH 5/8] test(spec): restate the isomorphic pin count, and never call the factory on a refused body The pin count falls 786 -> 783 with the three deleted pins, stated in both prose places and the count history. The defineViewItem type pin now wraps the call: the factory parses, so calling it on the refused body threw at module load. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- .../spec/src/type-alias-convention.pin.test.ts | 15 ++++++++++++--- .../spec/src/ui/view-item-config-type.test.ts | 3 ++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index db0e412f6e0..3ac387b41d5 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -275,7 +275,7 @@ import type * as M187 from './shared/duration.zod.js'; import type * as M188 from './ai/build-progress.zod.js'; // --------------------------------------------------------------------------- -// 786 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 783 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1663,7 +1663,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 786 isomorphic pins', () => { + it('still declares all 783 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2310,7 +2310,16 @@ describe('ADR-0122 type-alias convention', () => { // touch disjoint pins (M22's three, M14's one); #17158 landed first, so // this entry's arrow starts from its 787. The count below was re-derived // from the merged file, not added up. -1 removed. - expect(pins).toHaveLength(786); + // + // 786 -> 783 is #19920's typing of three schemas whose static type had been + // erased, so that input and infer were the same `unknown` and the pins held + // vacuously: `JoinedReportBlockSchema` (its `z.ZodTypeAny` annotation + // removed) and `ViewItemSchema` / `ViewItemWireSchema` (their `config` typed + // by its arm). Typed, each carries defaults, so input !== infer: + // Iso_ui_report__JoinedReportBlockSchema, Iso_ui_view__ViewItemSchema and + // Iso_ui_view__ViewItemWireSchema leave, and `JoinedReportBlockParsed`, + // `ViewItemParsed` and `ViewItemWireParsed` are declared. -3 removed. + expect(pins).toHaveLength(783); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until #6605 nothing read either diff --git a/packages/spec/src/ui/view-item-config-type.test.ts b/packages/spec/src/ui/view-item-config-type.test.ts index 8a48c10756f..78af633427b 100644 --- a/packages/spec/src/ui/view-item-config-type.test.ts +++ b/packages/spec/src/ui/view-item-config-type.test.ts @@ -57,8 +57,9 @@ const scalarConfig: ViewItem = { name: 'crm_lead.x', object: 'crm_lead', viewKin const listConfigOnFormArm: ViewItem = { name: 'crm_lead.x', object: 'crm_lead', viewKind: 'form', config: LIST_CONFIG }; // @ts-expect-error -- the same on the wire member. const wireScalarConfig: ViewItemWire = { name: 'crm_lead.x', object: 'crm_lead', viewKind: 'list', config: 42 }; +// Wrapped, never called: the factory parses, and this body is refused at runtime too. // @ts-expect-error -- and on `defineViewItem`'s parameter. -const definedScalarConfig = defineViewItem({ name: 'crm_lead.x', object: 'crm_lead', viewKind: 'list', config: 42 }); +const definedScalarConfig = () => defineViewItem({ name: 'crm_lead.x', object: 'crm_lead', viewKind: 'list', config: 42 }); // @ts-expect-error -- the `viewItem` member of ViewMetadata carries the same config type. const metadataScalarConfig: ViewMetadata = { name: 'crm_lead.x', object: 'crm_lead', viewKind: 'list', config: 42 }; // @ts-expect-error -- …and of ViewMetadataParsed. From 35094e87739f152baa3b46c8a3250259c033208c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:34:24 +0000 Subject: [PATCH 6/8] docs(changeset): name the three Parsed aliases and the schemas whose declared types narrow Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- .changeset/19920-exported-types-remainder.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/19920-exported-types-remainder.md b/.changeset/19920-exported-types-remainder.md index e3f14bcc7af..6e073aa8fbb 100644 --- a/.changeset/19920-exported-types-remainder.md +++ b/.changeset/19920-exported-types-remainder.md @@ -16,6 +16,8 @@ Three places in the published types were wider than the doors that judge the sam **If your code stops compiling.** A value you annotated with one of these names, or passed to `defineReport` / `defineViewItem`, is not the shape the door accepts: correct it, or type a value that is still unvalidated as `unknown` and let the schema's `safeParse` decide. A ViewItem's `config` must match its `viewKind`: a `ListView` config under `viewKind: 'list'`, a `FormView` config under `viewKind: 'form'`. +The declared types of `JoinedReportBlockSchema`, `ViewItemSchema` and `ViewItemWireSchema` narrow with them, so `z.input` / `z.infer` of each is typed where it was `unknown` (or carried an `unknown` `config`). Typed, each schema's input and output now differ by its defaults, so three ADR-0122 parsed-state aliases are added beside the bare names: `JoinedReportBlockParsed`, `ViewItemParsed` and `ViewItemWireParsed`. Nothing is removed or renamed. + One default is applied by the parse and is absent from `ViewMetadataParsed` / `AssembledViewArtifactParsed`, and their TSDoc now says so: the flattened list overlay member re-applies `type: 'grid'` in an `.overwrite()`, so every body it parses carries `type`, while its output type leaves `type` optional. The types are the members' declared shapes, not the schemas' verdicts: refinements are not types, so each schema remains the only judge. From 406335af43db5cd3ed1c4680377a1d65a6089eed Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 04:36:38 +0000 Subject: [PATCH 7/8] =?UTF-8?q?docs(changeset):=20declare=20Clause-?= =?UTF-8?q?=E2=91=A1=20yes=20(narrowing),=20since=20three=20type=20names?= =?UTF-8?q?=20are=20added?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diff narrows published types and also adds three exported names (JoinedReportBlockParsed, ViewItemParsed, ViewItemWireParsed), so the line reads `yes (narrowing)`. The BREAKING banner and the ADR-0087 marker reason now say that no EXISTING export changes; the marker token is unchanged. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- .changeset/19920-exported-types-remainder.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/19920-exported-types-remainder.md b/.changeset/19920-exported-types-remainder.md index 6e073aa8fbb..871c888d355 100644 --- a/.changeset/19920-exported-types-remainder.md +++ b/.changeset/19920-exported-types-remainder.md @@ -4,9 +4,9 @@ fix(spec): `JoinedReportBlock`, a ViewItem's `config` and a flattened overlay's `viewKind` carry the shapes their doors accept (#19920) -Clause-②: no (narrowing) +Clause-②: yes (narrowing) -**BREAKING for TypeScript code that annotates with `JoinedReportBlock`, `Report`, `ReportParsed`, `ViewItem`, `ViewItemWire`, `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` or `AssembledViewArtifactParsed`, or that passes an unchecked value to `defineReport` / `defineViewItem`**: a narrowing of published TYPES, landing in the launch window as `minor` (the lockstep convention: the bump level is not the carrier, this banner and the disposition below are). The runtime accept set does not move at all: no schema's parse, no export and no value changes. +**BREAKING for TypeScript code that annotates with `JoinedReportBlock`, `Report`, `ReportParsed`, `ViewItem`, `ViewItemWire`, `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` or `AssembledViewArtifactParsed`, or that passes an unchecked value to `defineReport` / `defineViewItem`**: a narrowing of published TYPES, landing in the launch window as `minor` (the lockstep convention: the bump level is not the carrier, this banner and the disposition below are). The runtime accept set does not move at all: no schema's parse, no value and no existing export changes. Three parsed-state type names are added (below); nothing is removed or renamed. Three places in the published types were wider than the doors that judge the same bodies, so values those doors refuse type-checked: @@ -22,4 +22,4 @@ One default is applied by the parse and is absent from `ViewMetadataParsed` / `A The types are the members' declared shapes, not the schemas' verdicts: refinements are not types, so each schema remains the only judge. - + From 777b5097834d92c9e9565878406aa2409c597811 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 04:41:31 +0000 Subject: [PATCH 8/8] fix(spec): a flattened list overlay's type and columns carry the list shape's types listOverlayPatchFields() read `type` and `columns` off ListViewShapeSchema through a cast of its shape to a record of z.ZodTypeAny, which typed both keys `unknown` on the list overlay member. So `{ object, viewKind: 'list', columns: 42 }` type-checked as ViewMetadata, ViewMetadataParsed, AssembledViewArtifact and AssembledViewArtifactParsed while that member refuses it. The shape is now read as typed; the schemas are the same objects, so no parse moves. The item-4 TSDoc sentences on ViewMetadataParsed and AssembledViewArtifactParsed no longer say the two keys are `unknown`, and the pin that held them `unknown` is replaced by pins that they are typed, with four refused-body probes and a runtime half. The changeset names the fourth narrowing. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- .changeset/19920-exported-types-remainder.md | 7 +-- packages/spec/src/ui/assembled-views.zod.ts | 6 +-- .../src/ui/view-overlay-viewkind-type.test.ts | 52 +++++++++++++++---- packages/spec/src/ui/view.zod.ts | 19 ++++--- 4 files changed, 61 insertions(+), 23 deletions(-) diff --git a/.changeset/19920-exported-types-remainder.md b/.changeset/19920-exported-types-remainder.md index 871c888d355..f610973b9d6 100644 --- a/.changeset/19920-exported-types-remainder.md +++ b/.changeset/19920-exported-types-remainder.md @@ -2,19 +2,20 @@ '@objectstack/spec': minor --- -fix(spec): `JoinedReportBlock`, a ViewItem's `config` and a flattened overlay's `viewKind` carry the shapes their doors accept (#19920) +fix(spec): `JoinedReportBlock`, a ViewItem's `config`, a flattened overlay's `viewKind` and a flattened list overlay's `type` / `columns` carry the shapes their doors accept (#19920) Clause-②: yes (narrowing) **BREAKING for TypeScript code that annotates with `JoinedReportBlock`, `Report`, `ReportParsed`, `ViewItem`, `ViewItemWire`, `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` or `AssembledViewArtifactParsed`, or that passes an unchecked value to `defineReport` / `defineViewItem`**: a narrowing of published TYPES, landing in the launch window as `minor` (the lockstep convention: the bump level is not the carrier, this banner and the disposition below are). The runtime accept set does not move at all: no schema's parse, no value and no existing export changes. Three parsed-state type names are added (below); nothing is removed or renamed. -Three places in the published types were wider than the doors that judge the same bodies, so values those doors refuse type-checked: +Four places in the published types were wider than the doors that judge the same bodies, so values those doors refuse type-checked: - `JoinedReportBlock`: FROM `unknown` TO the input shape of `JoinedReportBlockSchema`. The schema was annotated `z.ZodTypeAny`, which erased its shape; it now carries its inferred type. The same erasure made every `blocks[]` element of `Report` / `ReportParsed` (and so of `defineReport`'s parameter) `unknown`; each is now a block. - A ViewItem's `config`: FROM `unknown` TO the arm's own config type, a `ListView` config on the `list` arm and a `FormView` config on the `form` arm. This holds on `ViewItem`, `ViewItemWire`, `defineViewItem`'s parameter and return, and the `viewItem` member of `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`. The arm builder took `config` as `z.ZodTypeAny`; it is now a generic parameter. - A flattened overlay member's `viewKind`: FROM `'list' | 'form'` on both members TO `'list'` on the list overlay and `'form'` on the form overlay, the one value each member accepts. A list-shaped body naming `viewKind: 'form'` used to type-check, through the list overlay member, as `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`. +- A flattened list overlay's `type` and `columns`: FROM `unknown` TO the list view's own types, both optional: `type` one of the list view types, `columns` a field list. This holds on the list overlay member of `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`. The member read both keys off the list view shape through a cast that erased them, so `{ object, viewKind: 'list', columns: 42 }` type-checked as all four while that member refuses it. -**If your code stops compiling.** A value you annotated with one of these names, or passed to `defineReport` / `defineViewItem`, is not the shape the door accepts: correct it, or type a value that is still unvalidated as `unknown` and let the schema's `safeParse` decide. A ViewItem's `config` must match its `viewKind`: a `ListView` config under `viewKind: 'list'`, a `FormView` config under `viewKind: 'form'`. +**If your code stops compiling.** A value you annotated with one of these names, or passed to `defineReport` / `defineViewItem`, is not the shape the door accepts: correct it, or type a value that is still unvalidated as `unknown` and let the schema's `safeParse` decide. A ViewItem's `config` must match its `viewKind`: a `ListView` config under `viewKind: 'list'`, a `FormView` config under `viewKind: 'form'`. A flattened list overlay's `columns` is a field list and its `type` one of the list view types. The declared types of `JoinedReportBlockSchema`, `ViewItemSchema` and `ViewItemWireSchema` narrow with them, so `z.input` / `z.infer` of each is typed where it was `unknown` (or carried an `unknown` `config`). Typed, each schema's input and output now differ by its defaults, so three ADR-0122 parsed-state aliases are added beside the bare names: `JoinedReportBlockParsed`, `ViewItemParsed` and `ViewItemWireParsed`. Nothing is removed or renamed. diff --git a/packages/spec/src/ui/assembled-views.zod.ts b/packages/spec/src/ui/assembled-views.zod.ts index e35cd06702a..ab34b888daa 100644 --- a/packages/spec/src/ui/assembled-views.zod.ts +++ b/packages/spec/src/ui/assembled-views.zod.ts @@ -122,9 +122,9 @@ export type AssembledViewArtifact = z.input<(typeof VIEW_METADATA_MEMBERS)[Exclu * [#19920] One default is applied by the parse but absent from this type, the one * `ViewMetadataParsed` (`view.zod.ts`) names: the flattened list overlay member declares `type` * without the list shape's `.default('grid')` and re-applies it in an `.overwrite()`, which - * returns the member's own output type. So on that member `type` stays optional here (and, like - * its `columns`, typed `unknown`), while every body it parses comes back with `type` set: - * `'grid'` when the body named none. + * returns the member's own output type. So on that member `type` stays optional here (typed as + * the list shape's `type` enum), while every body it parses comes back with `type` set: `'grid'` + * when the body named none. */ export type AssembledViewArtifactParsed = z.infer<(typeof VIEW_METADATA_MEMBERS)[Exclude]>; diff --git a/packages/spec/src/ui/view-overlay-viewkind-type.test.ts b/packages/spec/src/ui/view-overlay-viewkind-type.test.ts index e719f41d352..bf04a8f90e3 100644 --- a/packages/spec/src/ui/view-overlay-viewkind-type.test.ts +++ b/packages/spec/src/ui/view-overlay-viewkind-type.test.ts @@ -2,8 +2,9 @@ /** * [#19920] Each flattened overlay member's static `viewKind` is its own arm's literal: `'list'` on - * the list overlay, `'form'` on the form overlay. And the list overlay's `type` default, which the - * parse applies and the output type does not carry, is what the `ViewMetadataParsed` / + * the list overlay, `'form'` on the form overlay. The list overlay's `type` and `columns` carry the + * list shape's own types, not `unknown`. And the list overlay's `type` default, which the parse + * applies and the output type does not carry, is what the `ViewMetadataParsed` / * `AssembledViewArtifactParsed` TSDoc says it is. * * `flattenedViewOverlayFields(kind)` took `kind: 'list' | 'form'`, so `z.enum([kind])` widened to @@ -12,6 +13,11 @@ * `AssembledViewArtifactParsed`, while both doors refuse it (the list member: the arm mismatch; the * form member: `type` and `columns`). The function is now generic, so each member keeps its literal. * + * `listOverlayPatchFields()` read the list overlay's `type` and `columns` off the list shape + * through a cast to a record of `z.ZodTypeAny`, which typed both `unknown` on that member: + * `{ object, viewKind: 'list', columns: 42 }` type-checked as the same four union types while the + * list member refuses it. It now reads the shape as typed. + * * Two halves, judged by two programs (the `view-metadata-type.test.ts` shape): * * - The TYPE half is judged by `tsc -p tsconfig.test.json` (the package's `typecheck` script, via @@ -70,17 +76,38 @@ const parsedMetadata: ViewMetadataParsed = { type: 'grid', columns: ['name'], ob void [listKindIn, listKindOut, formKindIn, formKindOut, listKindInForm, listKindOutForm, formKindInList, formKindOutList]; void [artifact, parsedArtifact, metadata, parsedMetadata]; +// ── The list overlay's `type` and `columns` carry the list shape's types ───────────────────── + +type IsUnknown = unknown extends T ? true : false; +const typeAndColumnsAreTypedThere: [ + IsUnknown, IsUnknown, + IsUnknown, IsUnknown, +] = [false, false, false, false]; +const listOverlayPatchWithType: ListOverlayIn = { object: 'crm_lead', viewKind: 'list', type: 'kanban', columns: ['name'] }; +// @ts-expect-error -- the list overlay's `columns` is a field list, not a number. +const listOverlayColumnsNumber: ListOverlayIn['columns'] = 42; +// @ts-expect-error -- its `type` is the list shape's enum. +const listOverlayTypeUnknown: ListOverlayIn['type'] = 'spreadsheet'; +// @ts-expect-error -- a list overlay whose `columns` is a number is no view artifact. +const numericColumnsArtifact: AssembledViewArtifact = { object: 'crm_lead', viewKind: 'list', columns: 42 }; +// @ts-expect-error -- nor a parsed one. +const numericColumnsParsedArtifact: AssembledViewArtifactParsed = { object: 'crm_lead', viewKind: 'list', columns: 42 }; +// @ts-expect-error -- nor a view body. +const numericColumnsMetadata: ViewMetadata = { object: 'crm_lead', viewKind: 'list', columns: 42 }; +// @ts-expect-error -- nor a parsed one. +const numericColumnsParsedMetadata: ViewMetadataParsed = { object: 'crm_lead', viewKind: 'list', columns: 42 }; +void [typeAndColumnsAreTypedThere, listOverlayPatchWithType, listOverlayColumnsNumber, listOverlayTypeUnknown]; +void [numericColumnsArtifact, numericColumnsParsedArtifact, numericColumnsMetadata, numericColumnsParsedMetadata]; + // ── The list overlay's `type` default: applied by the parse, absent from the output type ───── // -// The TSDoc on `ViewMetadataParsed` / `AssembledViewArtifactParsed` says both things in words, and -// these two lines compile only while each one holds. The day a change carries the `.overwrite()` -// default into the output type, or types the member's `type` / `columns`, a line stops compiling -// and the TSDoc sentences are then false: correct them with it. +// The TSDoc on `ViewMetadataParsed` / `AssembledViewArtifactParsed` says this in words, and this +// line compiles only while it holds. The day a change carries the `.overwrite()` default into the +// output type, the line stops compiling and the TSDoc sentences are then false: correct them with +// it. type IsOptionalKey = {} extends Pick ? true : false; -type IsUnknown = unknown extends T ? true : false; const typeIsOptionalOnListOverlayOutput: IsOptionalKey = true; -const typeAndColumnsAreUnknownThere: [IsUnknown, IsUnknown] = [true, true]; -void [typeIsOptionalOnListOverlayOutput, typeAndColumnsAreUnknownThere]; +void [typeIsOptionalOnListOverlayOutput]; describe('[#19920] the flattened overlay members keep their own viewKind literal', () => { it('the list-shaped `viewKind: "form"` body is refused by every door that judges it', () => { @@ -91,6 +118,13 @@ describe('[#19920] the flattened overlay members keep their own viewKind literal expect(AssembledViewArtifactSchema.safeParse(body).success).toBe(false); }); + it('a list overlay whose `columns` is a number is refused by every door that judges it', () => { + const body = { object: 'crm_lead', viewKind: 'list', columns: 42 }; + expect(VIEW_METADATA_MEMBERS.listOverlay.safeParse(body).success).toBe(false); + expect(ViewMetadataSchema.safeParse(body).success).toBe(false); + expect(AssembledViewArtifactSchema.safeParse(body).success).toBe(false); + }); + it("a column-less list patch parses with `type: 'grid'`, which its output type leaves optional", () => { const patch = { object: 'crm_lead', viewKind: 'list', sort: [{ field: 'name', order: 'asc' }] }; for (const door of [VIEW_METADATA_MEMBERS.listOverlay, ViewMetadataSchema, AssembledViewArtifactSchema]) { diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index e2d9eb54457..d9ae60ae74b 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -5779,13 +5779,17 @@ const FORM_OVERLAY_COLUMNS_IS_A_COUNT = * {@link assertViewIdentity} records for the union's own door. */ function listOverlayPatchFields() { - const shape = (ListViewShapeSchema as unknown as { shape: Record }).shape; - const type = shape.type as unknown as z.ZodDefault; + // [#19920] Read off the shape as typed, not through a cast to a record of + // `z.ZodTypeAny`: that cast erased both keys to `unknown` on this member, so + // `{ object, viewKind: 'list', columns: 42 }` type-checked as every union + // type read off {@link VIEW_METADATA_MEMBERS} while this member refuses it. + // The schemas are the same objects either way; only their static types move. + const shape = ListViewShapeSchema.shape; return { - columns: shape.columns!.optional(), + columns: shape.columns.optional(), // `.meta({ default })` keeps the served JSON Schema's `default: 'grid'` // byte-identical: the default is real, applied by the overwrite below. - type: type.unwrap().optional().meta({ default: LIST_OVERLAY_DEFAULT_TYPE }), + type: shape.type.unwrap().optional().meta({ default: LIST_OVERLAY_DEFAULT_TYPE }), }; } @@ -6791,10 +6795,9 @@ export type ViewMetadata = z.input<(typeof VIEW_METADATA_MEMBERS)[ViewMetadataBr * overlay member (`VIEW_METADATA_MEMBERS.listOverlay`) declares `type` without the list shape's * `.default('grid')`, so its checks can tell a column-less patch from a full config, and * re-applies the default in `.overwrite(applyListOverlayTypeDefault)`. An `.overwrite()` returns - * the member's own output type, so on that member `type` stays optional here, while every body - * that member parses comes back with `type` set: `'grid'` when the body named none. On that - * member `type` and `columns` are also typed `unknown`: `listOverlayPatchFields` reads both off - * the list shape through an untyped cast. + * the member's own output type, so on that member `type` stays optional here (typed as the list + * shape's `type` enum), while every body that member parses comes back with `type` set: `'grid'` + * when the body named none. */ export type ViewMetadataParsed = z.infer<(typeof VIEW_METADATA_MEMBERS)[ViewMetadataBranch]>; export type ViewScope = z.input;