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..f610973b9d6 --- /dev/null +++ b/.changeset/19920-exported-types-remainder.md @@ -0,0 +1,26 @@ +--- +'@objectstack/spec': minor +--- + +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. + +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'`. 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. + +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/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/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index f51e1475899..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 @@ -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 > >>; @@ -1658,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 @@ -2305,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/assembled-views.zod.ts b/packages/spec/src/ui/assembled-views.zod.ts index 2ad85131f62..ab34b888daa 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 (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/joined-report-block-type.test.ts b/packages/spec/src/ui/joined-report-block-type.test.ts new file mode 100644 index 00000000000..5efe1e7f6f1 --- /dev/null +++ b/packages/spec/src/ui/joined-report-block-type.test.ts @@ -0,0 +1,82 @@ +// 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 JoinedReportBlockParsed, + 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 JoinedReportBlockParsed', () => { + const parsed: ReportParsed = ReportSchema.parse(joined); + 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 007fe3e539e..bd8f519206c 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,7 +550,19 @@ 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; +/** 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 new file mode 100644 index 00000000000..78af633427b --- /dev/null +++ b/packages/spec/src/ui/view-item-config-type.test.ts @@ -0,0 +1,112 @@ +// 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 ViewItemParsed, + 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 }; +// 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 }); +// @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: ViewItemParsed = 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..bf04a8f90e3 --- /dev/null +++ b/packages/spec/src/ui/view-overlay-viewkind-type.test.ts @@ -0,0 +1,136 @@ +// 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. 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 + * 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. + * + * `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 + * `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` 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 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; +const typeIsOptionalOnListOverlayOutput: IsOptionalKey = true; +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', () => { + 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 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]) { + 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 335037c4dbd..d9ae60ae74b 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -4931,8 +4931,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, @@ -4940,6 +4947,30 @@ function viewItemArmShape(viewKind: K, config: z.ZodT }; } +/** + * [#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 @@ -5032,7 +5063,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.'))), @@ -5080,7 +5114,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.')), @@ -5291,8 +5328,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 @@ -5736,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 }), }; } @@ -5916,7 +5963,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, @@ -6703,8 +6753,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 @@ -6736,6 +6790,14 @@ 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 (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; 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,