Skip to content

Commit 681868c

Browse files
fix(spec): JoinedReportBlock, a ViewItem config and the overlay viewKind carry the shapes their doors accept (#19920) (#20369)
Part of #19920 Clause-②: yes (narrowing) This PR takes the four remainder items that seat 1's release on #19920 (comment 5858219255) lists, plus remainder 5 (the flattened list overlay's `type` and `columns`), on `main` after PR #20286. #19920 remains open because this round's census and probes found three more sites of the same family, outside this PR's surface. See "What stays on #19920" below. ## What changed Only types change. The runtime accept set does not move: no schema's parse, no value, and no removed or renamed export. The FROM column was probed on the base `c5dcb3ba0`, both against the source (compiler API, `tsconfig.test.json` options) and against the built `dist`. | item | FROM | TO | |:--|:--|:--| | 1. `JoinedReportBlock` (`ui/report.zod.ts`) | `unknown`, because `JoinedReportBlockSchema` was annotated `z.ZodTypeAny`. Each `blocks[]` element of `Report` / `ReportParsed` (and so of `defineReport`'s parameter) was `unknown` for the same reason. | The schema's own input type: the annotation is removed. `blocks[]` elements are blocks. | | 2. A ViewItem's `config` (`ui/view.zod.ts`) | `unknown` on both arms of `ViewItem` and `ViewItemWire`, because `viewItemArmShape(viewKind, config)` typed `config` as `z.ZodTypeAny`. The same `unknown` reached `defineViewItem` and the `viewItem` member of `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`. | The arm's config type: a `ListView` config under `viewKind: 'list'`, a `FormView` config under `viewKind: 'form'`. `config` is a generic parameter now, beside `viewKind`. | | 3. The flattened overlay members' `viewKind` | The union of `'list'` and `'form'` on both members, because `flattenedViewOverlayFields(kind)` was not generic, so `z.enum([kind])` widened. `{ type: 'grid', columns: ['name'], object, viewKind: 'form' }` type-checked, through the list member, as all four union types above, while both doors refuse it. | `'list'` on the list overlay member, `'form'` on the form overlay member: the function is generic in the kind. | | 4. The list overlay's `type` default | Not named anywhere. | One TSDoc paragraph on `ViewMetadataParsed`, and the same on `AssembledViewArtifactParsed`. It says, from the code, that the member declares `type` without `.default('grid')` and re-applies it in `.overwrite(applyListOverlayTypeDefault)`. An `.overwrite()` returns the member's own output type, so `type` stays optional in the output type while every parse through that member returns it (`'grid'` when the body named none). On that member `type` is typed as the list shape's `type` enum. | | 5. The flattened list overlay's `type` and `columns` (`ui/view.zod.ts`) | `unknown` on the list overlay member of `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`, on input and on output, because `listOverlayPatchFields()` read both keys off `ListViewShapeSchema` through a cast of its shape to a record of `z.ZodTypeAny`. `{ object, viewKind: 'list', columns: 42 }` type-checked as all four while that member refuses it. | The list shape's own types, both optional: `type` the list view type enum, `columns` the field list. The shape is read as typed; the schemas are the same objects, so no parse moves. | ### Changes beyond the five items, each forced, with what forced it 1. **Declaration size (item 2).** With only the generic parameter, `view.zod.d.ts` grows by +170,655 B (500,535 to 671,190, +34%), with 0 TS7056. The cause is that `ViewItemSchema`, `ViewItemWireSchema` and `VIEW_METADATA_MEMBERS.viewItem` each spell both config types out in full, at 58,764 B, 59,326 B and +60,108 B. - **What this PR does instead.** Both schemas carry an explicit type annotation through two non-exported aliases read off `viewItemArmShape` itself. `ViewItemArmShape` is the ReturnType of `typeof viewItemArmShape`, instantiated. `ViewItemWireArmShape` is that plus `viewItemWireFields()`, written as one mapped type. The `viewItem` entry of `VIEW_METADATA_MEMBERS` is spelled `ViewItemWireSchema as typeof ViewItemWireSchema`, an assertion to the schema's own type, so the emitter names the schema instead of copying it. - **Result.** `view.zod.d.ts` is 496,008 B, 4,527 B smaller than on the base. - **Identity proof.** Measured with an `Equal` probe (the mutual-conditional form) against in-memory inferred twins of both schemas. It holds for each schema, for its z.input and z.output, and for the member. A control pair answers false. - **A spelling I measured and did not take.** A first spelling of the wire annotation used an intersection. It was assignable but not identical (Equal false), so it was replaced by the mapped form. - **The deviation.** No TS error forced this assertion. Declaration size forced it. So it deviates from the dispatch's mechanism assumption 2, which allowed assertions only where a TS error forces them. 2. **ADR-0122 (items 1 and 2).** Three isomorphism pins in `type-alias-convention.pin.test.ts` turned into TS2344 ×3: `Iso_ui_report__JoinedReportBlockSchema`, `Iso_ui_view__ViewItemSchema` and `Iso_ui_view__ViewItemWireSchema`. They had held only because input and infer were the same erased `unknown`; typed, each schema carries defaults. The pins are deleted. Per `check:spec-parsed-alias` rule 2, three parsed-state aliases are added: `JoinedReportBlockParsed`, `ViewItemParsed` and `ViewItemWireParsed`. These are three new type exports on the `ui` entry; `api-surface/ui.json` and `export-origins/ui.json` were regenerated. The pin count is restated from 786 to 783 in the file's two prose places and its count history. 3. **`test-typecheck-debt.json`.** The `report.test.ts` entry shrinks by two signatures (TS18046 "'b' is of type 'unknown'" and TS2571), because `blocks[]` is typed now. It was regenerated with `gen:test-typecheck-debt`. 4. **A pending release note corrected on purpose.** See the next section. ## Confirmation needed: a pending release note is corrected on purpose (`Check Changeset` stays red) - **The note.** `.changeset/19920-exported-types-not-unknown.md` is PR #20260's pending entry for this card. It ended "`JoinedReportBlock` is not changed by this change, and still resolves to `unknown`." - **What this PR changes under it.** Item 1 makes the second half of that sentence false in any release that carries both entries. - **The rewrite.** The sentence now reads "`JoinedReportBlock` is not changed by this change. It stops resolving to `unknown` in its own entry (#19920)." That holds whichever release carries either entry. Nothing else in the note moves. - **What the gate says.** `node scripts/check-empty-changeset.mjs --base origin/main` exits 1, naming exactly this file, in its DELIBERATE CORRECTION class. Following the gate's prescription, the file is not restored from the base, which would put the false sentence back. - **What happens next.** It stays red until someone confirms the correction here. If a release consumes that entry before this PR lands, the merge keeps `main`'s deletion and the correction is moot. The new entry is `.changeset/19920-exported-types-remainder.md`: `@objectstack/spec` `minor`, `Clause-②: yes (narrowing)` (the diff narrows published types and also adds three exported type names, per the at-tier record 5863387565), a BREAKING banner naming every type that narrows, FROM and TO per item (four bullets, remainder 5 included), a banner and ADR-0087 reason saying that no EXISTING export changes, and the ADR-0087 marker `not-required (no-migration-prescription)`. ## Measurements - **TS7056**: 0 in every spec build of this round: origin/main `5049a3cf`, the pre-remainder-5 head `406335af` and the head `777b5097`. - **Remainder 5's cost** (`406335af` to `777b5097`): the `view.zod` chunk grows 495,664 to 498,393 B (+2,729, the same for `.d.mts`); the `page.zod` chunk moves -3 B; every other declaration file is byte-identical; all 128 files +5,452 B. - **The whole PR** (`5049a3cf` to `777b5097`): | module | base | head | delta | |:--|--:|--:|--:| | `view.zod` chunk (`.d.ts`, and the same for `.d.mts`) | 500,191 | 498,393 | -1,798 | | `page.zod` chunk (report and assembled views) | 306,012 | 311,003 | +4,991 | | `index.d.ts` | 3,277,798 | 3,283,480 | +5,682 | | `system/index.d.ts` | 3,644,976 | 3,647,050 | +2,074 | | `ui/index.d.ts` | 524,900 | 524,978 | +78 | | all 128 `.d.ts` / `.d.mts` files | 30,888,091 | 30,910,145 | +22,054 (+0.071%) | - Item 1's in-memory emit figures (+3,542 B of `report.zod`) are the previous run's and were not re-measured; the chunk table above supersedes them. ## Reverse verification (on disk, from committed state, via `scripts/ablation-replace.mjs`) All five legs re-run at `777b5097`, from committed state, through `scripts/ablation-replace.mjs` in wrap mode; each pin file compiled under `tsconfig.test.json`'s options: | leg | reverted to | pin file | result | |:--|:--|:--|:--| | item 1 | `JoinedReportBlockSchema: z.ZodTypeAny` | `joined-report-block-type.test.ts` | 7 x TS2578, 2 x TS18046 | | item 2 | `config: z.ZodTypeAny` in `viewItemArmShape` | `view-item-config-type.test.ts` | 8 x TS2578, 1 x TS18046 | | item 3 | non-generic `flattenedViewOverlayFields` | `view-overlay-viewkind-type.test.ts` | 11 x TS2578 | | item 4 | the list overlay's `type` keeping the list default | `view-overlay-viewkind-type.test.ts` | 1 x TS2322 | | remainder 5 | the cast of the shape to a record of `z.ZodTypeAny` | `view-overlay-viewkind-type.test.ts` | 6 x TS2578, 4 x TS2322 | Every leg: the tool reports the mutation landed and the restore proven (blob after restore equals the HEAD blob, `git diff HEAD` empty), and a second check re-reads `git hash-object`. Control, no mutation: the three pin files compile with 0 diagnostics; `git status --porcelain` empty. ## Tests and gates, at `777b5097` At `777b5097`: spec build exit 0 (TS7056 x0); spec typecheck exit 0 (check:test-typecheck OK, 53 files / 253 errors / 140 signatures); spec check:generated exit 0; spec vitest local 560 files, 16,493 passed, 1 todo; spec test:repo 34 files, 620 passed; consumer typechecks exit 0 for lint, metadata, metadata-protocol and objectql after building their 12-package closure (rest NOT MEASURED locally, 26-package closure). dispatch-gates derived 86: 83 exit 0, check-empty-changeset exit 1 (the deliberate correction), 2 NOT MEASURED (check:dual-build-cjs-loads, check:type-check-debt, exit 3); `--ran`: 86 derived, 84 run, 2 NOT-MEASURED, 0 UNRUN. Lint narrowed and proven: 7 changed `.ts` files, 0 errors, 0 warnings; the 5 other changed files are outside eslint's configuration; no type-aware linting (`eslint.config.mjs`:327). ## Consumer census - **This repo.** Outside `packages/spec`, the code that imports any narrowed name is: - `defineReport` in the two example report files above; - `Report` in `qa/downstream-contract/src/pipeline.report.ts`; - `ReportSchema` in one platform-objects test and one downstream-contract test; - `ViewMetadataSchema` in two metadata-protocol tests; - `AssembledViewArtifactSchema` in objectql's `engine.ts` and one objectql test (its own static type does not move). - Nothing outside spec names `viewItemArmShape` or `flattenedViewOverlayFields`, which are not exported. All consumer typechecks above are green. - **objectui at the pin `f8a9d0fb`: one break, by design, for the seat to carry.** `packages/types/src/__tests__/report-chart-query-spec-parity.test.ts`:725 is the inverted tripwire `true satisfies IsUnknown` of the spec's `JoinedReportBlock`. Compiled with that file's own two type definitions against this head's dist, it gives TS1360 ("Type 'true' does not satisfy the expected type 'false'"); line 726 (`IsAny`) still holds. - Its docblock says this failure is the instruction to re-run the triage and burn the local interface down, together with the ledger entry at `scripts/check-spec-symbol-derivation.mjs`:636. - It reds objectui's own `type-check` (`tsc -p tsconfig.test.json`) on its next spec bump. It does not red this repo's Console Pin Gate, which builds objectui without `__tests__`. - **objectui's other hits.** Five other objectui files reference `ViewItemSchema` / `ViewMetadataSchema` in code; every other census hit is a comment or objectui's own local `JoinedReportBlock` interface. From reading them, not from compiling them: they read `.success` / `.error` of a `safeParse`, or cast the schema `as unknown as` a local interface. None reads a typed `config` off a parse result. - **Remainder 5 at objectui's pin `f8a9d0fb`.** Six calls in four test files parse a body with `ViewMetadataSchema`, and none reads `.data` off the verdict, so the now-typed list-overlay `type` / `columns` reach no objectui read (from reading, not compiling). ## What stays on #19920 A compiler-API census of the 2,337 non-generic exported aliases of `packages/spec/src`, excluding tests, with a control that must read lit: 6 aliases resolve to `unknown` before and after this PR, and none belongs to this family (`FlowValueSlot`, `AssignmentValue` and their `Parsed`, value slots; `GetPublishedMetaItemResponse` and its `Parsed`, opaque by ruling). At top-level-key level it counts 210 keys typed `unknown` before remainder 5 and 202 after; the 8 that left are remainder 5. Three sites of the family remain, outside this PR's surface: - `ApiError.code` (input): `ErrorCode` is cast to a one-argument `z.ZodType`, whose input defaults to `unknown`, and `makeApiErrorSchema` repeats the cast. `{ code: 42, message: 'x' }` compiles as `ApiError` while `ApiErrorSchema` refuses it at `code`. - `ViewFilterRule.operator` (input): a `z.preprocess`, whose input is `unknown` (the cause `InlineAction` had). `{ field: 'status', operator: 42 }` compiles while the door refuses it. - The list overlay's `options` bag: `listViewKindBlocks()` returns a record of `z.ZodTypeAny`, so the bag is typed as a string-keyed record of `unknown`. `options: { foo: 1, kanban: 42 }` compiles as `ViewMetadata` while the door refuses it. The census does not scan nested keys; a probe found this one. ## Acceptance notes - **The pin count holds at 783** (`check:spec-parsed-alias`: 1,442 bare `z.input` aliases, 783 pinned isomorphic, 659 paired). - **objectui tripwire, carried by the seat.** `packages/types/src/__tests__/report-chart-query-spec-parity.test.ts` at objectui pin `f8a9d0fb` asserts that `JoinedReportBlock` is `unknown` (line 725). It goes red at the next objectui spec-pin bump, and its docblock says that failure is the instruction to re-run the triage and burn down the local interface. Carrier: the next objectui spec-pin bump. It cannot red this repo: the Console Pin Gate builds objectui without its `__tests__`. - **Fence held.** `FormViewSchema.layout` (#20221), `ListViewShapeSchema.tabs` and the view container's body `name` (#20301) are untouched. `origin/main` `5049a3cf` was merged once, at `1161545d`, with no conflict; commits that landed on `main` later touch none of this PR's files. --- _Generated by [Claude Code](https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2f122b6 commit 681868c

12 files changed

Lines changed: 482 additions & 20 deletions

‎.changeset/19920-exported-types-not-unknown.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,6 @@ Four published type aliases were derived from a schema whose own static type era
1818

1919
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.
2020

21-
`JoinedReportBlock` is not changed by this change, and still resolves to `unknown`.
21+
`JoinedReportBlock` is not changed by this change. It stops resolving to `unknown` in its own entry (#19920).
2222

2323
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author writes moves — no spec key, no export and no stored row changes and every runtime accept set is unchanged, so `objectstack migrate meta` has nothing to reach — and only TypeScript annotations narrow, whose channel is the consumer's compiler. -->
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
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)
6+
7+
Clause-②: yes (narrowing)
8+
9+
**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.
10+
11+
Four places in the published types were wider than the doors that judge the same bodies, so values those doors refuse type-checked:
12+
13+
- `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.
14+
- 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.
15+
- 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`.
16+
- 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.
17+
18+
**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.
19+
20+
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.
21+
22+
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.
23+
24+
The types are the members' declared shapes, not the schemas' verdicts: refinements are not types, so each schema remains the only judge.
25+
26+
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author writes moves — no spec key, no existing export and no stored row changes (three parsed-state type names are added, none removed or renamed) and every runtime accept set is unchanged, so `objectstack migrate meta` has nothing to reach — and only TypeScript annotations narrow, whose channel is the consumer's compiler. -->

‎packages/spec/api-surface/ui.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@
216216
"InterfacePageConfigParsed (type)",
217217
"InterfacePageConfigSchema (const)",
218218
"JoinedReportBlock (type)",
219+
"JoinedReportBlockParsed (type)",
219220
"JoinedReportBlockSchema (const)",
220221
"KNOWN_COMPONENT_TYPES (const)",
221222
"KNOWN_COMPONENT_TYPE_CANDIDATES (const)",
@@ -420,8 +421,10 @@
420421
"ViewItem (type)",
421422
"ViewItemName (type)",
422423
"ViewItemNameSchema (const)",
424+
"ViewItemParsed (type)",
423425
"ViewItemSchema (const)",
424426
"ViewItemWire (type)",
427+
"ViewItemWireParsed (type)",
425428
"ViewItemWireSchema (const)",
426429
"ViewKeyCollision (interface)",
427430
"ViewKind (type)",

‎packages/spec/export-origins/ui.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@
212212
"InterfacePageConfigParsed": "src/ui/page.zod.ts#InterfacePageConfigParsed (type)",
213213
"InterfacePageConfigSchema": "src/ui/page.zod.ts#InterfacePageConfigSchema (const)",
214214
"JoinedReportBlock": "src/ui/report.zod.ts#JoinedReportBlock (type)",
215+
"JoinedReportBlockParsed": "src/ui/report.zod.ts#JoinedReportBlockParsed (type)",
215216
"JoinedReportBlockSchema": "src/ui/report.zod.ts#JoinedReportBlockSchema (const)",
216217
"KNOWN_COMPONENT_TYPES": "src/ui/component-type-vocabulary.ts#KNOWN_COMPONENT_TYPES (const)",
217218
"KNOWN_COMPONENT_TYPE_CANDIDATES": "src/ui/component-type-vocabulary.ts#KNOWN_COMPONENT_TYPE_CANDIDATES (const)",
@@ -406,8 +407,10 @@
406407
"ViewItem": "src/ui/view.zod.ts#ViewItem (type)",
407408
"ViewItemName": "src/ui/view.zod.ts#ViewItemName (type)",
408409
"ViewItemNameSchema": "src/ui/view.zod.ts#ViewItemNameSchema (const)",
410+
"ViewItemParsed": "src/ui/view.zod.ts#ViewItemParsed (type)",
409411
"ViewItemSchema": "src/ui/view.zod.ts#ViewItemSchema (const)",
410412
"ViewItemWire": "src/ui/view.zod.ts#ViewItemWire (type)",
413+
"ViewItemWireParsed": "src/ui/view.zod.ts#ViewItemWireParsed (type)",
411414
"ViewItemWireSchema": "src/ui/view.zod.ts#ViewItemWireSchema (const)",
412415
"ViewKeyCollision": "src/ui/view.zod.ts#ViewKeyCollision (interface)",
413416
"ViewKind": "src/ui/view.zod.ts#ViewKind (type)",

‎packages/spec/src/type-alias-convention.pin.test.ts‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ import type * as M187 from './shared/duration.zod.js';
275275
import type * as M188 from './ai/build-progress.zod.js';
276276

277277
// ---------------------------------------------------------------------------
278-
// 786 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared.
278+
// 783 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared.
279279
//
280280
// That number is machine-checked, not hand-kept. The runtime companion at the
281281
// 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<Eq< z.input< typeof M163.Pag
15511551
export type Iso_ui_page__PageTypeSchema = Assert<Eq< z.input< typeof M163.PageTypeSchema >, z.infer< typeof M163.PageTypeSchema > >>;
15521552

15531553
// ui/report.zod.ts
1554-
export type Iso_ui_report__JoinedReportBlockSchema = Assert<Eq< z.input< typeof M164.JoinedReportBlockSchema >, z.infer< typeof M164.JoinedReportBlockSchema > >>;
1554+
// `JoinedReportBlockSchema` left the family on #19920: its `z.ZodTypeAny`
1555+
// annotation made input and infer the same `unknown`, and with the annotation
1556+
// gone its `type` default makes them differ, so `JoinedReportBlockParsed` is
1557+
// declared and the pin deleted.
15551558
export type Iso_ui_report__ReportType = Assert<Eq< z.input< typeof M164.ReportType >, z.infer< typeof M164.ReportType > >>;
15561559

15571560
// ui/responsive.zod.ts
@@ -1566,6 +1569,10 @@ export type Iso_ui_responsive__StyleMapSchema = Assert<Eq< z.input< typeof M165.
15661569
// ui/theme.zod.ts — its five pins (Iso700–Iso704) left with the module at #10485.
15671570

15681571
// ui/view.zod.ts
1572+
// `ViewItemSchema` and `ViewItemWireSchema` left the family on #19920: their
1573+
// `config` was `z.ZodTypeAny` (input and infer the same `unknown`), and typed by
1574+
// its arm it carries the list and form configs' defaults, so `ViewItemParsed` and
1575+
// `ViewItemWireParsed` are declared and both pins deleted.
15691576
// `KanbanConfigSchema` left this list as Iso829 when #17393 gave it a `limit`
15701577
// with an APPLIED default, and returns as Iso_ui_view__KanbanConfigSchema now
15711578
// that #19228 removed that member before it was published: the two shapes
@@ -1584,8 +1591,6 @@ export type Iso_ui_view__RowHeightSchema = Assert<Eq< z.input< typeof M167.RowHe
15841591
export type Iso_ui_view__TreeConfigSchema = Assert<Eq< z.input< typeof M167.TreeConfigSchema >, z.infer< typeof M167.TreeConfigSchema > >>;
15851592
export type Iso_ui_view__UserFilterFieldSchema = Assert<Eq< z.input< typeof M167.UserFilterFieldSchema >, z.infer< typeof M167.UserFilterFieldSchema > >>;
15861593
export type Iso_ui_view__ViewItemNameSchema = Assert<Eq< z.input< typeof M167.ViewItemNameSchema >, z.infer< typeof M167.ViewItemNameSchema > >>;
1587-
export type Iso_ui_view__ViewItemSchema = Assert<Eq< z.input< typeof M167.ViewItemSchema >, z.infer< typeof M167.ViewItemSchema > >>;
1588-
export type Iso_ui_view__ViewItemWireSchema = Assert<Eq< z.input< typeof M167.ViewItemWireSchema >, z.infer< typeof M167.ViewItemWireSchema > >>;
15891594
export type Iso_ui_view__ViewKindSchema = Assert<Eq< z.input< typeof M167.ViewKindSchema >, z.infer< typeof M167.ViewKindSchema > >>;
15901595
export type Iso_ui_view__ViewScopeSchema = Assert<Eq< z.input< typeof M167.ViewScopeSchema >, z.infer< typeof M167.ViewScopeSchema > >>;
15911596
export type Iso_ui_view__VisualizationTypeSchema = Assert<Eq< z.input< typeof M167.VisualizationTypeSchema >, z.infer< typeof M167.VisualizationTypeSchema > >>;
@@ -1658,7 +1663,7 @@ describe('ADR-0122 type-alias convention', () => {
16581663
// this title and the section header above the pin list — are now asserted
16591664
// against the recomputed count below, so neither can go stale without a red
16601665
// test naming it.
1661-
it('still declares all 786 isomorphic pins', () => {
1666+
it('still declares all 783 isomorphic pins', () => {
16621667
// The truth of each pin is proved by tsc, not here — an `Assert<Eq<...>>`
16631668
// that stops holding is a compile error with the alias named. What tsc
16641669
// cannot notice is a pin that was DELETED: removing the assertion removes
@@ -2305,7 +2310,16 @@ describe('ADR-0122 type-alias convention', () => {
23052310
// touch disjoint pins (M22's three, M14's one); #17158 landed first, so
23062311
// this entry's arrow starts from its 787. The count below was re-derived
23072312
// from the merged file, not added up. -1 removed.
2308-
expect(pins).toHaveLength(786);
2313+
//
2314+
// 786 -> 783 is #19920's typing of three schemas whose static type had been
2315+
// erased, so that input and infer were the same `unknown` and the pins held
2316+
// vacuously: `JoinedReportBlockSchema` (its `z.ZodTypeAny` annotation
2317+
// removed) and `ViewItemSchema` / `ViewItemWireSchema` (their `config` typed
2318+
// by its arm). Typed, each carries defaults, so input !== infer:
2319+
// Iso_ui_report__JoinedReportBlockSchema, Iso_ui_view__ViewItemSchema and
2320+
// Iso_ui_view__ViewItemWireSchema leave, and `JoinedReportBlockParsed`,
2321+
// `ViewItemParsed` and `ViewItemWireParsed` are declared. -3 removed.
2322+
expect(pins).toHaveLength(783);
23092323

23102324
// The count is stated in PROSE twice as well — this case's title and the
23112325
// section header above the pin list — and until #6605 nothing read either

‎packages/spec/src/ui/assembled-views.zod.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,13 @@ export type AssembledViewArtifact = z.input<(typeof VIEW_METADATA_MEMBERS)[Exclu
118118
/**
119119
* Post-parse shape of {@link AssembledViewArtifact} — defaults applied, transforms run (ADR-0122):
120120
* the union of the same members' OUTPUT types, for the same reason.
121+
*
122+
* [#19920] One default is applied by the parse but absent from this type, the one
123+
* `ViewMetadataParsed` (`view.zod.ts`) names: the flattened list overlay member declares `type`
124+
* without the list shape's `.default('grid')` and re-applies it in an `.overwrite()`, which
125+
* returns the member's own output type. So on that member `type` stays optional here (typed as
126+
* the list shape's `type` enum), while every body it parses comes back with `type` set: `'grid'`
127+
* when the body named none.
121128
*/
122129
export type AssembledViewArtifactParsed = z.infer<(typeof VIEW_METADATA_MEMBERS)[Exclude<ViewMetadataBranch, 'container'>]>;
123130

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#19920] The published type `JoinedReportBlock` names one sub-report of a joined report; it is
5+
* not `unknown`, and neither is a `blocks[]` element of `Report` / `ReportParsed`.
6+
*
7+
* `JoinedReportBlockSchema` was annotated `z.ZodTypeAny`, so `z.input<typeof …>` of it WAS
8+
* `unknown`, and `ReportSchema`'s `blocks: z.array(JoinedReportBlockSchema)` was `unknown[]`: any
9+
* value type-checked as a block. The schema now carries its inferred type.
10+
*
11+
* Two halves, judged by two programs (the `view-metadata-type.test.ts` shape):
12+
*
13+
* - The TYPE half is judged by `tsc -p tsconfig.test.json` (the package's `typecheck` script, via
14+
* `check:test-typecheck`), not by vitest. Each `@ts-expect-error` below asserts that its line
15+
* does NOT compile. While the block type was `unknown` every one of them compiled, so each
16+
* directive was unused: TS2578 in a file with no `test-typecheck-debt.json` entry, which reds the
17+
* gate.
18+
* - The RUNTIME half ties the typed bodies to the doors: each one parses, and the joined report's
19+
* parsed `blocks` are values of the block's output type.
20+
*/
21+
22+
import { describe, it, expect } from 'vitest';
23+
import {
24+
JoinedReportBlockSchema,
25+
ReportSchema,
26+
type JoinedReportBlock,
27+
type JoinedReportBlockParsed,
28+
type Report,
29+
type ReportParsed,
30+
} from './report.zod';
31+
32+
type ParsedBlock = NonNullable<ReportParsed['blocks']>[number];
33+
34+
// ── Real bodies, each typed through the published names ──────────────────────────────────────
35+
36+
const openBlock: JoinedReportBlock = {
37+
name: 'open_block',
38+
label: 'Open Tasks',
39+
type: 'summary',
40+
dataset: 'task_metrics',
41+
rows: ['status'],
42+
values: ['est_hours'],
43+
order: [{ by: 'est_hours', direction: 'desc' }],
44+
};
45+
const listBlock: JoinedReportBlock = { name: 'done_block', dataset: 'task_metrics', values: ['task_count'] };
46+
const joined: Report = { name: 'task_overview', label: 'Task Overview', type: 'joined', blocks: [openBlock, listBlock] };
47+
48+
// ── What the block type refuses at compile time ──────────────────────────────────────────────
49+
50+
const someValue: unknown = JSON.parse('{"nope":1}');
51+
// @ts-expect-error -- `unknown` is not a block; it was assignable while JoinedReportBlock was `unknown`.
52+
const fromUnknown: JoinedReportBlock = someValue;
53+
// @ts-expect-error -- a block is an object.
54+
const scalar: JoinedReportBlock = 42;
55+
// @ts-expect-error -- `notABlockKey` is declared by no block (TS2353).
56+
const undeclaredKey: JoinedReportBlock = { name: 'b', dataset: 'task_metrics', notABlockKey: 1 };
57+
// @ts-expect-error -- `chart` was removed from the block (#20161); the closed shape refuses it too.
58+
const retiredChart: JoinedReportBlock = { name: 'b', dataset: 'task_metrics', chart: { type: 'bar' } };
59+
// @ts-expect-error -- `joined` is excluded from a block's type enum (no recursion).
60+
const nestedJoined: JoinedReportBlock = { name: 'b', type: 'joined' };
61+
// @ts-expect-error -- a `blocks[]` element of Report is a block, not any value.
62+
const reportWithScalarBlock: Report = { name: 'r', label: 'R', type: 'joined', blocks: [42] };
63+
// @ts-expect-error -- nor is a parsed one.
64+
const parsedScalarBlock: ParsedBlock = 42;
65+
void [fromUnknown, scalar, undeclaredKey, retiredChart, nestedJoined, reportWithScalarBlock, parsedScalarBlock];
66+
67+
describe('[#19920] JoinedReportBlock is a joined-report block, not unknown', () => {
68+
it('each block typed as JoinedReportBlock parses at the block door', () => {
69+
for (const block of [openBlock, listBlock]) {
70+
expect(JoinedReportBlockSchema.safeParse(block).success).toBe(true);
71+
}
72+
});
73+
74+
it('a joined report typed as Report parses, and its parsed blocks are JoinedReportBlockParsed', () => {
75+
const parsed: ReportParsed = ReportSchema.parse(joined);
76+
const blocks: JoinedReportBlockParsed[] = parsed.blocks ?? [];
77+
expect(blocks.map((b) => [b.name, b.type])).toEqual([
78+
['open_block', 'summary'],
79+
['done_block', 'tabular'],
80+
]);
81+
});
82+
});

‎packages/spec/src/ui/report.zod.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,15 @@ const JOINED_CONTAINER_CHART_REFUSED =
203203
* - A block is drawn as a table and has no `chart` key: #20161 removed it,
204204
* because nothing ever drew it. Writing it is refused with the upgrade
205205
* prescription (the `guidance` entry below).
206+
*
207+
* [#19920] Carries its inferred type, not a `z.ZodTypeAny` annotation. That
208+
* annotation erased the block's shape, so {@link JoinedReportBlock} and every
209+
* `blocks[]` element of {@link Report} / {@link ReportParsed} were `unknown`
210+
* and any value type-checked against them. It dodged no TS7056 (measured: none
211+
* without it); what it bought was declaration size, the block's shape being
212+
* emitted once here and once inside `ReportSchema`'s `blocks`.
206213
*/
207-
export const JoinedReportBlockSchema: z.ZodTypeAny = lazySchema(() => strictObject({
214+
export const JoinedReportBlockSchema = lazySchema(() => strictObject({
208215
surface: 'this joined report block',
209216
history:
210217
'Until this shape was closed these were dropped silently — the block still rendered, '
@@ -543,7 +550,19 @@ export const ReportSchema = lazySchema(() => strictObject({
543550
}
544551
}));
545552

553+
/**
554+
* One sub-report of a `type: 'joined'` report (input shape): the input type of
555+
* {@link JoinedReportBlockSchema}.
556+
*
557+
* [#19920] Was `unknown` while that schema was annotated `z.ZodTypeAny`.
558+
* `joined-report-block-type.test.ts` pins that `unknown`, an undeclared key and
559+
* the retired `chart` are refused here. A static type, not the schema's
560+
* verdict: the `order` check against the selected dimensions and measures is a
561+
* refinement, not a type, so `JoinedReportBlockSchema` remains the only judge.
562+
*/
546563
export type JoinedReportBlock = z.input<typeof JoinedReportBlockSchema>;
564+
/** Post-parse shape of {@link JoinedReportBlock} — defaults applied, transforms run (ADR-0122). */
565+
export type JoinedReportBlockParsed = z.infer<typeof JoinedReportBlockSchema>;
547566

548567
/**
549568
* Report Types

0 commit comments

Comments
 (0)