From b414ba6227059d5b634b7763c1ba0f1f52e56ff0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 20:42:01 +0000 Subject: [PATCH] =?UTF-8?q?feat(spec)!:=20tighten=20unknown=20keys=20on=20?= =?UTF-8?q?nine=20ui/=20shapes,=20reclassify=20two=20as=20no-door=20(#4001?= =?UTF-8?q?=20=E6=89=B9=2014)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eleven strip sites measured per the #4852 remeasure; the counts held exactly (1+1+2+2+3+2, confirmed against the ledger gate's own AST counter). Per-schema door measurement then split them nine / two. Closed with strictObject: ui/action.zod.ts ActionParamSchema.options[] ui/sharing.zod.ts SharingConfigSchema ui/report.zod.ts ReportSortSchema, JoinedReportBlockSchema ui/dataset.zod.ts DatasetDimensionSchema, DatasetMeasureSchema, .derived ui/dashboard.zod.ts DashboardWidgetSchema.compareTo (object arm), .layout Four of those are strict shells over strip children: the container was already strict and strictness does not recurse. An action param option carrying color/visibleWhen/icon/disabled parsed clean through getMetadataTypeSchema('action') and came back {label, value}. Reclassified as the fourth class (no door), NOT tightened: ui/notification.zod.ts NotificationActionSchema ui/sharing.zod.ts EmbedConfigSchema Both measured three ways with positive controls in the same run: no carrier key, unreachable in a 6860-node BFS from the 24 metadata-type roots + defineStack, zero parse outside their own tests. Injecting a synthetic carrier flipped both, so the verdict is a fact about the graph rather than a broken walker. sharing.zod.ts is the ledger's first file that splits across the class. action's option entry goes strict rather than following bulk-action's .passthrough() (#4909): measured, not inherited. That def reaches the grid verbatim with no spec door and objectui's BulkActionParam declares an explicit index signature; this path has a door that already strips and lands in the CLOSED SelectOptionMetadata. Curation anchored on named sibling contracts, not edit distance: dataset on the Cube layer this module's header names (a Cube metric's `type` IS its aggregation), report's order key on SortNodeSchema and the widget's flat sortBy/sortOrder, layout on React-Grid-Layout. Known reach limit, measured: compareTo is a union, so its prescription is produced but not delivered (zodIssuesToFields maps only top-level issues; #5014). The rejection is unaffected. Tests pin both halves separately. Out-of-scope findings filed unassigned: #5013 (ReportSchema's `filter` alias prescribes `filters`, which it also rejects, plus 5 dead entries), #5014, #5015 (ADR-0049 verdict for the two no-door shapes), #5016 (should action options carry the field-level per-option vocabulary). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- .../unknown-key-strictness-ui-batch14.md | 46 ++ content/docs/references/ui/sharing.mdx | 26 +- .../2026-07-unknown-key-strictness-ledger.md | 68 +- packages/spec/src/ui/action.zod.ts | 120 +++- packages/spec/src/ui/dashboard.zod.ts | 83 ++- packages/spec/src/ui/dataset.zod.ts | 154 ++++- packages/spec/src/ui/notification.zod.ts | 35 ++ packages/spec/src/ui/report.zod.ts | 82 ++- packages/spec/src/ui/sharing.zod.ts | 108 +++- .../spec/src/ui/strictness-batch14.test.ts | 589 ++++++++++++++++++ 10 files changed, 1277 insertions(+), 34 deletions(-) create mode 100644 .changeset/unknown-key-strictness-ui-batch14.md create mode 100644 packages/spec/src/ui/strictness-batch14.test.ts diff --git a/.changeset/unknown-key-strictness-ui-batch14.md b/.changeset/unknown-key-strictness-ui-batch14.md new file mode 100644 index 0000000000..8b4502d2a6 --- /dev/null +++ b/.changeset/unknown-key-strictness-ui-batch14.md @@ -0,0 +1,46 @@ +--- +"@objectstack/spec": major +--- + +feat(spec)!: 收紧 action param 选项、公开分享、报表排序、数据集语义层与仪表盘小组件的嵌套未知键(#4001 批 14) + +`ui/` 方向第二波。账本重测记的 11 个 strip 站点,逐个做门测量后:**9 个收紧,2 个改判**。 + +## 破坏性变更 —— 9 个形状不再静默丢弃未知键 + +| 形状 | 文件 | 之前 | +|---|---|---| +| `ActionParamSchema.options[]` | `ui/action.zod.ts` | `{ label, value }` 之外的键被剥 | +| `SharingConfigSchema` | `ui/sharing.zod.ts` | 同上 | +| `ReportSortSchema` · `JoinedReportBlockSchema` | `ui/report.zod.ts` | 同上 | +| `DatasetDimensionSchema` · `DatasetMeasureSchema` · `.derived` | `ui/dataset.zod.ts` | 同上 | +| `DashboardWidgetSchema.compareTo`(对象分支)· `.layout` | `ui/dashboard.zod.ts` | 同上 | + +**升级方式:把被拒的键改成错误信息点名的那个。** 拒绝本身就带处方 —— 它点名面、原样回显写错的键,并在可能时给出规范拼法。没有任何键被移除,也没有任何合法形状变得不合法:这些 schema 接受的键集合完全没变,变的只是「写了别的会怎样」。 + +其中四个是 **strict 外壳套 strip 子块** —— 容器早就 strict,但**严格性不递归**: + +- `ActionParamSchema` 自 #3746 起 strict,而它的 `options[]` 条目不是。实测一个带 `color` / `visibleWhen` / `icon` / `disabled` 的选项过 `getMetadataTypeSchema('action')`,出来是 `{"label":"Overload","value":"overload"}` —— 四个键在任何 renderer 看到之前就没了,报告成功。 +- `DashboardWidgetSchema` 自 ADR-0021 起 strict,而 `compareTo` 的对象分支和 `layout` 不是。 +- `DatasetSchema` / `ReportSchema` 同理,漏的正是承载语义契约的那几个子形状。 + +### 为什么 `action` 的选项走 strict,而兄弟 `bulk-action` 走 `.passthrough()` + +两边不同是**测出来的,不是照搬的**。#4909 给 bulk-action 选项条目 `.passthrough()` 的两条理由在这里都不成立:那边的 def「left as-authored」逐字到达 grid(中间没有 spec 门),且 objectui 的 `BulkActionParam` 声明了显式 `[key: string]: unknown` 兜底;这边有一道**已经在剥**的门,落点是**封闭**的 `SelectOptionMetadata` 接口。目标词汇封闭,正是「声明」胜过「容忍」的场合。action param 选项是否该讲字段级的逐选项词汇,是独立的能力问题(#5016),不在本批猜。 + +## 两个形状改判为第四类 `no door`,**不**收紧 + +`NotificationActionSchema`(`ui/notification.zod.ts`)与 `EmbedConfigSchema`(`ui/sharing.zod.ts`)**没有授权门**:没有承载键、从 24 个 metadata-type root + `defineStack` 做 BFS(6860 节点)不可达、三个仓里除自测外零 `.parse()`。收紧它们会花掉一次破坏性变更去留下「一个被精确校验的死槽位 —— 更有说服力的谎言」。ADR-0049 定去留:#5015。 + +`ui/sharing.zod.ts` 是账本第一个**一行两判**的文件:同一文件里 `SharingConfig` 是活门(`FormViewSchema.sharing` 承载,`rest-server.ts` 靠 `sharing.allowAnonymous` + `sharing.publicLink` 挂匿名表单路由,两个示例应用都在写),`EmbedConfig` 没有门。按文件下判断,无论落哪边都会错一半。 + +## 策展依据是同仓的兄弟契约,不是编辑距离 + +- **dataset** 锚在本模块自己 header 点名的 `data/analytics.zod.ts` Cube 层:Cube metric 的 `type` **就是**聚合函数,所以 `{ name: 'revenue', type: 'sum', field: 'amount' }` 过去 parse 干净、算出来是 `count`。`sql` 只给 guidance 不给别名 —— 把 `SUM(amount)` 指向吃字段路径的 `field`,是本战役自己要消灭的那种错误处方。 +- **report** 的排序键是作者会遇到的**第三种** sort 拼法(`SortNodeSchema` 的 `{field, order}`、小组件的扁平 `sortBy`/`sortOrder`、这里的 `{by, direction}`),而且映射方向相反,任何一种都推不出来。 +- **dashboard `layout`** 锚在 React-Grid-Layout:`minW`/`static`/`i` 等给逐键 guidance,而不是改名到无关的位置键上。 +- **sharing** 的别名全是 camelCase 目标,兜底系统性够不着(#4990)。`allowAnonymous` 写错拼法的后果是**表单保持私有而作者以为公开**。 + +## 已知触及面限制(实测记录,非推断) + +`compareTo` 是 union。zod 把失配 union 折叠成一条顶层 `invalid_union`(message 是裸的 `Invalid input`),分支错误挂在 `issue.errors`,而 `zodIssuesToFields` 只映射顶层 —— **处方产生了但送不到作者手上**。拒绝不受影响,这仍是 #4001 的收益。传输缺陷是 #5014,影响本战役放进 union 分支的每一条策展文案。测试对这两半**分开** pin,免得一个绿测试冒充一条没人打印的消息。 diff --git a/content/docs/references/ui/sharing.mdx b/content/docs/references/ui/sharing.mdx index 741b73478a..b6e4873884 100644 --- a/content/docs/references/ui/sharing.mdx +++ b/content/docs/references/ui/sharing.mdx @@ -9,9 +9,31 @@ description: Sharing protocol schemas Sharing & Embedding Protocol -Defines schemas for public link sharing, embed configuration, +Public-link sharing and iframe-embed configuration. The module name is -domain restrictions, and password protection for apps, pages, and forms. +plural, but the two shapes below are in **opposite** postures, and #4001 批 14 + +measured why rather than assuming a file-level verdict: + +- `SharingConfigSchema` has a **live authoring door**. `FormViewSchema.sharing` + +carries it (`view.zod.ts`), `view` is a metadata-type root, and the runtime + +really reads it: `rest-server.ts` mounts the anonymous form endpoints only + +when `sharing.allowAnonymous === true` and a `sharing.publicLink` slug + +matches. Both example apps author it (`app-showcase` `inquiry.view.ts`, + +`app-crm` `lead.view.ts`). It is `strictObject` as of #4001 批 14. + +- `EmbedConfigSchema` has **no door at all** — see its own block below. + +That split is the point. The ledger's classification question is *"who writes + +this schema's input?"*, and it is answered per SCHEMA, not per file; before + +批 14 this file's row carried one verdict for both. **Source:** `packages/spec/src/ui/sharing.zod.ts` diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 9ead54a487..c38258833f 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -20,6 +20,23 @@ One question decides the class: **who writes this schema's input?** | **authorable** | A human or AI author, into `*.object.ts` / `defineStack` config / Studio / MCP | `.strict()` + fixable error (the ratchet target) | | **wire** | Another machine: server responses, connector payloads, runtime envelopes, persisted runtime state | stay tolerant (`.strip` / `.passthrough`); strictness here turns an upstream *addition* into our parse crash | | **open** | Deliberately schemaless user data (record bodies, per-node-type `config`, React props) | stay open; a *sibling* contract validates it (e.g. a node executor's `configSchema`, #4027/#4040) | +| **no door** | **Nobody — because nothing parses it.** No carrier key on any metadata type, unreachable from the metadata-type roots, no `.parse()` outside the schema's own test | **do not tighten.** `.strict()` is a property of a PARSE; closing a shape nothing parses spends a breaking change to leave *"a precisely-validated dead slot — the more convincing lie"* (#4583). The verdict it needs is ADR-0049 enforce-or-remove (#4988, #5015) | + +The fourth class was added by 批 13 (#5003) and 批 14, and it is the one the +question above cannot reach by reading: *"who writes this schema's input?"* has no +cell for **"nobody, because there is no door"**, and a file's surface gives no hint +which it is. It has to be MEASURED — carrier key, graph reachability, call sites — +and the measurement needs a positive control in the same run, or an +`UNREACHABLE` verdict is indistinguishable from a broken walker. 批 14's run: 6860 +nodes from the 24 roots + `defineStack`, four controls (`Page` / `Action` / +`DashboardWidget` / `Webhook`) all `root-graph`, and injecting a synthetic carrier +flipped both no-door shapes to `root-graph`. + +Note the class is per SCHEMA like the others, and 批 14 found the first file that +**splits across it**: `ui/sharing.zod.ts` holds one live door (`SharingConfig`, +carried by `FormViewSchema.sharing` and read by `rest-server.ts`) and one shape +nothing in the repo so much as names (`EmbedConfig`). A file-level verdict would +have been wrong in one direction or the other, whichever way it fell. Mixed files carry both — classify per schema, not per file. A **response-side extension of an authoring schema** (e.g. `EffectiveObjectPermissionSchema`) @@ -486,20 +503,22 @@ not verdicts). | File | Sites | Class | Note / next action | |---|---|---|---| -| `action.zod.ts` | 8 | authorable | param schema strict (#3746); remaining blocks ride later steps. **9 → 8 at the #4001 re-measurement** — no schema changed: the ninth "site" was a `z.object(…)` inside a JSDoc paragraph, which the old textual counter could not tell from code | +| `action.zod.ts` | 8 | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `ActionParamSchema` was strict from #3746, but strictness does not recurse and its `options[]` entry was still strip: an option carrying `color` / `visibleWhen` / `icon` / `disabled` parsed clean through `getMetadataTypeSchema('action')` and came back `{ label, value }`. Closed with `strictObject`, NOT `.passthrough()` — the opposite call from `bulk-action.zod.ts`'s option entry two rows up, and made on measurement rather than symmetry: that def reaches the grid verbatim with no spec door in between and objectui's `BulkActionParam` declares an explicit `[key: string]: unknown`, whereas this path has a door that ALREADY strips and lands in the CLOSED `SelectOptionMetadata`. Whether this surface should carry the field-level per-option vocabulary at all is #5016. Earlier note: **9 → 8 at the #4001 re-measurement** — no schema changed; the ninth "site" was a `z.object(…)` inside a JSDoc paragraph, which the old textual counter could not tell from code **9 → 8 at the #4001 re-measurement** — no schema changed: the ninth "site" was a `z.object(…)` inside a JSDoc paragraph, which the old textual counter could not tell from code | | `view.zod.ts` | 50 | authorable | partially strict (ADR-0089); long tail of sub-blocks. `bulkActionDefs` left this file in #4457 — see the row below | | `bulk-action.zod.ts` | 3 | authorable | **strict as of #4457** — `BulkActionDefSchema` (the def itself). It was `z.array(z.record(z.string(), z.any()))` inline in `view.zod.ts`: a selection-bar button with **no shape at all**, so `opeartion` / `excution: 'aggregate'` parsed and shipped as a button that ran the default behaviour. Its two other sites are `BulkActionParamSchema` and that param's `options` entry, both deliberately **open** and both now `.passthrough()` — the param because objectui's `BulkActionParam` declares a `[key: string]: unknown` catch-all for widget config (min/max/step/format), so passthrough is the honest mirror and strictness would reject valid config (same call as `dashboard.zod.ts`'s widget `config`); the OPTION ENTRY on separate measured evidence, since its objectui type is closed and only the runtime path is open — `bulkParamToField` spreads each entry (`plugin-grid/src/components/bulkParamToField.ts:131`) into `SelectOptionMetadata` (`types/src/field-types.ts:288`), which declares and reads `color` / `icon` / `disabled` / `visibleWhen`. **This row said "both deliberately open" while only the parent was `passthrough`** — one intent, two postures, caught by the 2026-08-03 re-measure and closed by the ruling's verdict A (make the code match the prose). The lesson is the campaign's own: prose in this ledger is not a posture reading, which is why the remaining-strip map is gated and this column is not. The def also refuses the combinations the executor never reads (`patch` outside an update, `execution` outside a custom, `batchSize` on an aggregate) and a hand-written `actionDef`, which is renderer-attached | | `component.zod.ts` | 29 | authorable | **next candidate** — SDUI component defs; check React-prop open slots first (p) | | `theme.zod.ts` | 14 | authorable (p) | authored themes | | `app.zod.ts` | 18 | authorable | **strict as of #4001 PR B** — `AppSchema` + branding / area / context-selector / contribution, and the nav-item union converted to `z.discriminatedUnion('type', …)` (the union-error question, settled empirically: matched-branch-only errors, exact recursive paths, `toJSONSchema` clean). Per-target `params` stay open. PR A (#4142) tombstoned the seven audit-dead keys first | -| `dashboard.zod.ts` | 11 | authorable | partially strict | +| `dashboard.zod.ts` | 11 | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `DashboardWidgetSchema` has been strict since the ADR-0021 cutover; 批 14 closed the two NESTED holes inside it (`compareTo`'s object arm, `layout`), the same strict-shell-over-strip-children silhouette 批 13 found on `page.components[]`. `DashboardWidgetOptionsSchema` stays `passthrough` **deliberately** (renderer escape hatch) and the `responsive` tombstone (#4876) is untouched. ⚠️ `compareTo` is a UNION, so its curated prescription is produced but not delivered — `zodIssuesToFields` maps only top-level issues and a failed union collapses to a bare `Invalid input` (#5014). The REJECTION is unaffected | | `widget.zod.ts` | 9 | authorable (p) | | | `page.zod.ts` | 7 | authorable | partially strict (ADR-0089) | | `chart.zod.ts` / `i18n.zod.ts` / `responsive.zod.ts` | 7+6+4 | authorable (p) | i18n label shapes are wide-open records by design — verify. **`chart` 6 → 7 at the re-measurement** — again no schema changed: `ChartAggregateSchema` is written `z\n .object({`, and the old counter's `z\.object\(` could not match across the line break | -| `dataset.zod.ts` / `animation.zod.ts` / `dnd.zod.ts` / `keyboard.zod.ts` / `touch.zod.ts` | 4+4+4+4+7 | authorable (p) | interaction configs | -| `offline.zod.ts` / `report.zod.ts` | 3 ea | authorable (p) | | -| `notification.zod.ts` | 1 | authorable (p) | **#4610 dropped two sites** — the `./ui` `Notification` (toast/banner instance) and `NotificationConfig` (toaster global config) shapes were removed: zero importers in all three repos, and both shadowed live names owned elsewhere (`./api` owns the inbox row). What remains is `NotificationActionSchema`, part of the presentation vocabulary the ui entry keeps | -| `sharing.zod.ts` | 2 | authorable (p) | public-sharing config | +| `dataset.zod.ts` | 4 | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `DatasetSchema` was strict from the ADR-0021 cutover while the two shapes carrying the actual semantic contract — `DatasetDimension`, `DatasetMeasure` (+ `.derived`) — were not. Curated against the sibling this module's own header names, `data/analytics.zod.ts`'s Cube layer: a Cube metric's `type` IS its aggregation, so `{ name: 'revenue', type: 'sum', field: 'amount' }` parsed clean and computed a `count`; `sql` gets guidance rather than an alias, because aiming `SUM(amount)` at `field` is finding 7's trap | +| `animation.zod.ts` / `dnd.zod.ts` / `keyboard.zod.ts` / `touch.zod.ts` | 4+4+4+7 | authorable (p) | interaction configs — #5003 (批 13, in flight) reclassifies these as `no door`; that row is its edit, not 批 14's | +| `report.zod.ts` | 3 | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `ReportSchema` was already strict; `ReportSortSchema` and `JoinedReportBlockSchema` were not. The order key is the THIRD spelling of "sort" an author meets (`SortNodeSchema`'s `{field, order}`, the widget's flat `sortBy`/`sortOrder`, this `{by, direction}`), and the mappings run in opposite directions, so none is inferrable. ⚠️ `ReportSchema`'s OWN alias table carries a live false prescription (`filter` → `filters`, a key it also rejects; the real key is `runtimeFilter`) — out of 批 14's scope, filed as #5013 and pinned as a known defect in `strictness-batch14.test.ts` so the list cannot outlive it | +| `offline.zod.ts` | 3 | authorable (p) | #5003 (批 13, in flight) reclassifies this as `no door`; that row is its edit, not 批 14's | +| `notification.zod.ts` | 1 | authorable (p) | **#4610 dropped two sites** — the `./ui` `Notification` (toast/banner instance) and `NotificationConfig` (toaster global config) shapes were removed: zero importers in all three repos, and both shadowed live names owned elsewhere (`./api` owns the inbox row). What remains is `NotificationActionSchema` — and 批 14 measured it as **`no door`**, the fourth class: no carrier key (the barrel is its only importer), unreachable in a 6860-node BFS from the 24 metadata-type roots + `defineStack` (four positive controls passed in the same run; an injected carrier flipped it), and zero `.parse()` outside its own test. objectui consumes its `.shape.variant` as a VOCABULARY, never parsing an authored payload — which is exactly why closing it would buy nothing. Not tightened; ADR-0049 verdict filed as #5015 | +| `sharing.zod.ts` | 2 | **split** | The first row in this ledger to carry two verdicts, and the reason the classification question is per SCHEMA rather than per file. `SharingConfigSchema` is a **live door** — `FormViewSchema.sharing` carries it, `rest-server.ts` mounts the anonymous form routes on `sharing.allowAnonymous` + `sharing.publicLink`, and both example apps author it — **strict as of #4001 批 14**. `EmbedConfigSchema` is **`no door`**: nothing in the repo so much as names the symbol, BFS-unreachable, zero parse. Not tightened; ADR-0049 verdict filed as #5015 | ### `data/` — 162 sites @@ -664,7 +683,7 @@ it the same way: the decision is also written beside the schema and pinned in a test (`flow.test.ts`, `etl.test.ts`), because a row in a table is not where the next person to open that file will look. -#### `ui/` — 123 strip of 198 +#### `ui/` — 114 strip of 198 | File | Strip | Sites | Class | Batch | |---|---|---|---|---| @@ -679,18 +698,33 @@ next person to open that file will look. | `dnd.zod.ts` | 4 | 4 | authorable (p) | | | `keyboard.zod.ts` | 4 | 4 | authorable (p) | | | `responsive.zod.ts` | 4 | 4 | authorable (p) | | -| `dataset.zod.ts` | 3 | 4 | authorable (p) | `DatasetDimension` / `DatasetMeasure` + `.derived` | | `offline.zod.ts` | 3 | 3 | authorable (p) | | -| `dashboard.zod.ts` | 2 | 11 | authorable | Only `DashboardWidget.compareTo` and `.layout` left; `DashboardWidgetOptionsSchema` stays `passthrough` **deliberately** (renderer escape hatch — see the triage row) | -| `report.zod.ts` | 2 | 3 | authorable (p) | `ReportSort` / `JoinedReportBlock` | -| `sharing.zod.ts` | 2 | 2 | authorable (p) | `SharingConfig` / `EmbedConfig` | -| `action.zod.ts` | 1 | 8 | authorable | `ActionParamSchema.options` — a plain `{ label, value }` pair; the cheapest win in the directory | +| `sharing.zod.ts` | 1 | 2 | **no door** | 批 14: `SharingConfig` was a live door and is **closed**; the 1 left is `EmbedConfigSchema`, which no module in the repo even names (BFS-unreachable, zero parse). **This row shrinks without disappearing** — the first `no door` floor, the same read the `Class` column already has to carry for `flow`'s and `etl`'s wire floors. ADR-0049 verdict: #5015 | | `app.zod.ts` | 1 | 18 | verify | `BaseNavItemSchema` — the base the strict discriminated-union members extend. Closing a base that is `.extend()`ed is the #4001 trap that bit `view` (finding 16); confirm the members' strictness is not already covering it before touching | -| `notification.zod.ts` | 1 | 1 | authorable (p) | `NotificationActionSchema` | - -**Authorable strip in `ui/`: 123 of 123** — every remaining strip site in this -directory is authorable. Of those 123, `app.zod.ts`'s single site is held pending -the finding-16 `.extend()` check rather than counted as ready. +| `notification.zod.ts` | 1 | 1 | **no door** | 批 14: `NotificationActionSchema` reclassified, not tightened — no carrier key, BFS-unreachable, zero parse; objectui reads its `.shape` as a vocabulary. ADR-0049 verdict: #5015 | + +**Authorable strip in `ui/`: 112 of 114.** Recomputed FROM THE SURVIVING ROWS +above (29+20+14+9+7+7+6+4+4+4+4+3+1+1+1 = 114), never by decrementing the previous +header — see the `automation/` note on why a clean merge here is evidence of +nothing. Of those 114, `app.zod.ts`'s single site is held pending the finding-16 +`.extend()` check rather than counted as ready, and **2 are the fourth class**: +`sharing.zod.ts`'s `EmbedConfig` and `notification.zod.ts`'s `NotificationAction`, +both measured by 批 14 as having no authoring door at all (#5015). + +批 14 took the ratchet from 123 to 114 by closing **9 sites across four files** +(`action` 1, `report` 2, `dataset` 3, `dashboard` 2 — all four rows deleted by the +reverse pin) and reclassifying **2**. Worth recording what it did NOT find: the +#4852 remeasure's site counts held exactly (1+1+2+2+3+2 = 11, confirmed against +the gate's own AST counter), unlike 批 13, where the counts held but five of six +files' `authorable(p)` dissolved under measurement. Both outcomes are the +verification step working; neither is its expected answer. + +`sharing.zod.ts` is the shape this table had not seen before: **one file, two +verdicts.** Its row shrinks 2 → 1 rather than disappearing, because the surviving +site is deliberately-not-tightened rather than unfinished — the same read the +`Class` column already has to carry for `flow`'s and `etl`'s wire floors in +`automation/`, now needed for a `no door` floor too. The reverse pin still cannot +tell the three apart; only this column can. The one `open` site this directory carried is **gone, and not by being closed**: `bulk-action.zod.ts`'s `BulkActionParamSchema.options` was the row that read diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 438b109c9e..6b01bc6acd 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -108,6 +108,45 @@ const ACTION_PARAM_KEY_ALIASES: Readonly> = { * key(s) and, when one is a recognisable spelling of a declared key, points at * the canonical one. */ +/** + * Guidance for the two per-option keys that ARE declared one layer down, on + * `SelectOptionSchema` (`data/field.zod.ts`) — `color` and `visibleWhen`. + * + * Built per key rather than shared, because `guidance` emits one bullet per + * offending key and a shared string prints the same sentence N times (the + * 批 10 `join`/`joinGateway` lesson). + * + * Note what it deliberately does NOT say. The obvious advice — "make the param + * field-backed and inherit the field's options" — is FALSE for these two: + * objectui's `resolveActionParams` lowers an inherited list through + * `normaliseOptions`, which rebuilds every entry as `{ label, value }`. Both + * directions drop the key, so the sentence names where the vocabulary is real + * without promising a route that does not exist (ledger finding 18: prose in a + * rejection is behaviour, and a confidently wrong one is worse than none). + */ +const actionParamOptionDeclaredOnFieldOnly = (key: 'color' | 'visibleWhen'): string => + `\`${key}\` is a per-option key of a FIELD's option list (\`SelectOptionSchema\` in ` + + `\`data/field.zod.ts\`), where the object form and grid do read it. An action param's ` + + `options are \`{ label, value }\` — and the action metadata door has always stripped ` + + `anything else before a renderer could see it, so writing \`${key}\` here has never had ` + + `an effect. Whether this surface should carry the full per-option vocabulary is ` + + `#5016; do not rely on it today.`; + +/** + * Guidance for per-option keys that no spec shape declares at all. + * + * `icon` / `disabled` exist only in objectui's internal `SelectOptionMetadata` + * interface, which nothing populates from metadata — so unlike `color` / + * `visibleWhen` there is no "one layer down" to point at, and saying there was + * would be the false-prescription class this campaign has already shipped four + * times (ledger finding 18). + */ +const actionParamOptionUndeclaredAnywhere = (key: 'icon' | 'disabled'): string => + `no option shape in the spec declares \`${key}\` — not this one, and not the field-level ` + + `\`SelectOptionSchema\`. It exists only inside objectui's own ` + + `\`SelectOptionMetadata\` type, which no metadata path populates. An action param's ` + + `options are \`{ label, value }\`; drop the key.`; + const actionParamUnknownKeyError = strictUnknownKeyError({ surface: 'this action param', knownKeys: ACTION_PARAM_KEYS, @@ -136,8 +175,85 @@ export const ActionParamSchema = lazySchema(() => z.object({ * renderers check truthiness, so `false === undefined` for UI purposes). */ required: z.boolean().optional().default(false), - /** Select/picklist options override. */ - options: z.array(z.object({ label: I18nLabelSchema, value: z.string() })).optional(), + /** + * Select/picklist options override. + * + * #4001 批 14 closed the OPTION ENTRY. `ActionParamSchema` has been strict + * since #3405/#3746 — the file's template — but **strictness does not + * recurse**, so the entries inside `options` were still zod-default strip: + * the param was validated, its option list was not, and the shell reported + * success either way. + * + * **`strictObject`, not `.passthrough()` — measured, not inherited from the + * sibling.** `bulk-action.zod.ts`'s option entry went `.passthrough()` + * (#4909) and the reasoning there was specific: an authored bulk-action def + * is "left as-authored", reaches the grid VERBATIM, and objectui's + * `BulkActionParam` declares an explicit `[key: string]: unknown` catch-all, + * so `bulkParamToField`'s spread carries extras into a genuinely open widget + * vocabulary. Neither half of that holds here, and both were re-measured on + * 2026-08-03 rather than assumed: + * + * 1. **This surface has a parsing door, and the door already strips.** An + * action is a registered metadata type, so an authored param reaches + * objectui through `getMetadataTypeSchema('action')` + * (`MetadataManager.validate` / `GET /api/v1/meta` / the Studio form). + * Parsing a real action whose option carried + * `color` / `icon` / `disabled` / `visibleWhen` returned + * `{"label":"Overload","value":"overload"}` — every extra already gone, + * silently, before any renderer sees it. `.passthrough()` would therefore + * not be preserving a live flow; it would be *opening* one. + * 2. **The consumer type here is CLOSED, not a catch-all.** The dialog lowers + * a param through `paramToField` into field metadata, where the option + * vocabulary is objectui's `SelectOptionMetadata` — an enumerable + * interface (`label` / `value` / `color` / `icon` / `disabled` / + * `visibleWhen`), not an index signature. A closed target vocabulary is + * exactly the case where declaring beats tolerating. + * + * So the answer legitimately differs from the sibling's. What that leaves is + * a real, separable question — *should* an action param's option list speak + * the full per-option vocabulary that a FIELD's `options` + * (`SelectOptionSchema`, `data/field.zod.ts`) already declares and the object + * form already honours? That is a capability addition, not a strictness + * change, and it is filed as #5016 rather than guessed at here. Until it is decided, + * the honest contract is the one this schema has always had — `{ label, + * value }` — now said out loud instead of enforced by deletion. + * + * The aliases are anchored on `SelectOptionSchema`'s own curated table (the + * same idea, one layer down) rather than on edit distance, and deliberately + * carry across ONLY the entries whose target this shape actually declares — + * `never suggest a key the schema cannot accept` (ledger finding 12). + */ + options: z.array(strictObject({ + surface: 'this action param option', + history: + 'Until #4001 批 14 closed this shape these were dropped silently — the param still ' + + 'rendered its picker, minus whatever the key was meant to colour, gate or disable.', + aliases: { + // Carried over from `SelectOptionSchema`'s table — same idea, and these + // five point at keys THIS shape declares. + text: 'label', + name: 'label', + title: 'label', + key: 'value', + id: 'value', + // objectql/import-export spell the stored side this way. + optionValue: 'value', + optionLabel: 'label', + displayName: 'label', + }, + guidance: { + // The four per-option keys that ARE real one layer down. Each says where + // the vocabulary lives and — critically — does NOT promise that a + // field-backed param inherits them: `resolveActionParams`' + // `normaliseOptions` rebuilds each entry as `{ label, value }`, so that + // promise would be false in exactly the way ledger finding 18 warns about. + color: actionParamOptionDeclaredOnFieldOnly('color'), + visibleWhen: actionParamOptionDeclaredOnFieldOnly('visibleWhen'), + icon: actionParamOptionUndeclaredAnywhere('icon'), + disabled: actionParamOptionUndeclaredAnywhere('disabled'), + default: '`default` on an OPTION is the field-level picklist default (`SelectOptionSchema.default`). A dialog param defaults through `defaultValue` on the PARAM itself, one level up — write `defaultValue: \'\'` there.', + }, + }, { label: I18nLabelSchema, value: z.string() })).optional(), /** Placeholder override. */ placeholder: z.string().optional(), /** Help/description override. */ diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 4558f88581..a56c7730b0 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -287,7 +287,53 @@ export const DashboardWidgetSchema = lazySchema(() => z.object({ compareTo: z.union([ z.literal('previousPeriod'), z.literal('previousYear'), - z.object({ + // #4001 批 14: the object arm is closed. `DashboardWidgetSchema` has been + // strict since the ADR-0021 cutover, but STRICTNESS DOES NOT RECURSE — so + // `compareTo: { offset: '7d', granularity: 'month' }` parsed clean on `main` + // and came back `{ offset: '7d' }`, the widget rendering a comparison the + // author did not describe. A strict container around strip children is the + // silhouette of a closed surface, not a closed one. + // + // ⚠️ KNOWN REACH LIMIT, measured rather than assumed — this closure REJECTS + // reliably but its PROSE does not currently reach the author. `compareTo` is + // a union, and zod collapses a failed union into one top-level + // `invalid_union` issue whose message is the bare `Invalid input`; the arm + // errors (including the guidance below) live in `issue.errors`, and + // `zodIssuesToFields` in `rest/src/rest-server.ts` maps only top-level + // issues, so nothing carries them onto the wire. The rejection is still the + // #4001 win — a silent half-discard became a hard failure at `compareTo`. + // The transport gap is #5014, and it affects every curated + // unknown-key message this campaign has put inside a union arm, not just + // this one. `strictness-batch14.test.ts` pins BOTH halves: the bare + // top-level text an author sees today, and the guidance waiting in the arm + // errors — split deliberately, so a green test cannot stand in for a message + // no consumer prints. + strictObject({ + surface: 'this comparison window', + history: DASHBOARD_HISTORY, + // The neighbouring vocabularies for "shift a time window": the widget's own + // string arms (`previousPeriod` / `previousYear`) spelled as an object, and + // the date-macro / granularity words used elsewhere on this same widget. + aliases: { + period: 'offset', + duration: 'offset', + interval: 'offset', + shift: 'offset', + delta: 'offset', + by: 'offset', + amount: 'offset', + value: 'offset', + }, + guidance: { + // Naming an arm of this very union from inside its object arm. + type: 'the comparison KIND is the union itself, not a key: write `compareTo: \'previousPeriod\'` or `compareTo: \'previousYear\'` as a bare string. The object arm exists only for an explicit shift — `compareTo: { offset: \'7d\' }`.', + kind: 'the comparison KIND is the union itself, not a key: write `compareTo: \'previousPeriod\'` or `compareTo: \'previousYear\'` as a bare string. The object arm exists only for an explicit shift — `compareTo: { offset: \'7d\' }`.', + mode: 'the comparison KIND is the union itself, not a key: write `compareTo: \'previousPeriod\'` or `compareTo: \'previousYear\'` as a bare string. The object arm exists only for an explicit shift — `compareTo: { offset: \'7d\' }`.', + // Two real slots one level up, both easy to reach for here. + granularity: 'a comparison window carries no granularity — the shift is a whole duration (`7d` / `1M` / `1y`). Date bucketing is declared on the DATASET dimension (`dateGranularity`), which every widget bound to that dataset then shares.', + filter: '`filter` is the widget\'s own presentation-scope key, one level up — `compareTo` shifts whatever window that filter already resolves to. Move it out of `compareTo`.', + }, + }, { offset: z.string().regex(/^\d+[dwMy]$/, 'Offset must match (d|w|M|y), e.g. "7d", "1M", "1y"'), }), ]).optional().describe('Period-over-period comparison window'), @@ -321,7 +367,40 @@ export const DashboardWidgetSchema = lazySchema(() => z.object({ * disabled) even though it rendered correctly. Authors may still pin an * explicit grid position; absence means "auto-place". */ - layout: z.object({ + layout: strictObject({ + surface: 'this widget layout box', + history: DASHBOARD_HISTORY, + // React-Grid-Layout's own vocabulary is the competing contract here, and it + // is the one an author (or an LLM) will already know: RGL declares + // `minW`/`maxW`/`minH`/`maxH`/`static`/`isDraggable`/`isResizable` beside + // the same four letters. The renderer reads ONLY `{x, y, w, h}` (and + // auto-flows when the box is absent), so the rest are named rather than + // suggested — a rename would map a real RGL constraint onto an unrelated + // position key. + aliases: { + col: 'x', + column: 'x', + left: 'x', + row: 'y', + top: 'y', + width: 'w', + cols: 'w', + colSpan: 'w', + height: 'h', + rows: 'h', + rowSpan: 'h', + }, + guidance: { + minW: 'a widget layout box is exactly `{ x, y, w, h }`. React-Grid-Layout\'s size CONSTRAINTS (`minW`/`maxW`/`minH`/`maxH`) are not part of the metadata contract — the dashboard grid sizes by `columns` + `gap` on the dashboard and this box on the widget.', + maxW: 'a widget layout box is exactly `{ x, y, w, h }`. React-Grid-Layout\'s size CONSTRAINTS (`minW`/`maxW`/`minH`/`maxH`) are not part of the metadata contract — the dashboard grid sizes by `columns` + `gap` on the dashboard and this box on the widget.', + minH: 'a widget layout box is exactly `{ x, y, w, h }`. React-Grid-Layout\'s size CONSTRAINTS (`minW`/`maxW`/`minH`/`maxH`) are not part of the metadata contract — the dashboard grid sizes by `columns` + `gap` on the dashboard and this box on the widget.', + maxH: 'a widget layout box is exactly `{ x, y, w, h }`. React-Grid-Layout\'s size CONSTRAINTS (`minW`/`maxW`/`minH`/`maxH`) are not part of the metadata contract — the dashboard grid sizes by `columns` + `gap` on the dashboard and this box on the widget.', + static: 'a widget layout box is exactly `{ x, y, w, h }`. React-Grid-Layout\'s interaction flags (`static`/`isDraggable`/`isResizable`) are the DESIGNER\'s runtime state, not authored metadata — omit the box entirely to let the grid auto-flow the widget.', + isDraggable: 'a widget layout box is exactly `{ x, y, w, h }`. React-Grid-Layout\'s interaction flags (`static`/`isDraggable`/`isResizable`) are the DESIGNER\'s runtime state, not authored metadata — omit the box entirely to let the grid auto-flow the widget.', + isResizable: 'a widget layout box is exactly `{ x, y, w, h }`. React-Grid-Layout\'s interaction flags (`static`/`isDraggable`/`isResizable`) are the DESIGNER\'s runtime state, not authored metadata — omit the box entirely to let the grid auto-flow the widget.', + i: 'React-Grid-Layout keys an item by `i`; this box does not carry an id — the widget\'s own `id` (one level up) is the key. Remove it.', + }, + }, { x: z.number(), y: z.number(), w: z.number(), diff --git a/packages/spec/src/ui/dataset.zod.ts b/packages/spec/src/ui/dataset.zod.ts index 84207af56e..b3c89313ce 100644 --- a/packages/spec/src/ui/dataset.zod.ts +++ b/packages/spec/src/ui/dataset.zod.ts @@ -33,10 +33,86 @@ import { AggregationFunction, DateGranularity } from '../data/query.zod'; * layer is absorbed/retired in a later phase (D-A). */ +/** + * Shared history for the semantic-layer sub-shapes in this file (#4001 批 14). + * + * `DatasetSchema` (the container) has been strict since the ADR-0021 cutover; + * the two shapes that carry the actual semantic contract — the dimension and + * measure entries every presentation binds to BY NAME — were not. A strict + * container around strip children is the silhouette of a closed surface, not a + * closed surface (the nested-hole shape 批 13 found on `page.components[]`). + */ +const DATASET_HISTORY = + 'Until #4001 批 14 closed this shape these were dropped silently — the dataset still ' + + 'compiled and every report and widget bound to it still rendered, computing something ' + + 'other than what was declared.'; + +/** + * The competing vocabulary these two shapes are curated against is NAMED by this + * module's own header: `data/analytics.zod.ts`'s Cube layer (`DimensionSchema` / + * `MetricSchema`), which the two coexist with by design during ADR-0021 Phase 1. + * That is the anchor for the aliases below — a sibling contract in this repo, + * not an edit-distance guess: + * + * Cube dimension `{ name, label, description, type, sql, granularities }` + * Cube metric `{ name, label, description, type, sql, filters, format }` + * + * Two of those overlaps are actively dangerous rather than merely different, and + * neither is a typo any distance metric can reach: + * + * - **`type` means different things in the two layers.** On a Cube *metric* it + * is the AGGREGATION (`sum`/`avg`/…); on a dataset *dimension* it is the + * DATATYPE. So `{ name: 'revenue', type: 'sum', field: 'amount' }` — a + * perfectly sensible thing to write, and what an LLM trained on Cube/LookML + * emits — parsed clean on a measure and computed a `count`, because + * `aggregate` was absent and `type` was stripped. + * - **`sql` has no destination here at all.** The dataset layer is deliberately + * SMALLER than a query: no raw SQL, no hand-authored predicates (see the + * module header). Aliasing it to `field` would be finding 7's trap — pointing + * an author who wrote `sql: 'SUM(amount)'` at a slot that takes a field PATH, + * where the same content is wrong again. It gets `guidance` instead. + */ +const DATASET_NO_SQL = + 'the dataset layer takes no raw SQL — it is deliberately smaller than a query (ADR-0021). ' + + 'A dimension names a `field` (a base field or a `relationship[.relationship].field` path); ' + + 'a measure names an `aggregate` + `field`, and the only computed form is ' + + '`derived: { op, of: [...] }`, which combines OTHER measures in this dataset by name. ' + + 'Joins are compiled from `Dataset.include` — you never write an ON clause.'; + /** * Dimension — a groupable axis (e.g. "region", "close_date by quarter"). */ -export const DatasetDimensionSchema = lazySchema(() => z.object({ +export const DatasetDimensionSchema = lazySchema(() => strictObject({ + surface: 'this dataset dimension', + history: DATASET_HISTORY, + aliases: { + // The source. A dimension names a field PATH; these are the words the Cube + // layer, objectql and the chart surfaces use for the same slot. + column: 'field', + path: 'field', + source: 'field', + fieldName: 'field', + property: 'field', + // Bucketing. Cube spells it `granularities` (an ARRAY of supported ones); + // here it is one default bucket, so the rename also changes the shape — + // which is why it must be said rather than guessed. + granularity: 'dateGranularity', + granularities: 'dateGranularity', + dateBucket: 'dateGranularity', + bucket: 'dateGranularity', + interval: 'dateGranularity', + // Identity/display. + title: 'label', + displayName: 'label', + }, + guidance: { + sql: DATASET_NO_SQL, + expression: DATASET_NO_SQL, + formula: DATASET_NO_SQL, + description: + 'a dimension has no `description` — its author-facing text is `label`. `description` is declared on the DATASET itself; put the explanation there.', + }, +}, { /** Referenced by presentations (report rows/columns, widget dimensions). */ name: SnakeCaseIdentifierSchema.describe('Dimension name — referenced by presentations'), label: I18nLabelSchema.optional(), @@ -63,7 +139,47 @@ export const DerivedMeasureOp = z.enum(['ratio', 'sum', 'difference', 'product'] * Measure — an aggregatable value (e.g. "revenue = sum(amount)"). Defined ONCE * here; every presentation references it by name. */ -export const DatasetMeasureSchema = lazySchema(() => z.object({ +export const DatasetMeasureSchema = lazySchema(() => strictObject({ + surface: 'this dataset measure', + history: DATASET_HISTORY, + aliases: { + // THE dangerous one — see the note above `DATASET_NO_SQL`. A Cube metric's + // `type` IS the aggregation, and `type` is not declared here at all, so an + // author who brings that habit silently loses the aggregation. + type: 'aggregate', + aggregation: 'aggregate', + agg: 'aggregate', + fn: 'aggregate', + func: 'aggregate', + function: 'aggregate', + operation: 'aggregate', + // The aggregated column. + column: 'field', + source: 'field', + fieldName: 'field', + property: 'field', + // Measure-scoped filter — singular here, plural on the Cube metric. + filters: 'filter', + where: 'filter', + criteria: 'filter', + // Formatting / display. + numberFormat: 'format', + displayFormat: 'format', + currencyCode: 'currency', + title: 'label', + displayName: 'label', + // Computed measures. + calculated: 'derived', + computed: 'derived', + }, + guidance: { + sql: DATASET_NO_SQL, + expression: DATASET_NO_SQL, + formula: DATASET_NO_SQL, + description: + 'a measure has no `description` — its author-facing text is `label`. `description` is declared on the DATASET itself; put the explanation there.', + }, +}, { name: SnakeCaseIdentifierSchema.describe('Measure name — e.g. "revenue"; defined once'), label: I18nLabelSchema.optional(), /** Aggregation function — reuses the canonical query.zod enum. */ @@ -87,7 +203,39 @@ export const DatasetMeasureSchema = lazySchema(() => z.object({ * Mutually exclusive with `field`/`aggregate` semantics: when `derived` is * set, `aggregate` is ignored at compile time. */ - derived: z.object({ + derived: strictObject({ + surface: 'this derived-measure spec', + history: DATASET_HISTORY, + // Two keys, both terse, both therefore out of edit-distance reach of the + // words an author reaches for. `of` in particular: a two-character key has + // a distance budget of 2, so `operands` (8 edits away) can never suggest it. + aliases: { + operator: 'op', + operation: 'op', + type: 'op', + kind: 'op', + fn: 'op', + func: 'op', + function: 'op', + measures: 'of', + operands: 'of', + args: 'of', + arguments: 'of', + inputs: 'of', + refs: 'of', + from: 'of', + over: 'of', + }, + guidance: { + // The refs are measure NAMES; pointing a field/SQL author at `of` would + // hand them a slot where their content is wrong again (finding 7). + sql: DATASET_NO_SQL, + expression: DATASET_NO_SQL, + formula: DATASET_NO_SQL, + field: + 'a derived measure references OTHER MEASURES by name, never raw fields — that is what keeps it enumerable and reviewable (ADR-0021 Q1). List the measure names in `of`, and declare the underlying field on the measure being referenced.', + }, + }, { op: DerivedMeasureOp, /** Names of other measures in this dataset (2+ for ratio/difference). */ of: z.array(SnakeCaseIdentifierSchema).min(1), diff --git a/packages/spec/src/ui/notification.zod.ts b/packages/spec/src/ui/notification.zod.ts index 57ba560241..f5241374b8 100644 --- a/packages/spec/src/ui/notification.zod.ts +++ b/packages/spec/src/ui/notification.zod.ts @@ -49,6 +49,41 @@ export type NotificationPosition = z.infer; /** * Notification Action Schema * Defines an interactive action button within a notification. + * + * ⛔ **DELIBERATELY NOT `strictObject` — this shape has NO AUTHORING DOOR** + * (#4001 批 14, ADR-0078 completeness gate). It is a **vocabulary**, consumed by + * reading its `.shape`, never by parsing an authored payload. Three independent + * measurements on 2026-08-03, each with a positive control that passed in the + * same run: + * + * 1. **Carrier key** — nothing in `packages/spec/src` imports this schema except + * the `ui/index.ts` barrel. No metadata type declares a notification-actions + * key; the `./ui` "notification instance" and "notification system config" + * wrappers that once could have carried it were deleted in #4610 for having + * zero consumers (see the note at the bottom of this file). + * 2. **Graph reachability** — BFS from the 24 metadata-type roots plus + * `defineStack`'s `ObjectStackSchema` (the closure `build-schemas.ts` uses for + * the #4650 deletion check) visits 6860 nodes and never reaches it. Controls + * `PageSchema` / `ActionSchema` / `DashboardWidgetSchema` / `WebhookSchema` + * were all `root-graph` in the same run, and injecting a synthetic carrier + * flipped this schema to `root-graph` — so "unreachable" is a fact about the + * graph, not a broken instrument. + * 3. **Call sites** — no `.parse()` in framework or objectui outside this + * module's own `notification.test.ts`. objectui's use is the opposite of a + * parse: `animation-notification-spec-parity.test.tsx` reads + * `NotificationActionSchema.shape.variant` to pin its own hand-written + * `NotificationActionButton` interface against this enum, both ways. That pin + * depends on the SHAPE and is unaffected by the posture — which is precisely + * why closing the shape would buy nothing. + * + * `.strict()` is a property of a PARSE, and nothing parses this. Closing it + * would spend a v17 breaking change to make the file look finished and leave a + * precisely-validated dead slot — *"the more convincing lie"* (#4583). The + * verdict this shape actually needs is ADR-0049 enforce-or-remove; filed as #5015 + * and recorded in the strictness ledger's `no door` class. + * + * The pin in `notification.test.ts` goes RED the moment anyone gives this shape + * a carrier key — at which point it becomes authorable and this comment is wrong. */ export const NotificationActionSchema = lazySchema(() => z.object({ label: I18nLabelSchema.describe('Action button label'), diff --git a/packages/spec/src/ui/report.zod.ts b/packages/spec/src/ui/report.zod.ts index cec84ad93a..321ed1e4c4 100644 --- a/packages/spec/src/ui/report.zod.ts +++ b/packages/spec/src/ui/report.zod.ts @@ -49,7 +49,37 @@ export const ReportChartSchema = lazySchema(() => ChartConfigSchema.extend({ * a contract an author should have to rely on. The renderer lowers the list to * `DatasetSelection.order` in list order — see {@link reportSelectionOrder}. */ -export const ReportSortSchema = lazySchema(() => z.object({ +export const ReportSortSchema = lazySchema(() => strictObject({ + surface: 'this report order key', + history: + 'Until #4001 批 14 closed this shape these were dropped silently — the key still parsed, ' + + '`direction` fell back to `asc`, and the report rendered in an order nobody asked for ' + + '(the `SortNodeSchema` failure of #4721, one layer up).', + // Anchored on the two named sibling ordering contracts, not on edit distance. + // A report's `order` is the THIRD spelling of "sort" an author meets, and the + // other two are both correct where they live: + // • `data/query.zod.ts` `SortNodeSchema` — `{ field, order }` (closed by + // #4721, whose own alias table maps `direction` → `order`; the mapping + // runs the OTHER way here, which is exactly why neither can be inferred). + // • `dashboard.zod.ts` `DashboardWidget` — flat `sortBy` / `sortOrder`. + // `asc` / `desc` as a bare boolean-ish key is the Mongo/objectql habit. + aliases: { + field: 'by', + key: 'by', + column: 'by', + dimension: 'by', + measure: 'by', + name: 'by', + sortBy: 'by', + order: 'direction', + sortOrder: 'direction', + dir: 'direction', + sort: 'direction', + desc: 'direction', + descending: 'direction', + ascending: 'direction', + }, +}, { /** A dimension (`rows`/`columns`) or measure (`values`) name this report selects. */ by: z.string().describe('Dimension or measure name to order by (must be selected by this report)'), /** Sort direction. Null/empty cells sort LAST in both directions. */ @@ -133,7 +163,55 @@ export function reportSelectionOrder( * - The schema is intentionally permissive about the column shape: blocks * are not allowed to be themselves `joined` (no recursion). */ -export const JoinedReportBlockSchema: z.ZodTypeAny = lazySchema(() => z.object({ +export const JoinedReportBlockSchema: z.ZodTypeAny = lazySchema(() => strictObject({ + surface: 'this joined report block', + history: + 'Until #4001 批 14 closed this shape these were dropped silently — the block still rendered, ' + + 'minus whatever the key was meant to select, scope or order.', + // A block is a sub-report, so the vocabulary an author brings is the CONTAINER's + // (`ReportSchema`, thirty lines below) — and the two shapes deliberately differ: + // a block has no `drilldown`, no `protection`, no nested `blocks`, and its type + // enum excludes `joined` (no recursion). Those are the entries below: each one + // is a key that is correct one level up and wrong here, which edit distance + // reads as a near-match to something unrelated rather than as a layer mistake. + aliases: { + // ADR-0021 single-form: the legacy inline query was removed in the cutover. + // These are the spellings that cutover retired, aimed at their successors. + objectName: 'dataset', + object: 'dataset', + dataSet: 'dataset', + source: 'dataset', + // A block selects measures by name; `columns` is a real key here (the matrix + // across-axis), so the value list cannot borrow it — hence the explicit map. + fields: 'values', + measures: 'values', + metrics: 'values', + groupings: 'rows', + groupBy: 'rows', + dimensions: 'rows', + // Scope filter. `runtimeFilter` is camelCase, so the fallback under-reaches + // every one of these (#4990). + filter: 'runtimeFilter', + filters: 'runtimeFilter', + where: 'runtimeFilter', + criteria: 'runtimeFilter', + // Ordering, spelled as the container/objectql/dashboard surfaces spell it. + sort: 'order', + orderBy: 'order', + sortBy: 'order', + }, + guidance: { + // Wrong-layer pointers, all three verified against this schema's own shape + // and the container's: writing them here is not a typo, it is a level + // mistake, and a rename suggestion would send the author somewhere worse. + blocks: + 'a block cannot contain blocks — `type: \'joined\'` is excluded from a block\'s type enum on purpose (no recursion). Declare sibling blocks on the CONTAINER report\'s `blocks` list instead.', + drilldown: + '`drilldown` is a container-level key on the report, not per block — move it to the top-level report. A joined report drills through from the container.', + protection: + '`protection` is the ADR-0010 package-author lock policy, declared once on the REPORT — a block is not separately lockable. Move it to the top-level report.', + }, +}, { /** Stable id for the block (used as react key, telemetry, deeplinks). */ name: SnakeCaseIdentifierSchema, /** Human label shown above the block. Falls back to `name`. */ diff --git a/packages/spec/src/ui/sharing.zod.ts b/packages/spec/src/ui/sharing.zod.ts index 0ca1c9734e..dac360ecd6 100644 --- a/packages/spec/src/ui/sharing.zod.ts +++ b/packages/spec/src/ui/sharing.zod.ts @@ -5,19 +5,87 @@ * * Sharing & Embedding Protocol * - * Defines schemas for public link sharing, embed configuration, - * domain restrictions, and password protection for apps, pages, and forms. + * Public-link sharing and iframe-embed configuration. The module name is + * plural, but the two shapes below are in **opposite** postures, and #4001 批 14 + * measured why rather than assuming a file-level verdict: + * + * - `SharingConfigSchema` has a **live authoring door**. `FormViewSchema.sharing` + * carries it (`view.zod.ts`), `view` is a metadata-type root, and the runtime + * really reads it: `rest-server.ts` mounts the anonymous form endpoints only + * when `sharing.allowAnonymous === true` and a `sharing.publicLink` slug + * matches. Both example apps author it (`app-showcase` `inquiry.view.ts`, + * `app-crm` `lead.view.ts`). It is `strictObject` as of #4001 批 14. + * - `EmbedConfigSchema` has **no door at all** — see its own block below. + * + * That split is the point. The ledger's classification question is *"who writes + * this schema's input?"*, and it is answered per SCHEMA, not per file; before + * 批 14 this file's row carried one verdict for both. */ import { z } from 'zod'; +import { lazySchema } from '../shared/lazy-schema'; +import { strictObject } from '../shared/strict-object'; + +/** + * Shared history for the authorable shapes in this file (#4001). + */ +const SHARING_HISTORY = + 'Until #4001 closed this shape these were dropped silently — the form still published, ' + + 'without whatever the key was meant to open up, gate or expire.'; + /** * Sharing Config Schema - * Configuration for public sharing of an app, page, or form. - * Supports public links, password protection, domain restrictions, and expiration. + * + * Public sharing of a form view: a public link, optional password, domain + * allow-list, expiry, and the anonymous-access switch the REST layer gates the + * public form routes on. + * + * The aliases are semantic near-misses, not typos, and they matter more here + * than the edit-distance fallback suggests: every declared key on this shape is + * camelCase, and the fallback lower-cases the INPUT but not the candidates, so + * each capital letter costs a point of budget it never recovers (#4990). A bare + * `anonymous` therefore reaches nothing on its own. + * + * `allowAnonymous` is the one whose loss was silent AND security-shaped in both + * directions: authored on the wrong spelling it was stripped, the form stayed + * private, and the author believed it was public. */ -import { lazySchema } from '../shared/lazy-schema'; -export const SharingConfigSchema = lazySchema(() => z.object({ +export const SharingConfigSchema = lazySchema(() => strictObject({ + surface: 'this sharing config', + history: SHARING_HISTORY, + // Keys are matched case-insensitively with separators removed (`aliasProbe`), + // so one entry covers `isPublic` / `is_public` / `IS-PUBLIC`. + aliases: { + // The switch. `public` / `isPublic` / `shared` are how the same intent is + // spelled on neighbouring surfaces; `active` is the generic on/off word. + public: 'enabled', + isPublic: 'enabled', + shared: 'enabled', + active: 'enabled', + // The slug/URL. Each of these is out of edit-distance reach of the + // camelCase `publicLink`. + url: 'publicLink', + link: 'publicLink', + shareUrl: 'publicLink', + shareLink: 'publicLink', + slug: 'publicLink', + // Anonymous access — the key `rest-server.ts` actually gates the public + // form routes on. + anonymous: 'allowAnonymous', + allowGuest: 'allowAnonymous', + allowGuests: 'allowAnonymous', + publicAccess: 'allowAnonymous', + // Domain allow-list / expiry. + domains: 'allowedDomains', + allowedDomain: 'allowedDomains', + emailDomains: 'allowedDomains', + expires: 'expiresAt', + expiry: 'expiresAt', + expiration: 'expiresAt', + validUntil: 'expiresAt', + }, +}, { enabled: z.boolean().default(false).describe('Enable public sharing'), publicLink: z.string().optional().describe('Generated public share URL'), password: z.string().optional().describe('Password required to access shared link'), @@ -33,6 +101,34 @@ export const SharingConfigSchema = lazySchema(() => z.object({ * Embed Config Schema * Configuration for iframe embedding of an app, page, or form. * Supports origin restrictions, display options, and responsive sizing. + * + * ⛔ **DELIBERATELY NOT `strictObject` — this shape has NO AUTHORING DOOR** + * (#4001 批 14, ADR-0078 completeness gate). Three independent measurements on + * 2026-08-03, each with a positive control that passed in the same run: + * + * 1. **Carrier key** — nothing in `packages/spec/src` imports this schema except + * the `ui/index.ts` barrel. No metadata type declares an `embed` key; the one + * sibling in this file (`SharingConfigSchema`) is carried by + * `FormViewSchema.sharing`, and this one is carried by nothing. + * 2. **Graph reachability** — BFS from the 24 metadata-type roots plus + * `defineStack`'s `ObjectStackSchema` (the closure `build-schemas.ts` uses for + * the #4650 deletion check) visits 6860 nodes and never reaches it. Controls + * `PageSchema` / `ActionSchema` / `DashboardWidgetSchema` / `WebhookSchema` + * were all `root-graph` in the same run, and injecting a synthetic carrier + * flipped this schema to `root-graph` — so "unreachable" is a fact about the + * graph, not a broken instrument. + * 3. **Call sites** — no `.parse()` anywhere in framework or objectui outside + * this module's own `sharing.test.ts` and objectui's export-surface pin, + * which parses a literal it wrote itself. + * + * `.strict()` is a property of a PARSE, and nothing parses this. Closing it + * would spend a v17 breaking change to make the file look finished and leave a + * precisely-validated dead slot — *"the more convincing lie"* (#4583). The + * verdict this shape actually needs is ADR-0049 enforce-or-remove; filed as #5015 + * and recorded in the strictness ledger's `no door` class. + * + * The pin in `sharing.test.ts` goes RED the moment anyone gives this shape a + * carrier key — at which point it becomes authorable and this comment is wrong. */ export const EmbedConfigSchema = lazySchema(() => z.object({ enabled: z.boolean().default(false).describe('Enable iframe embedding'), diff --git a/packages/spec/src/ui/strictness-batch14.test.ts b/packages/spec/src/ui/strictness-batch14.test.ts new file mode 100644 index 0000000000..ecfb3272b7 --- /dev/null +++ b/packages/spec/src/ui/strictness-batch14.test.ts @@ -0,0 +1,589 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #4001 批 14 — the ui/ wave's second slice. + * + * Eleven strip sites measured, **nine closed and two reclassified**. This file + * carries both halves, because they are one verdict per schema and the ledger's + * row is per FILE: `sharing.zod.ts` alone splits, with one live door and one + * shape nothing parses. + * + * Three kinds of assertion live here: + * + * 1. **Closure** — each newly-strict shape rejects an undeclared key and the + * rejection is USEFUL (names the surface, echoes the key, prescribes). + * 2. **No-door pins** — for `NotificationActionSchema` / `EmbedConfigSchema`, + * an assertion that goes RED the moment either gains a carrier key, which is + * the event that would make this batch's "do not tighten" verdict wrong. + * 3. **Prescription integrity** — every alias target this batch added is a key + * the schema really accepts (ledger finding 12: *never suggest a key the + * schema cannot accept*), checked by parsing the prescribed key, not by + * reading the table. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, it, expect } from 'vitest'; +import ts from 'typescript'; +import { z } from 'zod'; + +import { ActionParamSchema, ActionSchema as ActionSchemaForAudit } from './action.zod'; +import { NotificationActionSchema } from './notification.zod'; +import { SharingConfigSchema, EmbedConfigSchema } from './sharing.zod'; +import { ReportSortSchema, JoinedReportBlockSchema, ReportSchema as ReportSchemaForAudit } from './report.zod'; +import { + DatasetDimensionSchema, + DatasetMeasureSchema, + DatasetSchema as DatasetSchemaForAudit, +} from './dataset.zod'; +import { DashboardWidgetSchema } from './dashboard.zod'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const SPEC_SRC = path.resolve(HERE, '..'); + +/** Every unknown-key message produced by parsing `payload` against `schema`. */ +function rejectionFor(schema: z.ZodTypeAny, payload: unknown): string { + const r = schema.safeParse(payload); + if (r.success) return ''; + return r.error.issues.map((i) => i.message).join('\n'); +} + +// --------------------------------------------------------------------------- +// 1. Closure — the nine sites this batch tightened +// --------------------------------------------------------------------------- + +describe('批 14 — closed shapes reject undeclared keys', () => { + const widgetBase = { + id: 'w1', + dataset: 'sales', + values: ['revenue'], + }; + + const cases: Array<[string, z.ZodTypeAny, Record, string]> = [ + [ + 'ui/action ActionParamSchema.options[]', + ActionParamSchema, + { name: 'p', options: [{ label: 'A', value: 'a', colour: 'red' }] }, + 'this action param option', + ], + [ + 'ui/sharing SharingConfigSchema', + SharingConfigSchema, + { enabled: true, anonymous: true }, + 'this sharing config', + ], + [ + 'ui/report ReportSortSchema', + ReportSortSchema, + { by: 'revenue', dir: 'desc' }, + 'this report order key', + ], + [ + 'ui/report JoinedReportBlockSchema', + JoinedReportBlockSchema, + { name: 'block_a', dataset: 'sales', values: ['revenue'], groupings: ['region'] }, + 'this joined report block', + ], + [ + 'ui/dataset DatasetDimensionSchema', + DatasetDimensionSchema, + { name: 'region', field: 'account.region', granularity: 'month' }, + 'this dataset dimension', + ], + [ + 'ui/dataset DatasetMeasureSchema', + DatasetMeasureSchema, + { name: 'revenue', aggregate: 'sum', field: 'amount', aggregation: 'sum' }, + 'this dataset measure', + ], + [ + 'ui/dataset DatasetMeasureSchema.derived', + DatasetMeasureSchema, + { name: 'rate', derived: { op: 'ratio', of: ['a', 'b'], operands: ['a'] } }, + 'this derived-measure spec', + ], + [ + 'ui/dashboard DashboardWidgetSchema.layout', + DashboardWidgetSchema, + { ...widgetBase, layout: { x: 0, y: 0, w: 3, h: 4, minW: 2 } }, + 'this widget layout box', + ], + ]; + + it.each(cases)('%s rejects an undeclared key and names its surface', (_name, schema, payload, surface) => { + const message = rejectionFor(schema, payload); + expect(message).toContain('Unrecognized key(s) on'); + expect(message).toContain(surface); + }); + + it('each rejection echoes the offending key back verbatim', () => { + expect(rejectionFor(SharingConfigSchema, { anonymous: true })).toContain('`anonymous`'); + expect(rejectionFor(ReportSortSchema, { by: 'x', dir: 'desc' })).toContain('`dir`'); + expect( + rejectionFor(DatasetMeasureSchema, { name: 'm', aggregate: 'sum', field: 'f', aggregation: 'sum' }), + ).toContain('`aggregation`'); + }); +}); + +// --------------------------------------------------------------------------- +// The curated prescriptions — the entries the batch justified individually +// --------------------------------------------------------------------------- + +describe('批 14 — curated prescriptions', () => { + it('sharing: the camelCase near-misses reach their canonical key', () => { + // Each of these is out of the edit-distance fallback's reach, because the + // fallback lower-cases the input but not the candidates (#4990). + for (const [written, canonical] of [ + ['anonymous', 'allowAnonymous'], + ['allowGuest', 'allowAnonymous'], + ['url', 'publicLink'], + ['slug', 'publicLink'], + ['domains', 'allowedDomains'], + ['expires', 'expiresAt'], + ['isPublic', 'enabled'], + ] as const) { + expect(rejectionFor(SharingConfigSchema, { [written]: true })).toContain( + `\`${written}\` → \`${canonical}\``, + ); + } + }); + + it('dataset measure: a Cube metric `type` is aimed at `aggregate`, not read as a datatype', () => { + // The dangerous overlap: on a Cube metric `type` IS the aggregation, and on + // a dataset DIMENSION `type` is the datatype. Before this batch, writing + // `type: 'sum'` on a measure parsed clean and computed something else. + const message = rejectionFor(DatasetMeasureSchema, { name: 'revenue', field: 'amount', type: 'sum' }); + expect(message).toContain('`type` → `aggregate`'); + }); + + it('dataset: `sql` gets a prescription, never a rename', () => { + // Aliasing `sql` to `field` would hand `SUM(amount)` to a slot that takes a + // field PATH — ledger finding 7's shape. It must arrive as guidance. + for (const schema of [DatasetDimensionSchema, DatasetMeasureSchema]) { + const message = rejectionFor(schema, { name: 'x', field: 'f', aggregate: 'sum', sql: 'SUM(amount)' }); + expect(message).toContain('takes no raw SQL'); + expect(message).not.toContain('`sql` → '); + } + }); + + it('dashboard: RGL constraint keys are prescribed, not renamed onto a position key', () => { + const message = rejectionFor(DashboardWidgetSchema, { + id: 'w1', dataset: 'd', values: ['v'], + layout: { x: 0, y: 0, w: 3, h: 4, minW: 2, static: true }, + }); + expect(message).toContain('React-Grid-Layout'); + expect(message).not.toContain('`minW` → '); + expect(message).not.toContain('`static` → '); + }); + + /** + * `compareTo` is a UNION, and that changes what a rejection is worth — a fact + * this batch measured rather than assumed, after writing the assertion the + * obvious way and watching it go red on `'Invalid input'`. + * + * Zod collapses a failed union into ONE top-level `invalid_union` issue whose + * message is the bare `'Invalid input'`; the arm errors — including the + * curated unknown-key prescription — live in `issue.errors`, one array per + * arm. And `zodIssuesToFields` (`rest/src/rest-server.ts`) maps only + * top-level issues, so nothing carries them onto the wire. + * + * The closure is still worth having and still does #4001's job: the widget + * now FAILS at `compareTo` instead of silently discarding half the object. + * But the prescription is currently reachable only by walking sub-errors, so + * these two assertions are deliberately split — one for what an author sees + * today, one for the text that is there to be surfaced once the transport is + * fixed (filed separately). Asserting only the second would have been a green + * test over a message no consumer prints. + */ + function unionSubMessages(schema: z.ZodTypeAny, payload: unknown): string { + const r = schema.safeParse(payload); + if (r.success) return ''; + const out: string[] = []; + for (const issue of r.error.issues) { + const arms = (issue as { errors?: Array> }).errors; + for (const arm of arms ?? []) for (const sub of arm) out.push(sub.message); + } + return out.join('\n'); + } + + it('dashboard compareTo: an undeclared key now FAILS the widget (what an author sees today)', () => { + const r = DashboardWidgetSchema.safeParse({ + id: 'w1', dataset: 'sales', values: ['revenue'], + compareTo: { offset: '7d', granularity: 'month' }, + }); + expect(r.success).toBe(false); + const union = r.success ? undefined : r.error.issues.find((i) => i.code === 'invalid_union'); + expect(union).toBeDefined(); + expect(union!.path).toEqual(['compareTo']); + // Pinning the CURRENT top-level text, so the day the transport starts + // surfacing arm errors this test says so instead of quietly improving. + expect(union!.message).toBe('Invalid input'); + }); + + it('dashboard compareTo: the curated prescription exists in the arm errors', () => { + expect(unionSubMessages(DashboardWidgetSchema, { + id: 'w1', dataset: 'sales', values: ['revenue'], + compareTo: { offset: '7d', granularity: 'month' }, + })).toContain('this comparison window'); + + expect(unionSubMessages(DashboardWidgetSchema, { + id: 'w1', dataset: 'sales', values: ['revenue'], + compareTo: { type: 'previousPeriod' }, + })).toContain("compareTo: 'previousPeriod'"); + }); + + it('action option: guidance separates keys that exist one layer down from keys that exist nowhere', () => { + const declaredOneLayerDown = rejectionFor(ActionParamSchema, { + name: 'p', options: [{ label: 'A', value: 'a', color: 'red' }], + }); + expect(declaredOneLayerDown).toContain('SelectOptionSchema'); + + const nowhere = rejectionFor(ActionParamSchema, { + name: 'p', options: [{ label: 'A', value: 'a', icon: 'x' }], + }); + // `icon` is NOT on SelectOptionSchema — claiming it were is the false + // prescription class (ledger finding 18). + expect(nowhere).toContain('no option shape in the spec declares `icon`'); + expect(nowhere).not.toContain('is a per-option key of a FIELD'); + }); + + it('guidance emits one bullet per offending key, not one shared sentence repeated', () => { + const message = rejectionFor(ActionParamSchema, { + name: 'p', options: [{ label: 'A', value: 'a', icon: 'x', disabled: true }], + }); + expect(message).toContain('`icon`'); + expect(message).toContain('`disabled`'); + }); +}); + +// --------------------------------------------------------------------------- +// 3. Prescription integrity — every alias target must actually be accepted +// --------------------------------------------------------------------------- + +/** + * The `aliases` table of every `strictObject(` call in one of this batch's six + * files, read from the SOURCE by AST, keyed by the call's `surface` string. + * + * Reading the source is what makes this a real check. The first version of this + * suite hand-listed the prescribed keys beside the assertion — a second copy of + * the truth — and it stayed GREEN under a deliberate sabotage that repointed a + * live alias at a key the schema rejects. That is the failure the ledger keeps + * recording in different instruments (finding 9, finding 19): a measurement + * reporting completeness it does not have. `strictObject` exists precisely to + * collapse two copies into one; a test over it must not reintroduce them. + */ +function aliasTablesBySurface(file: string): Map> { + const source = ts.createSourceFile( + file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true, + ); + const out = new Map>(); + const literal = (n: ts.Node): string | null => + ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n) ? n.text : null; + const prop = (o: ts.ObjectLiteralExpression, name: string): ts.Expression | null => { + for (const p of o.properties) { + if (ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === name) return p.initializer; + } + return null; + }; + const visit = (node: ts.Node): void => { + if ( + ts.isCallExpression(node) + && ts.isIdentifier(node.expression) + && node.expression.text === 'strictObject' + && node.arguments.length === 2 + && ts.isObjectLiteralExpression(node.arguments[0]) + ) { + const opts = node.arguments[0]; + const surfaceNode = prop(opts, 'surface'); + const surface = surfaceNode ? literal(surfaceNode) : null; + if (surface) { + const table: Record = {}; + const aliases = prop(opts, 'aliases'); + if (aliases && ts.isObjectLiteralExpression(aliases)) { + for (const p of aliases.properties) { + if (!ts.isPropertyAssignment(p)) continue; + const key = ts.isIdentifier(p.name) || ts.isStringLiteral(p.name) ? p.name.text : null; + const target = literal(p.initializer); + if (key && target) table[key] = target; + } + } + out.set(surface, table); + } + } + ts.forEachChild(node, visit); + }; + visit(source); + return out; +} + +/** Unwrap lazy / optional / default / array / effects down to a plain object's `.shape`. */ +function shapeOf(schema: unknown, depth = 0): Record | null { + if (depth > 12 || schema == null) return null; + const direct = (schema as { shape?: Record }).shape; + if (direct && typeof direct === 'object') return direct; + const def = (schema as { _zod?: { def?: Record } })._zod?.def; + if (!def) return null; + for (const key of ['innerType', 'element', 'in', 'out', 'schema', 'type'] as const) { + const inner = def[key]; + if (inner && typeof inner === 'object') { + const found = shapeOf(inner, depth + 1); + if (found) return found; + } + } + if (typeof (def as { getter?: unknown }).getter === 'function') { + return shapeOf((def as { getter: () => unknown }).getter(), depth + 1); + } + return null; +} + +/** + * The object arm of a union — the only arm an unknown-key error can come from. + * + * Unwraps the wrappers first: on the widget, `compareTo` is + * `ZodOptional(ZodUnion([...]))`, so reading `def.options` off the schema handed + * in finds nothing. The first draft did exactly that and the suite went red on + * *"could not resolve the shape behind this comparison window"* rather than + * quietly skipping the surface — the walker's hard-failure guard doing its job + * (ledger finding 9: a walker going quiet is precisely when it stops covering + * something). + */ +function unionObjectArm(schema: unknown, depth = 0): Record | null { + if (depth > 12 || schema == null) return null; + const def = (schema as { _zod?: { def?: Record } })._zod?.def; + if (!def) return null; + const options = (def as { options?: unknown[] }).options; + if (Array.isArray(options)) { + for (const arm of options) { + const shape = shapeOf(arm); + if (shape) return shape; + } + return null; + } + for (const key of ['innerType', 'in', 'out', 'schema'] as const) { + const inner = def[key]; + if (inner && typeof inner === 'object') { + const found = unionObjectArm(inner, depth + 1); + if (found) return found; + } + } + if (typeof (def as { getter?: unknown }).getter === 'function') { + return unionObjectArm((def as { getter: () => unknown }).getter(), depth + 1); + } + return null; +} + +describe('批 14 — no prescription points at a key the schema rejects', () => { + /** + * `surface` string → the DECLARED keys of the shape that surface names, + * resolved at RUNTIME rather than from the source object literal. + * + * Runtime, specifically, because a shape can spread (`...MetadataProtectionFields`) + * and a source-literal reader cannot see through that — it has to suppress the + * check for every spreading schema, which is most of the interesting ones. + * `.shape` sees the spread keys. + */ + const declaredKeys = (): Map> => { + const widget = shapeOf(DashboardWidgetSchema)!; + const measure = shapeOf(DatasetMeasureSchema)!; + const entries: Array<[string, Record | null]> = [ + ['this action param option', shapeOf(shapeOf(ActionParamSchema)!.options)], + ['this sharing config', shapeOf(SharingConfigSchema)], + ['this report order key', shapeOf(ReportSortSchema)], + ['this joined report block', shapeOf(JoinedReportBlockSchema)], + ['this dataset dimension', shapeOf(DatasetDimensionSchema)], + ['this dataset measure', measure], + ['this derived-measure spec', shapeOf(measure.derived)], + ['this comparison window', unionObjectArm(widget.compareTo)], + ['this widget layout box', shapeOf(widget.layout)], + ]; + return new Map(entries.map(([surface, shape]) => { + expect(shape, `could not resolve the shape behind "${surface}"`).toBeTruthy(); + return [surface, new Set(Object.keys(shape!))]; + })); + }; + + /** + * Pre-existing defects in tables this batch did NOT write, each already filed. + * Listed rather than skipped so the instrument stays complete over these six + * files: the day one is fixed, its entry here fails and gets deleted. + */ + const KNOWN_DEFECTS: ReadonlyArray = [ + // [surface, alias key, issue] + ['this report', 'columns', '#5013'], + ['this report', 'chart', '#5013'], + ['this report', 'filter', '#5013'], + ['this dataset', 'measures', '#5013'], + ['this dataset', 'filter', '#5013'], + ['this action', 'body', '#5013'], + ]; + + const BATCH14_SURFACES = [ + 'this action param option', 'this sharing config', 'this report order key', + 'this joined report block', 'this dataset dimension', 'this dataset measure', + 'this derived-measure spec', 'this comparison window', 'this widget layout box', + ] as const; + + const FILES = [ + 'ui/action.zod.ts', 'ui/sharing.zod.ts', 'ui/report.zod.ts', + 'ui/dataset.zod.ts', 'ui/dashboard.zod.ts', + ]; + + it('the AST reader really finds this batch\'s tables (self-test before the verdict)', () => { + const found = new Set(); + for (const f of FILES) for (const s of aliasTablesBySurface(path.join(SPEC_SRC, f)).keys()) found.add(s); + for (const surface of BATCH14_SURFACES) { + expect(found, `AST reader lost the table for "${surface}"`).toContain(surface); + } + // And it reads real content, not empty tables. + const sharing = aliasTablesBySurface(path.join(SPEC_SRC, 'ui/sharing.zod.ts')); + expect(sharing.get('this sharing config')!.anonymous).toBe('allowAnonymous'); + }); + + it('every alias TARGET this batch added is a key the schema really declares', () => { + const declared = declaredKeys(); + const failures: string[] = []; + for (const f of FILES) { + for (const [surface, table] of aliasTablesBySurface(path.join(SPEC_SRC, f))) { + const keys = declared.get(surface); + if (!keys) continue; // not a 批 14 surface — covered by the next test + for (const [written, target] of Object.entries(table)) { + if (!keys.has(target)) failures.push(`${surface}: \`${written}\` -> \`${target}\` (not declared)`); + if (keys.has(written)) failures.push(`${surface}: \`${written}\` is itself declared (dead entry)`); + } + } + } + expect(failures).toEqual([]); + }); + + it('the pre-existing defects in these files are exactly the ones already filed', () => { + // A reverse pin, the ADR-0010 debt-list idiom: this list cannot outlive its + // debt. Fix one upstream and this test names it and fails. + const seen: string[] = []; + for (const f of FILES) { + for (const [surface, table] of aliasTablesBySurface(path.join(SPEC_SRC, f))) { + if ((BATCH14_SURFACES as readonly string[]).includes(surface)) continue; + const shape = surface === 'this report' ? shapeOf(ReportSchemaForAudit) + : surface === 'this dataset' ? shapeOf(DatasetSchemaForAudit) + : surface === 'this action' ? shapeOf(ActionSchemaForAudit) + : null; + if (!shape) continue; + const keys = new Set(Object.keys(shape)); + for (const [written, target] of Object.entries(table)) { + if (keys.has(written) || !keys.has(target)) seen.push(`${surface}|${written}`); + } + } + } + expect(seen.sort()).toEqual( + KNOWN_DEFECTS.map(([s, k]) => `${s}|${k}`).sort(), + ); + }); + + it('the action param option shape accepts exactly the pair it prescribes', () => { + const r = ActionParamSchema.safeParse({ name: 'p', options: [{ label: 'A', value: 'a' }] }); + expect(r.success).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// 2. No-door pins — RED the day either shape gains a carrier key +// --------------------------------------------------------------------------- + +/** + * Every `.ts` module under `packages/spec/src`, excluding tests. + */ +function specModules(dir = SPEC_SRC, out: string[] = []): string[] { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, entry.name); + if (entry.isDirectory()) specModules(p, out); + else if (entry.name.endsWith('.ts') && !entry.name.includes('.test.')) out.push(p); + } + return out; +} + +/** + * Modules that import the module at `targetRel` (spec-src-relative, no + * extension), as a sorted list of spec-src-relative paths. + * + * Two properties this needs, and the first draft had neither — both found by + * running the pin before believing it: + * + * - **All three import forms.** A matcher that knows only `import … from '…'` + * misses the barrel's `export * from '…'` and a bare side-effect `import '…'`, + * and would land as a partly-hollow green. (批 13 hit the identical gap.) + * - **Specifier RESOLUTION, not substring matching.** This repo has TWO + * `sharing.zod` modules — `ui/sharing.zod` and `security/sharing.zod` — so a + * substring test credits `stack.zod.ts` and `security/index.ts` with + * importing the UI one. Every specifier is therefore resolved against its + * importer's own directory before comparing. + */ +function importersOf(targetRel: string): string[] { + const target = path.resolve(SPEC_SRC, targetRel); + const SPECIFIER = + /(?:^|\n)\s*(?:import\s[\s\S]*?from\s*|export\s[\s\S]*?from\s*|import\s*)['"]([^'"]+)['"]/g; + const out: string[] = []; + for (const file of specModules()) { + const source = fs.readFileSync(file, 'utf8'); + for (const [, specifier] of source.matchAll(SPECIFIER)) { + if (!specifier.startsWith('.')) continue; + if (path.resolve(path.dirname(file), specifier) !== target) continue; + out.push(path.relative(SPEC_SRC, file).split(path.sep).join('/')); + break; + } + } + return out.sort(); +} + +describe('批 14 — the two no-door shapes stay unclosed, and say so', () => { + it('the importer pin sees all three import forms and does not confuse same-named modules', () => { + // Self-test first: an assertion about "who imports X" is worthless if the + // matcher only knows one spelling. `ui/i18n.zod` is imported with + // `import … from` by several ui modules AND re-exported by the barrel with + // `export * from`. + const i18n = importersOf('ui/i18n.zod'); + expect(i18n).toContain('ui/index.ts'); // export * from + expect(i18n).toContain('ui/notification.zod.ts'); // import … from + expect(i18n.length).toBeGreaterThan(2); + + // And the discriminating case: two `sharing.zod` modules exist. Resolution, + // not substring matching, is what keeps them apart. + expect(importersOf('security/sharing.zod')).toContain('stack.zod.ts'); + expect(importersOf('ui/sharing.zod')).not.toContain('stack.zod.ts'); + }); + + it('ui/notification.zod has exactly one importer — the barrel. Nothing can carry NotificationAction', () => { + expect(importersOf('ui/notification.zod')).toEqual(['ui/index.ts']); + }); + + it('ui/sharing.zod is imported by the barrel and view.zod — which carries SharingConfig, NOT EmbedConfig', () => { + expect(importersOf('ui/sharing.zod')).toEqual(['ui/index.ts', 'ui/view.zod.ts']); + // The carrier is specific, and that asymmetry IS the reclassification: the + // form view names `SharingConfigSchema`, and no module anywhere names + // `EmbedConfigSchema`. Pin the symbol, not the file. + const namesEmbed = specModules() + .filter((f) => !f.endsWith('sharing.zod.ts')) + .filter((f) => /\bEmbedConfigSchema\b/.test(fs.readFileSync(f, 'utf8'))) + .map((f) => path.relative(SPEC_SRC, f).split(path.sep).join('/')); + expect(namesEmbed).toEqual([]); + expect(fs.readFileSync(path.join(SPEC_SRC, 'ui/view.zod.ts'), 'utf8')) + .toContain('SharingConfigSchema'); + }); + + it('both stay strip — a precisely-validated dead slot is the more convincing lie (#4583)', () => { + // Closing either would spend a v17 breaking change on a shape nothing + // parses. If someone closes one, this fails and points at the ledger row. + expect(NotificationActionSchema.safeParse({ label: 'Undo', action: 'undo', bogus: 1 }).success) + .toBe(true); + expect(EmbedConfigSchema.safeParse({ enabled: true, bogus: 1 }).success).toBe(true); + }); + + it('their prose records the verdict where the next reader will look', () => { + const notification = fs.readFileSync(path.join(SPEC_SRC, 'ui/notification.zod.ts'), 'utf8'); + const sharing = fs.readFileSync(path.join(SPEC_SRC, 'ui/sharing.zod.ts'), 'utf8'); + for (const source of [notification, sharing]) { + expect(source).toContain('NO AUTHORING DOOR'); + expect(source).toContain('#4001 批 14'); + } + }); +});