From a85a124ce88bdfedc8012e577f310b298a99d070 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 04:56:32 +0000 Subject: [PATCH] =?UTF-8?q?feat(lint,spec):=20dispatch=20ComponentPropsMap?= =?UTF-8?q?=20by=20`type`=20=E2=80=94=20the=20SDUI=20props=20bag=20gets=20?= =?UTF-8?q?its=20parse=20(#5068)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PageComponent.properties` is `z.record(z.string(), z.unknown())`, and ADR-0089 D3a strictness does not recurse into it, so the 31 typed prop schemas in `ComponentPropsMap` were parsed by nothing (#4001 batch 17's `no gate` verdict). objectui's SchemaRenderer hoists the bag and spreads every key it carries, so a misspelled prop is neither rejected nor dropped — it reaches the renderer and is ignored there. New advisory rule `validate-component-props` (packages/lint), wired into the shared authoring registry so `os validate` / `os build` / `os lint` all run it. It dispatches on the component's `type` and emits two ids: `component-props-unknown-key` (via the spec's own authoring-key walker, newly exported as `lintUnknownKeysAgainstSchema` so the posture rules are not re-derived) and `component-props-invalid` (via `safeParse`). A strict props schema routes its `unrecognized_keys` to the first id, so a future `strictObject` batch moves coverage between the halves without moving it out of the author's view. Unregistered `type`s are skipped: `type` is an open union and the example corpus alone authors 87 nodes of ten types this map does not carry. Warning-level only, deliberately. The live corpus violates the declarations in 52 places, 42 of which are open contract questions (#5728's inline i18n label maps, #5775's declared-but-unread record-picker props), so gating today would fail the platform's own pages. The inventory is the acceptance baseline for the error upgrade. `component.zod.ts` moves `no gate` -> `authorable` in the strictness ledger. The carrier itself is unchanged (direction B declined), so `component.test.ts`'s three standing assertions stay green — measured, and their prose updated to say which dispatch landed. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D --- .changeset/sdui-component-props-gate.md | 48 +++ ...07-unknown-key-strictness-ledger.counts.md | 8 +- .../2026-07-unknown-key-strictness-ledger.md | 18 +- packages/lint/src/authoring-rules.ts | 37 +++ packages/lint/src/index.ts | 7 + .../lint/src/validate-component-props.test.ts | 290 ++++++++++++++++++ packages/lint/src/validate-component-props.ts | 248 +++++++++++++++ .../lint/src/validate-react-page-props.ts | 100 +----- packages/lint/src/zod-issue-format.ts | 114 +++++++ packages/spec/api-surface.json | 2 + packages/spec/src/index.ts | 1 + .../src/kernel/metadata-authoring-lint.ts | 53 +++- packages/spec/src/ui/component.test.ts | 54 +++- packages/spec/src/ui/component.zod.ts | 59 +++- 14 files changed, 906 insertions(+), 133 deletions(-) create mode 100644 .changeset/sdui-component-props-gate.md create mode 100644 packages/lint/src/validate-component-props.test.ts create mode 100644 packages/lint/src/validate-component-props.ts create mode 100644 packages/lint/src/zod-issue-format.ts diff --git a/.changeset/sdui-component-props-gate.md b/.changeset/sdui-component-props-gate.md new file mode 100644 index 0000000000..3f9d779c23 --- /dev/null +++ b/.changeset/sdui-component-props-gate.md @@ -0,0 +1,48 @@ +--- +"@objectstack/lint": minor +"@objectstack/spec": minor +--- + +feat(lint,spec): SDUI 组件 props 接上解析闸门 —— `ComponentPropsMap` 不再是「声明了、从不被 parse」(#5068) + +`PageComponent.properties` 是 `z.record(z.string(), z.unknown())` 这个开放口袋。 +`PageComponentSchema` 自 ADR-0089 D3a 起是 `.strict()`,但**严格性不递归**:它守住 +component 节点自己的键,`properties` 里面一个字都没人看。于是 `ComponentPropsMap` +里 31 个 typed props schema 从来没有被任何东西 parse 过(#4001 批 17 的 `no gate` +判定:载体活着、parse 缺席)。后果不是无害的 —— objectui 的 `SchemaRenderer` 会把 +`properties` 整个 hoist 到节点上,再把 deny-list 之外的每个键 spread 成 React prop, +所以一个拼错的键既不被拒绝也不被丢弃:它一路走到渲染器,在那里被忽略,而作者拿到 +的是一张成功回执。这正是 ADR-0078 要消灭的形状。 + +**新规则(两个诊断 id,均为 warning 级)**,落在 `@objectstack/lint`,按维护者对 +#5068 的裁定走方向 A —— 在载体自己的授权门上分派解析,而不是改 `page` 协议的形状: + +- **`component-props-unknown-key`** —— props schema 未声明的键,包括 props 包自身 + 这一层和它底下每一个 strip 姿态的对象。走的是 `lintUnknownKeysAgainstSchema` + (本次从 `@objectstack/spec` 导出,即 `lintUnknownAuthoringKeys` 用在每个 metadata + 集合上的同一个 walker),所以 strip/strict/passthrough 的姿态规则与改名建议都是 + 单一实现,这里不重新推导一遍。 +- **`component-props-invalid`** —— props schema 拒绝的值:类型不对、必填缺失、枚举 + 越界。 + +配套的一条契约细节:走到第二层。`readonly`(#5176)挂在 `RecordHighlightsField` +联合体的对象成员上,即 `fields[]` 数组项里面 —— authorable-surface walk 严格一层、 +到不了那里(#5607 的更正)。本闸门到得了,并且两个方向都钉了测试:声明过的 +`readonly` 必须静默,拼错的 `readOnly` 必须报出来并指名正确拼法。 + +**未注册 type 一律跳过**,这是必须语义而不是宽松:`PageComponentSchema.type` 是 +`z.union([PageComponentType, z.string()])`,光是仓内 example 语料就授权了 10 种本 +map 不承载的类型、共 87 个节点(`flex`、`grid`、`object-metric`、`object-chart`、 +`record:line_items` …),它们的契约在 objectui 注册表和 ADR-0080 manifest 里。拿一个 +不存在的 schema 去审判它们,只会把每一个都报成坏的。 + +**为什么本步只落 warning。**接上 parse 是执法的前置条件,不是执法本身(#5020 在隔壁 +表面上的同一课)。闸门落在真实语料上会报 52 条:其中 34 条是三个已发布平台页把 +`{ en, 'zh-CN' }` 内联多语言 map 写进了声明为纯 `z.string()` 的 `I18nLabelSchema` +(#5728,裁定中),另有 8 条是 `element:text.content` 上同一形状。今天就 gate 掉它们, +等于用平台自己都不遵守的声明去否掉平台自己的页面。warning 期的违例清单就是 error +升级的验收基线,升级本身是独立一步。 + +作者侧不变:`properties` 仍然照原样解析、原样保留,没有任何东西开始被拒绝 —— +`os validate` / `os build` / `os lint` 多了一类建议性诊断而已。存储路径(`saveMetaItem` +/ REST `/meta`)仍然不校验 props 包,这一点被如实记录、未在本次修复。 diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index f1bcb7b70e..de74a738fc 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -29,11 +29,11 @@ Remaining strip sites by class: | Bucket | Sites | |---|---| -| authorable — the ruling's forced scope | 13 | +| authorable — the ruling's forced scope | 43 | | unresolved — needs a per-schema verdict | 33 | | wire / open — out of forced scope | 107 | | no door — no carrier, ADR-0049 territory | 14 | -| no gate — carrier live, no parse | 30 | +| no gate — carrier live, no parse | 0 | ## Posture, per triaged directory @@ -171,11 +171,11 @@ over it is here. | Bucket | Sites | |---|---| -| authorable — the ruling's forced scope | 4 | +| authorable — the ruling's forced scope | 34 | | unresolved — needs a per-schema verdict | 0 | | wire / open — out of forced scope | 3 | | no door — no carrier, ADR-0049 territory | 14 | -| no gate — carrier live, no parse | 30 | +| no gate — carrier live, no parse | 0 | ### `data/` — open diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 6ff6de0b2e..b984d1dcb9 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -628,7 +628,7 @@ sites left to be a verdict about. | `action-params.zod.ts` | wire | **never strict, deliberately** — one site: `ActionSessionSchema`, the action-body `ctx.session` declared at **#5697** (phase 1 of #5613's contract-first ruling). It is the RUNTIME shape `packages/runtime`'s `buildActionSession()` hands a body, not an authoring surface — nobody writes it — so closing it would turn a future engine-side enrichment into a parse failure for whoever parses a context they were GIVEN: the same call, and the same reason, as `data/hook.zod.ts`'s `HookContextSchema` row. The file had no row until now because everything else it exports is a function or an interface (`validateActionParams`, `ActionHandlerContext`), i.e. zero sites to classify. ⚠️ **Read the arrival direction, because it is the opposite of every other row in this table**: this site did not survive a strictness sweep, it is NEW surface — a shape that was being produced with no declaration anywhere (`actionContext` is a bare `any` at both dispatch sites), which is why neither this ledger nor any gate could see that its `roles` key carries `ExecutionContext.positions` under the spelling ADR-0090 D3 forbids. The key is declared as-built and marked deprecated in its `.describe()`; the rename is #5613 phase 2. A declaration is not an endorsement — do not read this row as "the shape is settled" | | `view.zod.ts` | authorable | partially strict (ADR-0089); long tail of sub-blocks. `bulkActionDefs` left this file in #4457 — see the row below | | `bulk-action.zod.ts` | 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` | ~~authorable (p)~~ **no gate** | **no parse anywhere (measured, #4001 批 17)** — the `(p)` resolved NEGATIVE, and this is the campaign's largest single reclassification. The standing warning said to verify objectui's React-prop open slots first; doing so found the question was moot one level up. **The carrier is live but it is an open bag**: `PageComponentSchema.properties` is `z.record(z.string(), z.unknown())`, and although `PageComponentSchema` has been `.strict()` since ADR-0089 D3a, **strictness does not recurse** — it closes the component node's own keys and leaves everything under `properties` unchecked. Nothing dispatches `ComponentPropsMap` by `type`. Three measurements on 2026-08-04, controls green in the same run: (1) a BFS from all 24 metadata-type roots plus `ObjectStackSchema`, over a 6899-node closure built with `build-schemas.ts`'s own `zodChildSchemas`/`zodShapeOf` (the #4650 walk), returns **UNREACHABLE for all 52 targets** (21 exported schemas + every one of `ComponentPropsMap`'s 31 entries), while `PageSchema`/`PageComponentSchema`/`PageRegionSchema`/`ThemeSchema`/`ChartConfigSchema`/`ResponsiveConfigSchema` all resolve `root-graph` and 批 13's no-door shapes stay unreachable — the walk stops dead at `properties`. ⚠️ The #5056 bridge defect does not touch this row: it makes the derived-clone bridge report dead shapes as REACHABLE, the opposite direction, and nothing here rests on that bridge — all six positive controls resolve `root-graph` and all 52 targets miss BOTH `root-graph` and `derived-clone`; (2) across `objectstack`, `objectui` and `cloud`, every `.parse()`/`.safeParse()` on anything in this file is inside the file's own unit tests — objectui mirrors the props as hand-written React interfaces and imports only the inferred TYPES, `cloud` references none, and `react-blocks.ts` uses `Object.keys(ComponentPropsMap)` for type NAMES only (its `REACT_BLOCKS[].schema` entries all point at view/chart schemas); (3) empirically through the live door — `definePage()` IS `PageSchema.parse()` — an undeclared key written inside `components[].properties` parses clean and is RETAINED on 10/10 example-corpus pages, while the same key one level out is rejected on 10/10 (the negative control that makes the first number mean anything). ⚠️ **`no gate`, not `no door`** — the vocabulary is ALIVE and must not be retired: objectui's `SchemaRenderer` hoists `properties` onto the node and spreads every key not on its fixed deny-list straight into the React component, so a misspelled key is neither rejected nor dropped — it reaches the renderer and is ignored there, the ADR-0078 failure mode one layer below where this ratchet reaches. That IS the #4909 open-slot shape, but `.passthrough()` would be exactly as vacuous as `.strict()` on a schema nothing parses, so no posture change was made. The fix is to wire the parse at the carrier's own gate — a `packages/lint`/carrier change, filed as **#5068**, which also records the two constraints that stop it being a drive-by: `type` is an open union (`z.union([PageComponentType, z.string()])`, so `record:line_items`-style unregistered types are authored in the wild) and real pages already author shapes these schemas do not declare (`record:details` `sections[].fields[]`/`hideFields[]`, the record picker's `labelField` — `packages/lint/src/validate-page-field-bindings.ts` has documented the untyped bag all along). **Do not reschedule this as strictness work** — that is what the `(p)` was for, and it has been answered. Recorded in three places (file header, `component.test.ts` pin incl. a standing assertion that goes red the day `properties` gets a typed dispatch, this row) | +| `component.zod.ts` | ~~authorable (p)~~ ~~no gate~~ **authorable (gate wired at #5068)** | **no parse anywhere (measured, #4001 批 17; the parse landed at #5068 — see the end of this cell)** — the `(p)` resolved NEGATIVE, and this is the campaign's largest single reclassification. The standing warning said to verify objectui's React-prop open slots first; doing so found the question was moot one level up. **The carrier is live but it is an open bag**: `PageComponentSchema.properties` is `z.record(z.string(), z.unknown())`, and although `PageComponentSchema` has been `.strict()` since ADR-0089 D3a, **strictness does not recurse** — it closes the component node's own keys and leaves everything under `properties` unchecked. Nothing dispatches `ComponentPropsMap` by `type`. Three measurements on 2026-08-04, controls green in the same run: (1) a BFS from all 24 metadata-type roots plus `ObjectStackSchema`, over a 6899-node closure built with `build-schemas.ts`'s own `zodChildSchemas`/`zodShapeOf` (the #4650 walk), returns **UNREACHABLE for all 52 targets** (21 exported schemas + every one of `ComponentPropsMap`'s 31 entries), while `PageSchema`/`PageComponentSchema`/`PageRegionSchema`/`ThemeSchema`/`ChartConfigSchema`/`ResponsiveConfigSchema` all resolve `root-graph` and 批 13's no-door shapes stay unreachable — the walk stops dead at `properties`. ⚠️ The #5056 bridge defect does not touch this row: it makes the derived-clone bridge report dead shapes as REACHABLE, the opposite direction, and nothing here rests on that bridge — all six positive controls resolve `root-graph` and all 52 targets miss BOTH `root-graph` and `derived-clone`; (2) across `objectstack`, `objectui` and `cloud`, every `.parse()`/`.safeParse()` on anything in this file is inside the file's own unit tests — objectui mirrors the props as hand-written React interfaces and imports only the inferred TYPES, `cloud` references none, and `react-blocks.ts` uses `Object.keys(ComponentPropsMap)` for type NAMES only (its `REACT_BLOCKS[].schema` entries all point at view/chart schemas); (3) empirically through the live door — `definePage()` IS `PageSchema.parse()` — an undeclared key written inside `components[].properties` parses clean and is RETAINED on 10/10 example-corpus pages, while the same key one level out is rejected on 10/10 (the negative control that makes the first number mean anything). ⚠️ **`no gate`, not `no door`** — the vocabulary is ALIVE and must not be retired: objectui's `SchemaRenderer` hoists `properties` onto the node and spreads every key not on its fixed deny-list straight into the React component, so a misspelled key is neither rejected nor dropped — it reaches the renderer and is ignored there, the ADR-0078 failure mode one layer below where this ratchet reaches. That IS the #4909 open-slot shape, but `.passthrough()` would be exactly as vacuous as `.strict()` on a schema nothing parses, so no posture change was made. The fix is to wire the parse at the carrier's own gate — a `packages/lint`/carrier change, filed as **#5068**, which also records the two constraints that stop it being a drive-by: `type` is an open union (`z.union([PageComponentType, z.string()])`, so `record:line_items`-style unregistered types are authored in the wild) and real pages already author shapes these schemas do not declare (`record:details` `sections[].fields[]`/`hideFields[]`, the record picker's `labelField` — `packages/lint/src/validate-page-field-bindings.ts` has documented the untyped bag all along). **Do not reschedule this as strictness work** — that is what the `(p)` was for, and it has been answered. ✅ **#5068 answered it in turn: the parse is wired (lint side, warning level) and the class is `authorable` again** — the strip-table row below carries the full flip, including the three things it did not do. One correction belongs here rather than there, because this row is where the wrong expectation was written down: the pin in `component.test.ts` said its carrier assertion *"goes red the day `properties` gets a typed dispatch"*, which assumed direction B. The dispatch that landed is direction A — on `packages/lint`'s authoring gate — so the assertion is GREEN after the fix, and it was measured that way rather than predicted. The pin now says which dispatch landed and what a future red there would mean (the carrier reshaped: a protocol change, not a lint one). Recorded in three places (file header, `component.test.ts` pin, this row) | | `theme.zod.ts` | authorable | **strict as of #4001 批 15** — all 14 sites. The `(p)` resolved to authorable on two doors, both measured: `stack.zod.ts` declares `themes: z.array(ThemeSchema)` (so `defineStack()` parses every theme on boot and on `objectstack build`), and `defineTheme()` parses one directly. A BFS from all 24 metadata-type roots plus `ObjectStackSchema` reaches every schema in the file, with `PageSchema`/`DashboardSchema`/`ReportSchema`/`WebhookSchema`/`StateMachineSchema` passing as positive controls and 批 13's no-door shapes failing as negative controls **in the same run**. Note what is NOT claimed: `theme` is deliberately absent from `BUILTIN_METADATA_TYPE_SCHEMAS`, so a stored theme row is not validated by the metadata REST door — the gate is the authoring one, and the file says so rather than implying reach it lacks. **The `passthrough` question was asked per BLOCK, not per file**, and the answer split: objectui's `ThemeEngine` reads `colors`/`borderRadius`/`shadows`/`typography.fontFamily` through FIXED maps (an extra key is read by nothing, ever), but spreads `fontSize`/`fontWeight`/`lineHeight`/`letterSpacing`/`duration`/`timing`/`zIndex` with `Object.entries` into `--font-size-` … — the #4909 open shape at the runtime. Closed anyway, on two measurements: `.strip` already discarded those extras before the engine saw them (so no author depends on the openness and nothing the renderer receives changes), and `customVars` is a DECLARED escape hatch that emits an arbitrary CSS custom property by name, so closing the token scales removes no capability and only removes a second, undocumented way to spell one — the way whose typos are indistinguishable from intent. Curation is measured throughout: the shadcn vocabulary (`card`→`surface`, `foreground`→`text`, `destructive`→`error`) comes from objectui's own `COLOR_TO_CSS_MAP`, which RENAMES every palette key on the way out; `md`→`base` on `fontSize` and `base`→`normal` on `fontWeight` are a same-file scale disagreement (`borderRadius`/`shadows` declare `md`, `fontSize` does not); `radius`→`base` because `base` is emitted as the bare `--radius`, the one radius variable objectui's CSS actually reads; and `easeIn`→`ease_in` because `animation.timing` is the file's single snake_case vocabulary, so the camelCase spelling is an author obeying AGENTS.md #3 rather than making a typo. The eight #3494 removals get one distinct tombstone each. ⚠️ **Two of those tombstones deliberately prescribe NO replacement slot**: `touchTarget`/`keyboardNavigation` read like they should point at `ui/touch.zod.ts`/`ui/keyboard.zod.ts`, which 批 13 measured as having no carrier at all — prescribing them would walk an author out of a loud rejection into a silent one, the ledger's finding 7. **#4988 then retired both modules outright**, so the two tombstones' refusal to name a replacement is now the only correct wording available: had they pointed at `ui/touch.zod.ts` / `ui/keyboard.zod.ts`, that prescription would today name a deleted file — finding 7 with an extra major on top. ⚠️ **Separately filed — and ANSWERED at #5021, which is why this row's site count fell 14 → 6.** 批 15 recorded that `--font-size-*`, `--font-weight-*`, `--line-height-*`, `--letter-spacing-*`, `--z-*`, `--duration-*`, `--timing-*`, `--font-heading` and `--font-mono` have ZERO first-party consumers (only the colour vars, `--radius*`, `--shadow*` and `--font-sans` are read), and refused to act on it inside a strictness batch: that is ADR-0049 liveness, not unknown keys, and the two must not be run together — strictness makes a dropped key loud, it cannot make a slot live. The refusal was correct and the separation is what made the follow-up answerable. #5021 re-measured against objectui `main` (2026-08-04) with `--font-sans`/`--radius`/`--shadow`/`--primary` as positive controls **in the same run**, the maintainer ruled RETIRE over both alternatives (wire consumers / bless as a public token surface — the latter rejected as a stability promise attached to a slot the platform's own UI ignores, the #4583 shape), and `typography.fontSize`/`.fontWeight`/`.lineHeight`/`.letterSpacing`, `typography.fontFamily.heading`/`.mono`, `animation` and `zIndex` are now `retiredKey()` tombstones prescribing `customVars`. **Note what this row's arithmetic does NOT say**: the eight sites left `ui/` from the `strict` column (120 → 112), and `strip` is unchanged at 75 — a retirement removes closed doors, so it cannot move this ratchet's open-site debt in either direction. The two campaigns stayed disjoint to the end. ⚠️ The prescription is `customVars` **because it was measured live**, not because it is the nearest-looking slot: the engine emits each entry as `--: ` verbatim, so every retired variable is reproducible byte for byte and the retirement removes no capability — the distinction from `touchTarget`/`keyboardNavigation` two sentences up, which got NO replacement precisely because theirs would have been a guess. The five aliases pointing at the retired keys (`animations`/`motion`/`transitions` → `animation`, `layers`/`stacking` → `zIndex`) and the seven pointing into the retired typography scales were **deleted with their targets**, not re-pointed — leaving them would answer an author with "did you mean `zIndex`?" and then reject `zIndex`, finding 7's exact shape, and this file has now signposted that failure mode three times | | `app.zod.ts` | 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` | 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. ⚠️ **The `compareTo` union caveat this row carried is RESOLVED, and it is the one entry in this table whose limit was dissolved rather than worked around.** 批 14 recorded that `compareTo` was a UNION, so its curated prescription was produced but never delivered — `zodIssuesToFields` maps only top-level issues and a failed union collapses to a bare `Invalid input` (#5014) — with the rejection itself unaffected. **#5011 removed the union**: the slot converged onto the analytics executor's own contract, `{ kind, dimension? }`, a plain strict object whose message IS top-level. The reason was not the message, it was worse — all three declared arms were broken on the ADR-0021 dataset path (the two strings silently dropped by the renderer, `{ offset }` throwing `compareTo requires a timeDimension "undefined"`), while all three worked on the legacy inline path: same key, two fates, the failing one blessed. The union-free shape is the design benefit, pinned in `dashboard-compareto.test.ts` so it cannot silently return. **#5014 still binds every OTHER curated message this campaign has put inside a union arm** — this row is one slot's correction, not the finding's retraction. ⚠️ **#5010 retired four more widget keys and moved this row's posture by nothing, which is the point.** The `#4956` drill gave `DashboardWidgetSchema`'s 22 widget-level keys their first per-key verdicts and found six dead; `actionUrl`/`actionType`/`actionIcon` (a per-widget action BUTTON no renderer in either repo has ever drawn — all 14 `actionUrl` reads in `DashboardRenderer` are scoped to `header.actions[]`) and `aria` (ARIA attributes that never reached the DOM — the dashboard-level `aria` the #3896 sweep removed, one level down) are now `retiredKey` tombstones beside `responsive`. **Strip sites remain 0 and the strictness verdict is untouched**, because a retirement is ADR-0049 work and this ratchet is not: closing a door makes a *dropped* key loud, it cannot make a *declared* one live — the same boundary `theme.zod.ts` records two rows up, met here from the other side. The removal also settled a second-order cost the strictness campaign could never have reached: `packages/lint`'s dashboard action-ref rule enforced ERROR-severity reference integrity on `widgets[].actionUrl`, its docblock calling the key "the per-widget button" and claiming to mirror a runtime dispatch that does not exist, so an author could FAIL A BUILD because a control that cannot render pointed at an action that also did not — an enforcement gate sustaining the very false affordance ADR-0049 wrote it to delete. That widget branch is gone, pinned. ⚠️ **`colorVariant`, the fifth dead key, is deliberately NOT retired here and this row must not be read as closing it**: the rewrite target the #4956 triage assumed (`options.colorVariant`) measured dead too — `options` only reaches a renderer through `componentSchema` on the INLINE path, and `dataset` is required on this schema, so every spec-authorable widget is dataset-bound and renders through `DatasetWidget`, which has no colour affordance at all. Moving the key there would relocate 16 authored sites from one dead slot to another and mint a second inert key. Returned for adjudication; `chartConfig`'s dashboard-face inertness (11 of 12 keys, #5175) is the same shape on the neighbouring slot | @@ -844,7 +844,7 @@ next person to open that file will look. | File | Class | Batch | |---|---|---| -| `component.zod.ts` | **no gate** | ⛔ **not strictness work** — measured at 批 17 as having no parse at all: BFS-unreachable from every metadata root (all 52 targets, controls green in the same run), zero production `.parse()` sites in the three repos, and an unknown key inside `components[].properties` demonstrably survives the live `definePage()` door. The carrier (`PageComponentSchema.properties`) is live but is `z.record(z.string(), z.unknown())` — ADR-0089 D3a strictness does not recurse into it. Closing these 29 sites would gate nothing (#4583). Blocked on wiring the parse at the carrier — **#5068**. See the triage row for the full measurement | +| `component.zod.ts` | **authorable** | **was `no gate` until #5068** (one verdict per cell on purpose — it is the machine-readable input to the generated subtotal, so the history lives here in the evidence). ⛔ **was not strictness work** — measured at 批 17 as having no parse at all: BFS-unreachable from every metadata root (all 52 targets, controls green in the same run), zero production `.parse()` sites in the three repos, and an unknown key inside `components[].properties` demonstrably survives the live `definePage()` door. The carrier (`PageComponentSchema.properties`) is live but is `z.record(z.string(), z.unknown())` — ADR-0089 D3a strictness does not recurse into it. Closing these 29 sites would have gated nothing (#4583), so the batch recorded the verdict and filed the wiring as **#5068**. ✅ **#5068 wired it, and this row's `no gate` verdict is spent — the sites are `authorable`.** `packages/lint/src/validate-component-props.ts` dispatches `ComponentPropsMap` by the component's `type` and judges `properties`: undeclared keys through `lintUnknownKeysAgainstSchema` (the same walker `lintUnknownAuthoringKeys` runs on every metadata collection — one implementation of the posture rules, not a second), values through `safeParse`. It runs on all three authoring commands from the shared registry. **Read the flip precisely, exactly as at #5020: what changed is the PARSE, not the posture.** All 31 entries still STRIP; the gate reports an undeclared key because the walker reads a strip-mode object, and converting these sites to `strictObject` moves that same report into the gate's `safeParse` half (`unrecognized_keys`, routed to the same rule id) — which is what makes the ratchet meaningful rather than cosmetic. Three things the flip did NOT do, each of which someone will otherwise assume: (1) **the carrier is unchanged, by decision** — the maintainer's 2026-08-05 ruling took direction A (gate at the authoring door) and DECLINED direction B (a discriminated `properties`) as breaking against an open `type` union, so `PageComponentSchema.properties` is still `z.record(z.string(), z.unknown())` and `component.test.ts`'s three standing assertions stay GREEN — measured against the landed gate, with their prose updated to say which dispatch landed; (2) **unregistered types are SKIPPED**, a required semantic rather than leniency — the example corpus alone authors 87 nodes across ten types this map does not carry (`flex`, `grid`, `object-metric`, `object-chart`, `record:line_items`, …), and judging them against an absent schema would report every one as broken; (3) **the storage path is still open** — a `saveMetaItem` / REST `/meta` write stores an unvalidated props bag (#4463's fourth wall), recorded rather than fixed. ⚠️ The gate is **WARNING-level** in this first step, and the reason is a measurement: on the example corpus + the three published platform pages it reports **52 findings** (44 value verdicts, 8 undeclared keys), of which 34 are inline `{ en, 'zh-CN' }` label maps against an `I18nLabelSchema` that is a plain `z.string()` (**#5728**, undecided) and 8 more are the same shape on `element:text.content`. Gating those would fail the platform's own pages to enforce declarations the platform does not keep — the `groupBy` judgement #5020 had to make, at corpus scale. That inventory is the acceptance baseline for the error upgrade, which is its own step. See the triage row for the full 批 17 measurement | | `view.zod.ts` | mixed · 1 authorable, 2 wire | **15 of 20 closed at #4001 批 18**, a sixteenth (`UserFiltersSchema`) at **#5073** once its protocol blocker was adjudicated, a seventeenth — `ViewFilterRuleSchema`, closed by an EARLIER wave — reopened at **#5114**, and then the file's last authoring debt cleared at **#5074**, which closed `ViewItemSchema` (×2 arms), `ListView.sort` AND `ViewFilterRuleSchema` in one structural change. **The strip count went 5 → 3, and the arithmetic is the finding, not the number: FOUR sites closed and TWO were ADDED** — the two arms of the new `ViewItemWireSchema`, which are strip BY DESIGN. That is why this row's Class cell is now a split (`1 authorable, 2 wire`) rather than a smaller `authorable` count: the wire contract that used to live on "the member nobody closed" now has a name, and this map measures posture, not intent. Closed: `ViewDataSchema`'s four provider arms, `UserFilterField.options`, `GanttQuickFilter.options`, `GanttConfig.tooltipFields`, `ListView.conditionalFormatting` / `.emptyState`, `FormFieldBase.keyField`, `FormView.subforms`, and `submitBehavior`'s four arms. Reachability was measured, not assumed: a BFS from all 24 metadata-type roots plus `ObjectStackSchema` resolves every one `root-graph`, with `ViewSchema`/`FormViewSchema`/`ViewItemSchema`/`PageSchema` as positive controls and 批 13's no-door shapes UNREACHABLE **in the same run** — and the instrument had to be fixed first: `lazySchema` returns a Proxy, but a carrier writes `X.optional()`, which RESOLVES it, so the closure holds the real instance and comparing the Proxy alone false-negatived `ViewDataSchema` (caught by cross-checking its two literal carrier keys, not by trusting the reading). ⚠️ **Re-checked against #5056**: every 批 18 target is `root-graph` by **identity**, so **none** of the fifteen rests on the `derived-clone` bridge that 批 16 found can mark a dead shape reachable. The one `derived-clone` verdict in the run is `ListViewSchema` — a positive CONTROL, not a target, and independently identity-reachable via `ObjectListViewSchema`. Every closed shape also has a literal carrier key in this file and a named parse door (`defineView` / `defineViewItem` / the `view` metadata-type schema / objectui's `GanttConfigSchema.safeParse` at `plugin-gantt/src/ObjectGantt.tsx:408`) — the strong-evidence class #5056 leaves standing. ⚠️ **`ListView.sort` was closed, REVERTED, and closed again at #5074 — the round trip is the file's most useful finding.** It carried `direction → order`, the #4721 alias for the identical tuple (`{field, direction:'desc'}` parsed to `{field, order:'asc'}` — a silently REVERSED sort). The full suite then failed one case: `view-metadata-schema.test.ts` pins `sort: [{ id, field, order }]` as the exact body a console column-sort PUT persists, and objectui stamps that `id` per row (`components/src/custom/sort-builder.tsx:68`/`:94`, `crypto.randomUUID()`). **The mechanism governs every nested block in this file and is the opposite of what the union's own comment implies: `.strip()` does NOT recurse.** `ViewMetadataSchema` rescues Studio's round-trip keys by making its flattened members `.strip()`, but that re-opens the TOP level only — a nested block closed inside `ListViewSchema` is still reached through that member, so a console-stamped key inside it becomes a 422 regardless. `id` was deliberately NOT declared to silence it: it is a React list key, and declaring it would put a UI artifact on the authorable surface and tell an AI author to emit one. **#5074 supplied the missing half and the shape is now CLOSED**: the write door removes the declared decoration vocabulary (`VIEW_CONSOLE_ROW_DECORATIONS` / `stripViewConsoleDecorations`, the mirror of `stripReadDecorations`) BEFORE the union runs, so the opening is recursive-effective where a member-level `.strip()` can never be, and the authoring surface never grew the key. The `direction → order` alias came back with it. Curation on what DID close is anchored to named siblings: an option `count` gets a wrong-layer pointer to `showCount` because objectui COMPUTES it per render; and a bare `name` on the `object` data source is deliberately NOT aliased — it is a real key on the view ITEM, so a rename would be finding 7 again. `submitBehavior` became a `discriminatedUnion` on the `kind` literal it already required: as a plain union of four strict members the rejection is an `invalid_union` whose prescription #5014 measured the renderers flattening away. ⚠️ **`GanttConfigSchema` / `TreeConfigSchema` are `strictObject(…).passthrough()`** — open at the parent by design, and this ledger's own counter used to read them as `strict`, because `postureOf` returned early on the `strictObject` idiom instead of walking the chain. **Fixed at #5072**: the idiom now seeds the initial posture and the chain always runs, so the two read `passthrough` and the directory's strict count drops by 2. The strip count was never affected — neither posture is strip — so this row's numbers do not move. **`UserFiltersSchema` is CLOSED as of #5073, and it is the one site in this file whose blocker was never a strictness question.** Closing it would have 422'd `allowAddTab` — a key objectui's renderer reads (`plugin-list/src/UserFilters.tsx:182`/`:742`) and the spec never declared; because `saveMetaItem` validates but persists the ORIGINAL body, the stripped key still reached the renderer, so the capability WORKED and closing would have removed it rather than making a silent failure loud. 批 18 stopped and filed rather than guessing, and the maintainer adjudicated **promote, then close, in one PR** (2026-08-04): `allowAddTab` is now DECLARED here, so the capability is discoverable from the contract (JSON Schema / Studio SchemaForm / an AI author) instead of living in one React file, and the shape closes behind it with no intermediate state. The rejected option was `SANCTIONED_LOCAL` in objectui, which would have made spec and objectui two sources of truth for one contract — the fork #2231's derive-by-reference exists to prevent (PD#12) — and would have taught authors to delete a working key with a rejection that was itself "correct" (finding 7). Two details the close is worth remembering for. **(a)** The promotion is scoped to what the renderer really does: the add-tab button objectui renders carries no click handler, so `allowAddTab` declares that the affordance RENDERS and deliberately says nothing about creating presets — a `.describe()` promising more would be PD#10's advertise-what-you-don't-deliver, and the renderer gap is filed as **#5236**. **(b)** The 批 6e reliance question resolved exactly as predicted — `ObjectUserFiltersSchema` is `.omit()`ed off this base and `.omit()` inherits posture, so the pin flipped from "drops" to "rejects", which is wanted (the CLI lint `validate-list-view-mode.ts` was already reporting these) — but inheriting the posture also inherits the base's ERROR MAP, whose `knownKeys` were read from the base shape and therefore still listed the omitted keys. Measured on the flip: `tab` was answered *"Did you mean `tab` → `tabs`?"*, steering the author at the one key that surface refuses — finding 7 produced by the fix for finding 7. So the object variant now carries its own map built over the OMITTED shape (the shape still derived by `.omit()`, so #2231 holds), with `guidance` pointing all three page-only keys at `listViews`. **⚠️ #5074 — the authoring/wire SPLIT, and the row's headline.** `ViewItemSchema` wore two contracts: the authoring gate (`defineViewItem`, objectui's view-create form, which validates `createBuildBody`'s output against the real spec schema) and member 1 of `ViewMetadataSchema`, the union `saveMetaItem` validates every persisted `view` body against. The wire role was measured, not inferred — objectui's pin control PUTs `{...storedItem, isPinned}` (`ObjectView.tsx:882` → `data-objectstack/src/index.ts:2801`); a stored ViewItem record carries `viewKind` AND `config`, so the merged body lands on member 1 (the flattened members are excluded by their `config: z.undefined()` guard) and closing the one schema would have 422'd pinning a saved view. The maintainer ruled **split** (2026-08-04), and the two-axis reasoning is worth keeping: `defineViewItem({name, object, viewKind, confg: {…}})` — one letter — used to strip the typo and hand back a ViewItem with **no view configuration at all**, parsed clean, which is #1535's `workflows: [...]` replayed on the file's densest authoring surface. `ViewItemSchema` is now `strictObject` on both arms; `ViewItemWireSchema` is the `.strip()` wire variant, built from the SAME `viewItemArmShape()` (derive-by-reference, #2231 — a `discriminatedUnion` cannot be `.extend()`ed, so sharing the shape factory is what keeps one contract from becoming two transcriptions), and `isPinned`/`sortOrder` are DECLARED on it — an explicit home, instead of surviving because nobody closed the member. **The scope addendum's hard requirement was recursive-effective openness, and that is the part a posture flip could not deliver.** `.strip()` re-opens a member's TOP level only, so the two console-decorated NESTED blocks (`ListView.sort[].id`, `ViewFilterRule.id`) were still reached at full strictness through it. The route taken is the addendum's second sanctioned one: a declared decoration vocabulary stripped before validation, at the wire door, reaching every carrier at every depth — including ones added later, which a hand-maintained parallel wire tree would not. It is deliberately NOT a second schema tree (PD#12's fork) and deliberately NOT a declared `id` (批 18 Q1's two-axis rejection: a React list key on the authoring surface teaches AI authors to emit UUIDs). Two landmines were named in the ruling and both are pinned in `view-authoring-wire-split.test.ts` §5: `z.toJSONSchema()` must still emit a four-member `anyOf` (the `/api/v1/meta/types/view` endpoint feeds Studio's SchemaForm from it — it does; a pipe converts to its output side, asserted in BOTH io directions), and the `lazySchema` Proxy's ADR-0089 D3a crash (`Cannot set properties of undefined (setting 'ref')`) must not recur under a pipe-rooted lazy schema — it does not, and each new schema is converted directly rather than only through its parent. **One real hazard the change surfaced, fixed in the same PR:** a `z.preprocess` at a registered root put TWO gate walkers into the exact blind spot #4488 had already found and fixed in `check-liveness.mts` — `metadata-authoring-lint.ts` and `metadata-form-zod-reconciliation.test.ts` both unwrapped a pipe via `def.in`, which for a preprocess is the TRANSFORM, so each reported `view` as *not key-bearing* and silently stopped covering it. Caught by their own coverage assertions (`lintables.length >= 1`, `root schema is not key-bearing`), which is precisely what those assertions exist for; both now prefer whichever side is not the transform. **A gate going quiet is worse than a gate failing** — and the pattern will recur on the next preprocess-rooted registration, so it is recorded here rather than only in the diff. **Still open, one site, measured:** `FormFieldBaseSchema` — a module-private BASE whose sole consumer already applies `.strict()` plus the ADR-0089 `strictVisibilityError` map; the door is closed, the ledger counts the base. The two remaining strip sites beyond it are `ViewItemWireSchema`'s arms, which are `wire` by design and are not debt. `ViewFilterRuleSchema` — **the same wire contamination, one block over, and it was already LIVE on `main`** (#5114): closed by an earlier wave, while objectui's filter builder stamps `id: crypto.randomUUID()` on every row it writes (`components/src/custom/filter-builder.tsx:228`, re-stamped on read-back at `plugin-view/src/config/view-config-utils.ts:146`/`:160`), and `saveMetaItem` persists the AUTHORED body verbatim — so saving a filter from the console 422'd, on all three paths including the flattened overlay that is the body actually PUT. Reopened as a p1 hotfix; `id` deliberately NOT declared, for the reason given for `sort` above. **That reopen was explicitly PROVISIONAL — "pending #5074" — and #5074 retired it rather than leaving it standing: the shape is CLOSED again, by the same decoration strip that closed `sort`, so the authoring gate rejects `id` by name while the console's own three paths still parse.** Its pin file now asserts the split per door, and the direction is the INVERTED one worth flagging to the next reader: probes 1/3 and 2/3 were GREEN before #5074 and are RED after (that IS the close), while 3/3 — the body the console actually PUTs — is green on BOTH sides and must stay so; a file that only asserted "the console body parses" would have passed unchanged through a change that quietly declared `id` as authorable. Two details worth keeping: the overlay path's rejection surfaces as `invalid_union` / *"Invalid input"* — the #5014 flattening, so the key that caused it is not in the message the author sees, which is why this sat on `main` unnoticed; and the reopening was verified in BOTH directions (re-close it and 7 assertions in `view-filter-rule-wire-id.test.ts` go red, while that file's two mechanism CONTROLS — top-level aux key rides, nested `emptyState` still rejects — stay green either way, which is what makes them controls). #5074's scope addendum named this site; the gate it was waiting on — a wire opening that REACHES a nested block — landed with it. Each verdict is recorded in three places (schema JSDoc + `view-strictness-batch18.test.ts` / `view-filter-rule-wire-id.test.ts` + this row) | | `widget.zod.ts` | **no door** | ⛔ **not strictness work** — the whole file measured unreachable from every authoring root (#4001 批 16), with no carrier key and zero parse in all three repos. ADR-0049 triage is **#5055**. See the triage row above, including why the campaign's own BFS said otherwise first (**#5056**) | | `chart.zod.ts` | **authorable** | **was `no gate` until #5020** (the cell carries one verdict on purpose — it is the machine-readable input to the generated subtotal, so the history lives here in the evidence). `ChartAggregateSchema` + `ChartGroupBySchema`'s object arm. Config / axis / series / annotation / interaction closed at 批 15; these two were held OUT of the ratchet as `no gate` — carrier live, no parse — because closing them would have gated nothing (#4583). **#5020 wired the parse, so the hold is over and these two are ordinary strictness work again.** `packages/lint/src/validate-react-page-props.ts` now calls `ChartAggregateSchema.safeParse()` on a static `aggregate={{…}}` literal, and the hand-derived `CHART_FUNCTIONS` list + count/field refinement twin are deleted. That is the path **#5022 demonstrated on one key** and this row was blocked on: `ChartDrillDownSchema` arrived with its gate already wired, parsing instead of re-deriving, while `aggregate` beside it did the opposite. ⚠️ **The flip is `no gate` → `authorable`, NOT → closed.** Both sites still STRIP: the parse the gate runs drops `groupby` / `dateGranularty` rather than reporting them, so the ADR-0078 failure mode survives until the posture changes. Converting the two object arms to `strictObject` is **#5583** (Blocked-by resolved; a sub-issue of #4001), which is also where the two `chart.test.ts` "still STRIPS — deliberate" pins invert and where the one product question lands — `groupBy` is declared REQUIRED here and in the published react-blocks type while the renderer honours its absence, so #5020's gate reports that single case at `warning` instead of gating a shape the platform delivers | @@ -993,14 +993,18 @@ remains open is overwhelmingly work for OTHER issues: - **`no gate`** — ~~`chart.zod.ts`'s remaining pair from 批 15~~ **left this class at #5020**, which wired the react-page publish gate to parse `ChartAggregateSchema` instead of re-deriving it; the pair is `authorable` - again and its strictness half is #5583. Still here: **all of - `component.zod.ts` from 批 17** (#5068). That single row is the campaign's - largest reclassification and the reason this subtotal fell by 29 without one - site being closed. Note what #5020 makes visible about the class as a whole — + again and its strictness half is #5583. ~~Still here: **all of + `component.zod.ts` from 批 17**~~ — **#5068 wired that gate too, so as of it + the class is EMPTY.** `component.zod.ts` was the campaign's largest single + reclassification and the reason this subtotal fell by 29 without one site + being closed; it is `authorable` again, with its strictness half still to be + scheduled. Both departures make the same point about the class as a whole: leaving it is a TWO-step move, and only the first step is the carrier's own issue: wire the parse (the `no gate` cure), then close the posture (ordinary ratchet work, on its own issue). A single PR doing both would land a strict - rejection nobody had yet seen a gate produce. + rejection nobody had yet seen a gate produce — which is why #5068 shipped its + gate at WARNING level over a corpus that violates the declarations in 52 + places, and left the error upgrade to the issue that clears them. Read the difference before acting on either: they imply OPPOSITE follow-ups (`no door` → ADR-0049 enforce-or-remove; `no gate` → wire the parse at the diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index bf986fb49e..54ecc98a53 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -105,6 +105,7 @@ import { validateWidgetBindings } from './validate-widget-bindings.js'; import { validateDashboardActionRefs } from './validate-dashboard-action-refs.js'; import { validateFilterTokens } from './validate-filter-tokens.js'; import { validateReferenceIntegrity } from './reference-integrity-suite.js'; +import { validateComponentProps } from './validate-component-props.js'; import { validateResponsiveStyles } from './validate-responsive-styles.js'; import { validateJsxPages } from './validate-jsx-pages.js'; import { validateReactPages } from './validate-react-pages.js'; @@ -454,6 +455,42 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ runtimeTypes: ['flow'], run: (stack) => validateReferenceIntegrity(stack), }, + // ADR-0078 / #5068 — the SDUI component-props gate. `PageComponent.properties` + // is `z.record(z.string(), z.unknown())` and ADR-0089 D3a strictness does not + // recurse into it, so until this entry existed the 31 typed prop schemas in + // `ComponentPropsMap` were parsed by NOTHING (#4001 批 17's `no gate` + // verdict): an undeclared or wrongly-typed prop parsed clean, was retained, + // and reached objectui's renderer to be ignored there. This dispatches on + // `type` and judges the bag; unregistered types are skipped, which is a + // required semantic (`type` is an open union — the example corpus authors 87 + // nodes of 10 types this map does not carry). + // + // `normalized` for a reason worth stating, since the props bag survives the + // Zod parse UNCHANGED and both tiers would otherwise carry the same data: the + // ADR-0087 conversion layer runs inside `normalizeStackInput`, so a converted + // alias (`page-header-subtitle-alias` rewrites `properties.description` → + // `subtitle`) is already canonical here and is never reported as undeclared — + // while a schema error elsewhere in the stack cannot take these findings down + // with it. + // + // Advisory, deliberately, and this is the whole shape of #5068's first step: + // wiring the parse is the precondition for enforcement, not the enforcement + // (#5020, one surface over). The live corpus violates the declarations in two + // places that are open contract questions — inline i18n label maps on three + // published platform pages (#5728) and the record picker's declared-but-unread + // `displayField` (#5775) — so gating today would fail the platform's own pages + // to enforce declarations the platform does not keep. The error upgrade is a + // separate step, once the warning-period inventory is empty. + { + name: 'validateComponentProps', + tier: 'advisory', + input: 'normalized', + commands: ALL, + source: 'packages/lint/src/validate-component-props.ts', + surfaces: CLI_ONLY, + surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT, + run: (stack) => validateComponentProps(stack), + }, // ADR-0065 — a styled node's responsiveStyles must be scopable (needs an // `id`), name real CSS properties + design tokens, and carry a `large` base. { diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index add2995cff..b900dd9422 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -280,6 +280,13 @@ export type { ActionLocationsFinding, ActionLocationsSeverity } from './validate export { validatePageFieldBindings, PAGE_FIELD_UNKNOWN } from './validate-page-field-bindings.js'; export type { PageFieldFinding, PageFieldSeverity } from './validate-page-field-bindings.js'; +export { + validateComponentProps, + COMPONENT_PROPS_UNKNOWN_KEY, + COMPONENT_PROPS_INVALID, +} from './validate-component-props.js'; +export type { ComponentPropsFinding, ComponentPropsSeverity } from './validate-component-props.js'; + export { validateChartBindings, CHART_DIMENSION_UNKNOWN, diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts new file mode 100644 index 0000000000..f63e2cc87d --- /dev/null +++ b/packages/lint/src/validate-component-props.test.ts @@ -0,0 +1,290 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #5068 — the SDUI component-props gate. +// +// The hole this closes was measured through the SAME door the fix uses, before +// the rule was wired: `runAuthoringRules('validate', …)` on a page carrying +// `properties: { title: 'T', titel: 'typo' }` reported ZERO findings — from the +// whole registry, not just from this rule. Same for a wrongly-typed prop and +// for a misspelled key one layer down inside `fields[]`. The two probes that +// must stay silent (an unregistered `type`, and `readonly` — declared at #5176) +// reported zero in both states, which is what makes the other three mean +// something. +import { describe, expect, it } from 'vitest'; +import { normalizeStackInput } from '@objectstack/spec'; +import { + validateComponentProps, + COMPONENT_PROPS_UNKNOWN_KEY, + COMPONENT_PROPS_INVALID, +} from './validate-component-props.js'; +import { runAuthoringRules, AUTHORING_RULES } from './authoring-rules.js'; + +type AnyRec = Record; + +/** One page, one region, the given components. */ +const stackWith = (components: unknown[], pageExtra: AnyRec = {}): AnyRec => ({ + pages: [ + { + name: 'probe_page', + label: 'Probe', + type: 'home', + object: 'task', + regions: [{ name: 'main', components }], + ...pageExtra, + }, + ], +}); + +const unknownKeys = (f: ReturnType) => + f.filter((x) => x.rule === COMPONENT_PROPS_UNKNOWN_KEY); +const invalid = (f: ReturnType) => + f.filter((x) => x.rule === COMPONENT_PROPS_INVALID); + +describe('validateComponentProps — undeclared keys', () => { + it('reports a key the type\'s props schema does not declare, with the near-miss named', () => { + const findings = validateComponentProps( + stackWith([{ type: 'page:header', properties: { title: 'T', titel: 'typo' } }]), + ); + expect(unknownKeys(findings)).toHaveLength(1); + const [f] = unknownKeys(findings); + expect(f.severity).toBe('warning'); + expect(f.path).toBe('pages[0].regions[0].components[0].properties.titel'); + expect(f.where).toBe('page "probe_page" · page:header'); + expect(f.message).toContain('Did you mean `title`?'); + }); + + it('is silent on a fully declared props bag', () => { + const findings = validateComponentProps( + stackWith([ + { type: 'page:header', properties: { title: 'T', subtitle: 'S', breadcrumb: true } }, + { + type: 'record:related_list', + properties: { objectName: 'task', relationshipField: 'project_id', limit: 5 }, + }, + ]), + ); + expect(findings).toEqual([]); + }); + + it('walks components nested inside `properties` (tabs items → children)', () => { + const findings = validateComponentProps( + stackWith([ + { + type: 'page:tabs', + properties: { + items: [ + { + label: 'Detail', + children: [{ type: 'record:highlights', properties: { fields: ['status'], layuot: 'vertical' } }], + }, + ], + }, + }, + ]), + ); + expect(unknownKeys(findings).map((f) => f.path)).toEqual([ + 'pages[0].regions[0].components[0].properties.items[0].children[0].properties.layuot', + ]); + }); + + it('skips a page whose components are a derived cache of authored `source`', () => { + const findings = validateComponentProps( + stackWith([{ type: 'page:header', properties: { titel: 'typo' } }], { kind: 'react', source: 'x' }), + ); + expect(findings).toEqual([]); + }); +}); + +/** + * The second layer, and why it needs its own pins (#5607's correction). + * + * `readonly` is not a key on `RecordHighlightsProps`. It lives one level down, + * on the OBJECT ARM of the `RecordHighlightsField` union that `fields[]` holds + * — and the authorable-surface walk is strictly one level deep, so it never + * collects it. #5176 declared the key precisely because objectui's HeaderHighlight + * gate already honours it; a gate that could not see layer 2 would have gone on + * reporting it as undeclared and told authors to delete a live key. + * + * Both directions are pinned, because only the pair proves the walk descends: + * the declared spelling must stay SILENT (or the gate over-reports a live key) + * and a near-miss must be REPORTED (or the silence is just the walk stopping at + * `fields`, which passes for the wrong reason). + */ +describe('validateComponentProps — the second layer (union arm below an array)', () => { + it('accepts `readonly` on a highlights field object (#5176) — both verdicts clean', () => { + const findings = validateComponentProps( + stackWith([ + { + type: 'record:highlights', + properties: { fields: ['owner', { name: 'status', label: 'Status', readonly: true }] }, + }, + ]), + ); + expect(findings).toEqual([]); + }); + + it('reports a near-miss of it (`readOnly`) at layer 2, naming the declared spelling', () => { + const findings = validateComponentProps( + stackWith([ + { type: 'record:highlights', properties: { fields: [{ name: 'status', readOnly: true }] } }, + ]), + ); + expect(unknownKeys(findings)).toHaveLength(1); + expect(unknownKeys(findings)[0].path).toBe( + 'pages[0].regions[0].components[0].properties.fields.0.readOnly', + ); + expect(unknownKeys(findings)[0].message).toContain('Did you mean `readonly`?'); + }); + + it('leaves a bare string field name alone (the union\'s other arm)', () => { + const findings = validateComponentProps( + stackWith([{ type: 'record:highlights', properties: { fields: ['status'] } }]), + ); + expect(findings).toEqual([]); + }); +}); + +describe('validateComponentProps — value verdicts', () => { + it('reports a wrongly-typed prop and echoes what was written', () => { + const findings = validateComponentProps( + stackWith([ + { + type: 'record:related_list', + properties: { objectName: 'task', relationshipField: 'p', limit: 'five' }, + }, + ]), + ); + expect(invalid(findings)).toHaveLength(1); + expect(invalid(findings)[0].severity).toBe('warning'); + expect(invalid(findings)[0].path).toBe('pages[0].regions[0].components[0].properties.limit'); + expect(invalid(findings)[0].message).toContain('expected number, received string'); + }); + + it('reports a missing required prop', () => { + const findings = validateComponentProps( + stackWith([{ type: 'record:related_list', properties: { objectName: 'task' } }]), + ); + expect(invalid(findings).map((f) => f.path)).toContain( + 'pages[0].regions[0].components[0].properties.relationshipField', + ); + }); + + /** + * `ElementDataSourceSchema` is the component-node binding that "overrides + * page-level object context", and objectui's element renderers read it FIRST + * (`ds.object ?? props.object`). A component that binds through it has not + * omitted the flat shorthand — so reporting the props schema's required + * `object` here would be a WRONG verdict, not a strict one. + */ + it('does not report the required `object` prop when `dataSource` supplies it', () => { + const withDataSource = validateComponentProps( + stackWith([ + { + type: 'element:record_picker', + id: 'picker', + dataSource: { object: 'project', limit: 50 }, + properties: { displayField: 'name' }, + }, + ]), + ); + expect(withDataSource).toEqual([]); + + // …and still reports it when nothing supplies it (or the suppression above + // would be indistinguishable from the rule never looking). + const without = validateComponentProps( + stackWith([{ type: 'element:record_picker', properties: { displayField: 'name' } }]), + ); + expect(invalid(without).map((f) => f.path)).toEqual([ + 'pages[0].regions[0].components[0].properties.object', + ]); + }); + + /** + * The routing that keeps this gate whole across a future `strictObject` + * batch. `AriaPropsSchema` is the one CLOSED shape inside these props (#4001 + * 批 16), so an unknown key under `aria` is refused by the PARSE rather than + * by the unknown-key walker — which stays silent on a strict node by design, + * so as not to be a second voice over a loud rejection. Both paths land on + * one rule id: when the ratchet closes `ComponentPropsMap`'s own 31 sites, + * coverage moves between the halves without moving out of the author's view. + */ + it('routes a STRICT node\'s rejection to the unknown-key id (aria, closed at #4001 批 16)', () => { + const findings = validateComponentProps( + stackWith([ + { type: 'page:header', properties: { title: 'T', aria: { ariaLabel: 'x', ariaLabl: 'typo' } } }, + ]), + ); + expect(invalid(findings)).toEqual([]); + expect(unknownKeys(findings)).toHaveLength(1); + expect(unknownKeys(findings)[0].path).toContain('.aria.ariaLabl'); + }); +}); + +/** + * `type` is `z.union([PageComponentType, z.string()])` — open by design. The + * example corpus authors 87 nodes across ten types `ComponentPropsMap` does not + * carry (`flex`, `grid`, `object-metric`, `object-chart`, `record:line_items`, + * …): SDUI blocks whose contract lives in objectui's registry and the ADR-0080 + * manifest, not here. Judging them against an absent schema would report every + * one of them as broken, which is why the skip is a REQUIRED semantic and not + * leniency (the maintainer's ruling on #5068). + */ +describe('validateComponentProps — unregistered types are skipped', () => { + it.each(['record:line_items', 'flex', 'object-metric', 'record:quick_actions'])( + 'says nothing about `%s`, whatever its props carry', + (type) => { + const findings = validateComponentProps( + stackWith([{ type, properties: { anything: 1, at: 'all', nested: { deep: true } } }]), + ); + expect(findings).toEqual([]); + }, + ); +}); + +/** + * Why the registry entry reads the NORMALIZED tier. + * + * The props bag survives the Zod parse unchanged (`z.record(z.string(), + * z.unknown())`), so the two tiers carry identical props and the choice looks + * free. It is not: the ADR-0087 D2 conversion layer runs INSIDE + * `normalizeStackInput`, and `page-header-subtitle-alias` rewrites + * `properties.description` → `subtitle` on header nodes. Reading the raw + * authored input would report a key the conversion layer has already fixed — + * the rule contradicting a declared conversion, which is exactly the + * second-de-facto-contract shape Prime Directive #12 forbids. + */ +describe('validateComponentProps — reads the post-conversion (normalized) tier', () => { + const authored = stackWith([ + { type: 'page:header', properties: { title: 'Lead', description: 'All open leads' } }, + ]); + + it('says nothing about a key the ADR-0087 conversion layer canonicalizes', () => { + expect(validateComponentProps(normalizeStackInput(authored))).toEqual([]); + }); + + it('WOULD report it pre-conversion — which is why the tier is not free', () => { + const raw = validateComponentProps(authored); + expect(unknownKeys(raw).map((f) => f.path)).toEqual([ + 'pages[0].regions[0].components[0].properties.description', + ]); + }); +}); + +describe('validateComponentProps — wiring', () => { + it('is registered as an advisory rule on all three authoring commands', () => { + const entry = AUTHORING_RULES.find((r) => r.name === 'validateComponentProps'); + expect(entry).toBeDefined(); + expect(entry?.tier).toBe('advisory'); + expect([...(entry?.commands ?? [])].sort()).toEqual(['build', 'lint', 'validate']); + }); + + it('reaches the shared authoring pipeline — the run that reported nothing before #5068', () => { + const findings = runAuthoringRules('validate', { + normalized: stackWith([{ type: 'page:header', properties: { title: 'T', titel: 'typo' } }]) as never, + }); + const mine = findings.filter((f) => f.rule.startsWith('component-props')); + expect(mine).toHaveLength(1); + expect(mine[0].severity).toBe('warning'); + expect(mine[0].rule).toBe(COMPONENT_PROPS_UNKNOWN_KEY); + }); +}); diff --git a/packages/lint/src/validate-component-props.ts b/packages/lint/src/validate-component-props.ts new file mode 100644 index 0000000000..37c639ef32 --- /dev/null +++ b/packages/lint/src/validate-component-props.ts @@ -0,0 +1,248 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0078] The SDUI component-props gate (#5068) — the parse + * `ComponentPropsMap` never had. + * + * ## What was missing + * + * `PageComponent.properties` is `z.record(z.string(), z.unknown())`: an open + * bag. `PageComponentSchema` has been `.strict()` since ADR-0089 D3a, but + * strictness does not RECURSE — it closes the component node's own keys and + * leaves everything under `properties` unjudged. The typed prop schemas exist + * (`ComponentPropsMap`, `@objectstack/spec/ui`) and #4001 批 17 measured that + * **nothing parses them**: BFS-unreachable from all 24 metadata roots, zero + * production `.parse()` in `objectstack` / `objectui` / `cloud`, and — through + * the live `definePage()` door — an undeclared key written inside `properties` + * parses clean and is RETAINED, while the same key one level out is rejected. + * That is the `no gate` class: carrier live, parse absent. + * + * It is not harmless. objectui's `SchemaRenderer` hoists `properties` onto the + * node and spreads every key not on its fixed deny-list straight into the React + * component, so a misspelled key is neither rejected nor dropped — it reaches + * the renderer and is ignored there. The author gets a success receipt for + * configuration that does nothing, which is the exact shape ADR-0078 exists to + * eliminate. + * + * ## What this rule does + * + * It dispatches on the component's `type` and judges `properties` against that + * type's props schema — the maintainer's ruling on #5068 (direction A: gate at + * the carrier's own authoring door, not by reshaping the `page` protocol). + * Two verdicts, from one dispatch: + * + * - **`component-props-unknown-key`** — a key the props schema does not + * declare, at the props bag's own level or at any strip-mode object below it. + * Reported through `lintUnknownKeysAgainstSchema` (`@objectstack/spec`), the + * same walker `lintUnknownAuthoringKeys` runs on every metadata collection — + * so the posture rules (strip reports, strict stays silent because the parse + * is loud on its own, passthrough stays silent because the key survives) and + * the rename suggestions are single-source, never re-derived here. + * - **`component-props-invalid`** — a value the props schema rejects: a wrong + * type, a missing required key, a value outside a declared enum. + * + * A schema that is STRIP today reports its unknown keys through the walker; one + * that a later `strictObject` batch closes reports them through `safeParse` as + * `unrecognized_keys` instead. Both are routed to the SAME rule id below, so + * the ratchet moves coverage between the halves without moving it out of the + * author's view — and without this file needing to know which posture the spec + * is at. + * + * ## Why WARNING, and only warning, in this PR + * + * Wiring the parse is the precondition for enforcement, not the enforcement + * (the #5020 lesson, one surface over). The corpus this landed on carries live + * violations that are open contract questions, not authoring mistakes: + * `I18nLabelSchema` is a plain `z.string()` while three published platform + * pages author inline `{ en, 'zh-CN', … }` maps that objectui resolves + * (#5728), and the record picker declares a required `displayField` that no + * renderer reads while honouring an undeclared `labelField` (#5775). Gating + * those would fail the platform's own pages to enforce declarations the + * platform does not itself keep. So every finding here is advisory, the + * warning-period inventory is the acceptance baseline for the error upgrade, + * and the upgrade is its own step once the inventory is empty. + * + * ## Unregistered types are SKIPPED — a required semantic, not leniency + * + * `PageComponentSchema.type` is `z.union([PageComponentType, z.string()])`, an + * open union by design: the example corpus alone authors `flex`, `grid`, + * `object-metric`, `object-chart`, `object-grid`, `object-form`, + * `object-master-detail-form`, `record:quick_actions`, `record:alert` and + * `record:line_items` — 87 nodes whose props schema `ComponentPropsMap` simply + * does not carry (SDUI blocks live in objectui's registry and in the ADR-0080 + * manifest). Judging those against an absent schema would report every one of + * them as broken. `validate-page-field-bindings` skips unknown types for the + * same reason and says so in its own header. + */ + +import { ComponentPropsMap } from '@objectstack/spec/ui'; +import { lintUnknownKeysAgainstSchema } from '@objectstack/spec'; +import { walkPageComponents, type AnyRec } from './page-walk.js'; +import { describeIssue, type LintZodIssue } from './zod-issue-format.js'; + +/** A key authored in `properties` that the type's props schema does not declare. */ +export const COMPONENT_PROPS_UNKNOWN_KEY = 'component-props-unknown-key'; +/** A value in `properties` that the type's props schema rejects. */ +export const COMPONENT_PROPS_INVALID = 'component-props-invalid'; + +/** + * Advisory on every finding this rule emits — see the module header. The type + * is a single literal rather than a union so the tier claim in + * `authoring-rules.ts` is provable from this file's source, which is exactly + * what `authoring-rule-wiring.test.ts` reads it for. + */ +export type ComponentPropsSeverity = 'warning'; + +export interface ComponentPropsFinding { + severity: ComponentPropsSeverity; + /** Diagnostic rule id. */ + rule: string; + /** Human-readable location, e.g. `page "task_detail" · record:highlights`. */ + where: string; + /** Config path, e.g. `pages[0].regions[1].components[0].properties.titel`. */ + path: string; + /** What is wrong. */ + message: string; + /** How to fix it. */ + hint: string; +} + +function isRec(v: unknown): v is AnyRec { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +function strName(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +/** Coerce a collection (array or name-keyed map) to an array of records. */ +function asArray(v: unknown): AnyRec[] { + if (Array.isArray(v)) return v as AnyRec[]; + if (v && typeof v === 'object') { + return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) })); + } + return []; +} + +/** A zod schema, as much of one as this file reads. */ +interface PropsSchema { + safeParse(value: unknown): { success: boolean; error?: { issues: ReadonlyArray } }; +} + +const PROPS_SCHEMAS = ComponentPropsMap as unknown as Record; + +/** + * The one prop whose absence this rule does NOT report when the component + * carries a per-element `dataSource`. + * + * `ElementDataSourceSchema` is declared on the component node as the binding + * that "overrides page-level object context", and objectui's element renderers + * read it FIRST (`const object = ds.object ?? props.object`) — the same + * precedence `page-walk.ts` encodes for every rule built on it. The props + * schemas declare `object` as required because it is the flat shorthand; a + * component that binds through the richer sibling has not omitted anything. + * Reporting it would be the rule judging one half of a two-key contract, which + * is a wrong verdict rather than a strict one — the showcase's + * `element:record_picker` (`dataSource: { object: 'showcase_project', limit: 50 }`) + * is the live specimen. + */ +const DATASOURCE_SUPPLIED_PROP = 'object'; + +/** + * Is this issue "the required `object` prop is missing", on a component whose + * `dataSource` supplies it? + */ +function suppliedByDataSource(issue: LintZodIssue, component: AnyRec): boolean { + if (issue.path.length !== 1 || issue.path[0] !== DATASOURCE_SUPPLIED_PROP) return false; + const dataSource = isRec(component.dataSource) ? component.dataSource : undefined; + return strName(dataSource?.object) !== undefined; +} + +export function validateComponentProps(stack: AnyRec): ComponentPropsFinding[] { + const findings: ComponentPropsFinding[] = []; + if (!isRec(stack)) return findings; + + const pages = asArray(stack.pages); + for (let pi = 0; pi < pages.length; pi++) { + const page = pages[pi]; + if (!isRec(page)) continue; + const pageName = strName(page.name) ?? `#${pi}`; + + for (const { component, path } of walkPageComponents(page, `pages[${pi}]`)) { + const type = strName(component.type); + if (!type) continue; + // Unregistered type — skipped silently. See the module header: `type` is + // an open union and the majority of authored nodes are SDUI blocks this + // map does not carry. + const schema = PROPS_SCHEMAS[type]; + if (!schema) continue; + const props = isRec(component.properties) ? component.properties : undefined; + if (!props) continue; + + const where = `page "${pageName}" · ${type}`; + const base = `${path}.properties`; + + // ── Undeclared keys ────────────────────────────────────────────── + // The walker descends: `RecordHighlightsProps.fields[]` is a UNION whose + // object arm is where `readonly` lives (#5176/#5607), one layer below the + // props bag, and the authorable-surface walk that runs one level deep + // does not reach it. This one does, and `validate-component-props.test.ts` + // pins both directions of that. + for (const f of lintUnknownKeysAgainstSchema(schema, props, type, base)) { + findings.push({ + severity: 'warning', + rule: COMPONENT_PROPS_UNKNOWN_KEY, + where, + path: f.path, + message: + `\`${f.key}\` is not a prop \`${type}\` declares (ComponentPropsMap, @objectstack/spec/ui), ` + + 'so nothing verifies it: `properties` is an untyped bag, the renderer spreads whatever it ' + + 'carries, and a key it does not read is ignored in silence.' + + (f.suggestion ? ` Did you mean \`${f.suggestion}\`?` : ''), + hint: + f.guidance ?? + (f.suggestion + ? `Rename \`${f.key}\` → \`${f.suggestion}\`.` + : `Remove \`${f.key}\`, or — if the component really does honour it — declare it on ` + + `\`${type}\`'s props schema so the declaration and the renderer agree.`), + }); + } + + // ── Value verdicts ─────────────────────────────────────────────── + const parsed = schema.safeParse(props); + if (parsed.success) continue; + for (const issue of parsed.error?.issues ?? []) { + if (suppliedByDataSource(issue, component)) continue; + const at = issue.path.length ? `${base}.${issue.path.join('.')}` : base; + // A strict props schema reports its undeclared keys HERE instead of + // through the walker above. Same fact, same rule id — see the header. + if (issue.code === 'unrecognized_keys') { + for (const key of issue.keys ?? []) { + findings.push({ + severity: 'warning', + rule: COMPONENT_PROPS_UNKNOWN_KEY, + where, + path: `${at}.${key}`, + message: `\`${key}\` is not a prop \`${type}\` declares (ComponentPropsMap, @objectstack/spec/ui): ${issue.message}`, + hint: `Remove \`${key}\`, or declare it on \`${type}\`'s props schema if the component honours it.`, + }); + } + continue; + } + findings.push({ + severity: 'warning', + rule: COMPONENT_PROPS_INVALID, + where, + path: at, + message: `${at.slice(base.length + 1) || 'properties'}: ${describeIssue(issue, props)}`, + hint: + `\`${type}\`'s props are declared by ComponentPropsMap (@objectstack/spec/ui) — the ` + + 'rejection above carries the fix. Advisory for now: the props bag is not parsed on the ' + + 'storage path either, so nothing rejects this today (objectstack#5068).', + }); + } + } + } + + return findings; +} diff --git a/packages/lint/src/validate-react-page-props.ts b/packages/lint/src/validate-react-page-props.ts index 3508542a48..a93f7b644b 100644 --- a/packages/lint/src/validate-react-page-props.ts +++ b/packages/lint/src/validate-react-page-props.ts @@ -59,6 +59,9 @@ import { type FieldRef, type PageFieldFinding, } from './validate-page-field-bindings.js'; +// #5020's zod-rejection renderer, shared with the SDUI component-props gate +// since #5068 — see `zod-issue-format.ts` for why one copy matters here. +import { describeIssue } from './zod-issue-format.js'; import { SYSTEM_FIELDS } from './system-fields.js'; @@ -386,103 +389,6 @@ function checkChartAggregate( } } -/** - * One rejected value's issue, rendered so an author can act on it. - * - * Two things zod 4 does not do for us, both measured against this schema rather - * than assumed: - * - * 1. **Union arms collapse.** A union's arm failures never reach - * `error.issues` — the whole union is reported as ONE `invalid_union` whose - * own `message` is the bare string `"Invalid input"`, with the named arm - * messages tucked inside `issue.errors` (one array per arm). Reporting it - * verbatim would tell an author only that *something* about `groupBy` is - * wrong, which is precisely the class of unhelpful diagnostic this gate - * exists to replace. `aggregate.groupBy` is a union - * (`ChartGroupBySchema` — bare field name or `{ field, dateGranularity?, - * alias? }`), so this is the common path, and it matters more after #5583: - * an `unrecognized_keys` raised inside the object arm collapses exactly the - * same way, so the unpacking is what will carry the strict rejection's - * named surface + rename suggestion to the author. - * 2. **The offending value is dropped.** `Invalid option: expected one of - * "count"|"sum"|…` never echoes what was actually written, and the - * hand-rolled check it replaces did (`aggregate.function "median" is not an - * aggregation…`). It is recovered from the INPUT by path — generic, and no - * contract knowledge restated here to do it. - */ -function describeIssue(issue: LintZodIssue, root: unknown, depth = 0): string { - const value = depth === 0 ? valueAtPath(root, issue.path) : undefined; - // Suppressed in the two cases where it would only repeat what the message - // already says: a `custom` refinement names the missing key itself, and zod's - // `invalid_type` text ends in `received ` of its own accord. What is - // left is where the value genuinely is missing from the diagnostic — the enum - // rejections and the collapsed `invalid_union` (whose message is just - // "Invalid input"). - const seen = - depth > 0 || issue.code === 'custom' || issue.message.includes('received ') - ? '' - : value === undefined - ? ' (nothing is set there)' - : ` (received ${preview(value)})`; - - // Deliberately NOT `Array.isArray(issue.errors)`: that narrows a - // `ReadonlyArray<…>` to `any[]` and silently drops the element type, which is - // the TS7006 trap AGENTS.md names — the arms below would then be `any`. - const armIssues = issue.code === 'invalid_union' ? issue.errors : undefined; - if (!armIssues || armIssues.length === 0) { - return `${issue.message}${seen}`; - } - - const arms = armIssues - .map((arm) => - arm - .map((inner) => { - const where = inner.path.length ? `${inner.path.join('.')} — ` : ''; - return `${where}${describeIssue(inner, root, depth + 1)}`; - }) - .join('; '), - ) - .filter((text) => text.length > 0); - if (arms.length === 0) return `${issue.message}${seen}`; - return ( - `${issue.message}${seen} — no accepted form matched: ` + - arms.map((text, i) => `(${i + 1}) ${text}`).join(' ') - ); -} - -/** - * The subset of a zod issue this file reads. Declared structurally rather than - * imported as `z.core.$ZodIssue` so `packages/lint` keeps its single spec - * dependency and does not take a direct zod one for two field reads. - */ -interface LintZodIssue { - readonly code: string; - readonly message: string; - readonly path: ReadonlyArray; - /** Present on `invalid_union` only: the arms' own issues, one array each. */ - readonly errors?: ReadonlyArray>; -} - -const valueAtPath = (root: unknown, path: ReadonlyArray): unknown => { - let cur: unknown = root; - for (const key of path) { - if (!isRec(cur) && !Array.isArray(cur)) return undefined; - cur = (cur as Record)[key]; - } - return cur; -}; - -/** The author's own value, short enough to sit inside a diagnostic. */ -const preview = (value: unknown): string => { - let text: string; - try { - text = JSON.stringify(value) ?? String(value); - } catch { - text = String(value); - } - return text.length > 80 ? `${text.slice(0, 77)}…` : text; -}; - const isRec = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v); diff --git a/packages/lint/src/zod-issue-format.ts b/packages/lint/src/zod-issue-format.ts new file mode 100644 index 0000000000..ecb5728d4a --- /dev/null +++ b/packages/lint/src/zod-issue-format.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Rendering a zod rejection so an AUTHOR can act on it. + * + * Written for #5020 (`` against + * `ChartAggregateSchema`) and lifted here at #5068, where a second gate — + * the SDUI component-props gate, which safeParses `ComponentPropsMap[type]` + * against a page component's `properties` bag — needs the identical rendering. + * Two copies of this would drift in exactly the way #5020's own note describes + * about the contract it replaced: the value is in there being one function. + * + * Two things zod 4 does not do for us, both measured against real schemas + * rather than assumed: + * + * 1. **Union arms collapse.** A union's arm failures never reach + * `error.issues` — the whole union is reported as ONE `invalid_union` whose + * own `message` is the bare string `"Invalid input"`, with the named arm + * messages tucked inside `issue.errors` (one array per arm). Reporting it + * verbatim would tell an author only that *something* about the value is + * wrong, which is precisely the class of unhelpful diagnostic these gates + * exist to replace. `aggregate.groupBy` is a union (`ChartGroupBySchema` — + * bare field name or `{ field, dateGranularity?, alias? }`) and so is + * `RecordHighlightsProps.fields[]` (`RecordHighlightsField` — bare field + * name or `{ name, label?, icon?, type?, readonly? }`), so this is the + * common path on both surfaces. It matters more after #5583 / a future + * `strictObject` batch: an `unrecognized_keys` raised inside an object arm + * collapses exactly the same way, so the unpacking is what will carry the + * strict rejection's named surface + rename suggestion to the author. + * 2. **The offending value is dropped.** `Invalid option: expected one of + * "count"|"sum"|…` never echoes what was actually written, and the + * hand-rolled check #5020 replaced did (`aggregate.function "median" is not + * an aggregation…`). It is recovered from the INPUT by path — generic, and + * no contract knowledge restated here to do it. + */ + +/** + * The subset of a zod issue these gates read. Declared structurally rather than + * imported as `z.core.$ZodIssue` so `packages/lint` keeps its single spec + * dependency and does not take a direct zod one for two field reads. + */ +export interface LintZodIssue { + readonly code: string; + readonly message: string; + readonly path: ReadonlyArray; + /** Present on `invalid_union` only: the arms' own issues, one array each. */ + readonly errors?: ReadonlyArray>; + /** Present on `unrecognized_keys` only: the keys the strict object refused. */ + readonly keys?: ReadonlyArray; +} + +const isRec = (v: unknown): v is Record => + !!v && typeof v === 'object' && !Array.isArray(v); + +export const valueAtPath = (root: unknown, path: ReadonlyArray): unknown => { + let cur: unknown = root; + for (const key of path) { + if (!isRec(cur) && !Array.isArray(cur)) return undefined; + cur = (cur as Record)[key]; + } + return cur; +}; + +/** The author's own value, short enough to sit inside a diagnostic. */ +export const preview = (value: unknown): string => { + let text: string; + try { + text = JSON.stringify(value) ?? String(value); + } catch { + text = String(value); + } + return text.length > 80 ? `${text.slice(0, 77)}…` : text; +}; + +/** One rejected value's issue, rendered so an author can act on it. */ +export function describeIssue(issue: LintZodIssue, root: unknown, depth = 0): string { + const value = depth === 0 ? valueAtPath(root, issue.path) : undefined; + // Suppressed in the two cases where it would only repeat what the message + // already says: a `custom` refinement names the missing key itself, and zod's + // `invalid_type` text ends in `received ` of its own accord. What is + // left is where the value genuinely is missing from the diagnostic — the enum + // rejections and the collapsed `invalid_union` (whose message is just + // "Invalid input"). + const seen = + depth > 0 || issue.code === 'custom' || issue.message.includes('received ') + ? '' + : value === undefined + ? ' (nothing is set there)' + : ` (received ${preview(value)})`; + + // Deliberately NOT `Array.isArray(issue.errors)`: that narrows a + // `ReadonlyArray<…>` to `any[]` and silently drops the element type, which is + // the TS7006 trap AGENTS.md names — the arms below would then be `any`. + const armIssues = issue.code === 'invalid_union' ? issue.errors : undefined; + if (!armIssues || armIssues.length === 0) { + return `${issue.message}${seen}`; + } + + const arms = armIssues + .map((arm) => + arm + .map((inner) => { + const where = inner.path.length ? `${inner.path.join('.')} — ` : ''; + return `${where}${describeIssue(inner, root, depth + 1)}`; + }) + .join('; '), + ) + .filter((text) => text.length > 0); + if (arms.length === 0) return `${issue.message}${seen}`; + return ( + `${issue.message}${seen} — no accepted form matched: ` + + arms.map((text, i) => `(${i + 1}) ${text}`).join(' ') + ); +} diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 33c4daba7a..7024c21e7d 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -162,6 +162,7 @@ "isAggregatedViewContainer (function)", "isKnownPlatformCapability (function)", "lintUnknownAuthoringKeys (function)", + "lintUnknownKeysAgainstSchema (function)", "lintUnknownStackKeys (function)", "listLintableAuthoringCollections (function)", "mapMembershipRole (function)", @@ -1879,6 +1880,7 @@ "isConsumerInstallable (function)", "isKnownPlatformCapability (function)", "lintUnknownAuthoringKeys (function)", + "lintUnknownKeysAgainstSchema (function)", "lintUnknownStackKeys (function)", "listLintableAuthoringCollections (function)", "listMetadataCreateSeedTypes (function)", diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index d6d56114f7..96452e0e9c 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -99,6 +99,7 @@ export { defineObjectExtension } from './data/object.zod'; // tables and finding shape stay in data/ (frontend-safe). export { lintUnknownAuthoringKeys, + lintUnknownKeysAgainstSchema, lintUnknownStackKeys, listLintableAuthoringCollections, } from './kernel/metadata-authoring-lint'; diff --git a/packages/spec/src/kernel/metadata-authoring-lint.ts b/packages/spec/src/kernel/metadata-authoring-lint.ts index 051ae749d9..3770ddd076 100644 --- a/packages/spec/src/kernel/metadata-authoring-lint.ts +++ b/packages/spec/src/kernel/metadata-authoring-lint.ts @@ -359,16 +359,59 @@ export function lintUnknownAuthoringKeys(rawStack: unknown): UnknownAuthoringKey const item = items[i]; if (!isPlainRecord(item)) continue; const name = typeof item.name === 'string' && item.name ? item.name : String(i); - const basePath = `${collection}.${name}`; - if (posture.mode === 'strip') { - lintAuthoredRecordKeys(item, posture.keys, guidance, type, basePath, out); - } - descend(schema, item, basePath, '', type, guidance, out, 0); + out.push(...lintUnknownKeysAgainstSchema(schema, item, type, `${collection}.${name}`, guidance)); } } return out; } +/** + * Report every key an authored VALUE sets — at its own level and at every + * strip-mode object below it — that `schema` does not declare. + * + * This is the body {@link lintUnknownAuthoringKeys} runs per metadata item, + * lifted so a caller holding its own (schema, value) pair can run the same walk + * without re-deriving the posture rules. The rules are subtle enough to be worth + * having exactly once: which wrapper nodes to peel (#4488/#5074's two opposite + * pipes), when a union may be descended, and the strip/strict/passthrough split + * that keeps this lint from becoming a second voice over a parse that already + * rejects loudly. + * + * The caller that needs it (#5068) is `@objectstack/lint`'s component-props + * gate: `PageComponent.properties` is `z.record(z.string(), z.unknown())`, so + * the walk above stops dead at the carrier and everything under it is + * unjudged — the props schema that DOES declare those keys + * (`ComponentPropsMap[type]`) is reachable only by dispatching on the sibling + * `type`, which no schema can express. The gate dispatches, then calls this. + * + * Pure and side-effect free. Runs on the authored (unparsed) value; after the + * parse the unknown keys no longer exist to report. + * + * @param schema The Zod schema that declares `value`'s shape. + * @param value The authored value. + * @param surface The name a finding reports under (a metadata type, or any + * caller-chosen surface id such as a page-component type). + * @param basePath Dotted path of `value` itself; every finding's `path` extends it. + * @param guidance Optional curated rename/retirement table for this surface. + */ +export function lintUnknownKeysAgainstSchema( + schema: unknown, + value: unknown, + surface: string, + basePath: string, + guidance: Readonly> = EMPTY_GUIDANCE, +): UnknownAuthoringKeyFinding[] { + const out: UnknownAuthoringKeyFinding[] = []; + if (!isPlainRecord(value)) return out; + const posture = keyPosture(schema); + if (!posture || posture.keys.size === 0) return out; + if (posture.mode === 'strip') { + lintAuthoredRecordKeys(value, posture.keys, guidance, surface, basePath, out); + } + descend(schema, value, basePath, '', surface, guidance, out, 0); + return out; +} + /** * Report every TOP-LEVEL key an authored stack sets that * `ObjectStackDefinitionSchema` does not declare (#4167). diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index c739bf8daf..a6b2e9c483 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -982,19 +982,30 @@ describe('ComponentPropsMap record:chatter', () => { }); /** - * ── #4001 批 17: the `no gate` verdict, pinned ────────────────────────────── + * ── 批 17's `no gate` verdict, and what #5068 changed about it ────────────── * - * These schemas are NOT a pending `.strict()` batch. Nothing parses them, so - * closing them would enforce nothing (#4583). The full measurement and the - * reasoning live in `component.zod.ts`'s file header and in the `ui/` tables of - * `docs/audits/2026-07-unknown-key-strictness-ledger.md`. + * 批 17 measured that nothing parsed these schemas, so closing them would have + * enforced nothing (#4583). **#5068 wired the parse** — on the LINT side, per + * the maintainer's direction-A ruling — so the class is `authorable` again and + * the ratchet is ordinary strictness work. The full measurement and the three + * things the flip did not do live in `component.zod.ts`'s header and in the + * `ui/` tables of `docs/audits/2026-07-unknown-key-strictness-ledger.md`. + * + * **Every assertion below still holds, and that is the point rather than an + * oversight.** Direction B (a discriminated `properties` on the carrier) was + * DECLINED as breaking against an open `type` union, so the schema path is + * untouched: the carrier is still an open record, an unknown key still survives + * `PageSchema.parse()`, and all 31 entries still strip. Measured against the + * landed gate, not assumed — `packages/lint`'s `validate-component-props.test.ts` + * holds the other half (the gate reports what these three assertions show the + * schema still accepts). * * This block exists so the verdict cannot outlive its truth. Each assertion is * written to go RED the day the world changes underneath it — at which point the * correct response is to update all three places together, not to relax the test. */ -describe('#4001 批 17 — component props are `no gate` (carrier live, parse absent)', () => { - it('the carrier is still an OPEN bag — goes red the day `properties` gets a typed dispatch', () => { +describe('批 17 / #5068 — the carrier stays an open bag; the gate is on the lint side', () => { + it('the carrier is still an OPEN bag — direction B (a typed `properties`) was declined, so this stays green', () => { // `PageComponentSchema` is `.strict().transform(…)`, so unwrap the pipe to // reach the object shape. const def = (PageComponentSchema as any)._zod.def; @@ -1003,12 +1014,21 @@ describe('#4001 批 17 — component props are `no gate` (carrier live, parse ab let node = shape.properties; while (node?._zod?.def?.innerType) node = node._zod.def.innerType; expect(node._zod.def.type).toBe('record'); - // The value type must still be the fully-open `unknown`. A dispatch on - // `type` (the #5068 fix) replaces this, and that is the signal to reclassify - // this file back to `authorable` and schedule the ratchet. + // The value type must still be the fully-open `unknown`. #5068 dispatches + // `ComponentPropsMap` by `type` at the AUTHORING GATE + // (`packages/lint/src/validate-component-props.ts`), not here — the carrier + // keeps this shape by decision, because `type` is an open union and a + // discriminated `properties` would reject the unregistered types real pages + // author. If this ever DOES go red, the carrier itself was reshaped: that is + // a protocol change (direction B), not a lint change. expect(node._zod.def.valueType._zod.def.type).toBe('unknown'); }); + // Still true after #5068, and it is the sentence that keeps the gate honest: + // the SCHEMA accepts and retains the key; what changed is that the authoring + // gate now REPORTS it (at `warning`). A reader who mistakes the gate for a + // closed door would be wrong in the direction that matters — the storage path + // (`saveMetaItem` / REST `/meta`) runs no such gate at all. it('an unknown key inside `properties` survives the LIVE page parse — with the strict sibling as negative control', () => { const page = { name: 'batch17_probe', @@ -1034,7 +1054,7 @@ describe('#4001 批 17 — component props are `no gate` (carrier live, parse ab expect(PageSchema.safeParse(outside).success).toBe(false); }); - it('every ComponentPropsMap entry is still non-strict — a sweep that closes them without wiring #5068 fails here', () => { + it('every ComponentPropsMap entry is still non-strict — #5068 wired the parse, it did not close them', () => { const stillOpen: string[] = []; for (const [type, schema] of Object.entries(ComponentPropsMap)) { const def = (schema as any)._zod.def; @@ -1043,9 +1063,15 @@ describe('#4001 批 17 — component props are `no gate` (carrier live, parse ab if (def.catchall?._zod?.def?.type === 'never') continue; stillOpen.push(type); } - // All 31 registered component types are open. When #5068 wires the parse and - // a later batch closes them, this expectation flips — update the verdict in - // component.zod.ts and the ledger in the same PR. + // All 31 registered component types are open. #5068 wired the parse without + // touching the posture — deliberately, because the live corpus violates + // these declarations in places that are open contract questions (#5728's + // inline i18n label maps) and closing them in the same step would turn a + // warning inventory into a wall of hard rejections. When a later batch DOES + // close them, this expectation flips — update the verdict in + // component.zod.ts and the ledger in the same PR. The coverage survives the + // flip: the gate's unknown-key half goes quiet on a strict node and its + // `safeParse` half reports `unrecognized_keys` under the same rule id. expect(stillOpen.length).toBe(Object.keys(ComponentPropsMap).length); }); }); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index f2b7c80c54..30315c0b20 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -8,14 +8,26 @@ import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { FeedItemType, FeedFilterMode } from '../data/feed.zod'; // --------------------------------------------------------------------------- -// NOT CLOSED AGAINST UNKNOWN KEYS -- AND THAT IS THE MEASURED VERDICT -// (#4001 batch 17 / 批 17, ADR-0078). Read this before "finishing" the file. +// NOT CLOSED AGAINST UNKNOWN KEYS -- and until #5068 that was the whole +// verdict (#4001 batch 17 / 批 17, ADR-0078). Read this before "finishing" the +// file. // // SDUI component prop schemas: the declarative shape of every `page:*`, // `record:*`, `element:*`, `nav:*` and `ai:*` node a page can carry. // -// These 29 object sites are `no gate` -- carrier live, parse absent -- NOT a -// pending `.strict()` batch. Do not sweep `strictObject` across this file. +// ⚠️ STATUS AS OF #5068: the `no gate` verdict below is SPENT -- a parse now +// exists -- and this file is `authorable` again. What that does and does not +// mean is spelled out in the "#5068: the gate is wired" section at the end; +// read it before scheduling the ratchet, because the gate is on the LINT side +// and the carrier's own shape is deliberately unchanged. +// +// The measurement that produced the verdict is kept verbatim below: it is the +// evidence base for the ratchet, and every sentence of it is still true of the +// SCHEMA path. +// +// These 29 object sites were `no gate` -- carrier live, parse absent -- NOT a +// pending `.strict()` batch. Do not sweep `strictObject` across this file +// without reading the #5068 section first. // // It was scheduled as the #4001 campaign's largest remaining `ui/` block and // the measurement came back NEGATIVE: nothing parses these schemas, so @@ -84,8 +96,43 @@ import { FeedItemType, FeedFilterMode } from '../data/feed.zod'; // authors and declared `hideFields`, so wiring the gate no longer turns three // showcase pages and the `sys_user` platform page into hard parse errors. // -// When #5068 lands, this file becomes `authorable` and the ratchet applies. The -// verdict is pinned in `component.test.ts` and in the `ui/` tables of +// ── #5068: THE GATE IS WIRED — read the flip precisely ───────────────────── +// +// `packages/lint/src/validate-component-props.ts` dispatches on the component's +// `type` and judges `properties` against the entry below it: undeclared keys +// through the same walker every metadata collection uses +// (`lintUnknownKeysAgainstSchema`), values through `safeParse`. It runs on +// `os validate` / `os build` / `os lint` from the shared authoring registry. +// So these schemas ARE parsed now, and this file is `authorable`. +// +// Three things that flip did NOT do, each of which someone will otherwise +// assume: +// +// 1. **The carrier is unchanged, on purpose.** `PageComponentSchema.properties` +// is still `z.record(z.string(), z.unknown())`. The maintainer's 2026-08-05 +// ruling took direction A (gate at the authoring door) and DECLINED +// direction B (a discriminated `properties`) as breaking against an open +// `type` union. So the three standing assertions in `component.test.ts` +// stay GREEN — measured, not assumed — and their prose was updated to say +// which dispatch actually landed. +// 2. **Nothing here became strict.** All 31 entries still STRIP. The gate +// reports an undeclared key because the walker reads a strip-mode object; +// converting these sites to `strictObject` moves that same report into the +// gate's `safeParse` half (`unrecognized_keys`, routed to the same rule id) +// — which is what makes the ratchet meaningful rather than cosmetic, and it +// is ordinary strictness work again. +// 3. **The storage path is still open.** The gate is an AUTHORING door. A +// `saveMetaItem` / REST `/meta` write still stores an unvalidated props bag +// (#4463's fourth wall). That is recorded, not fixed, by #5068. +// +// The gate is WARNING-level in this first step. The live corpus violates these +// declarations in places that are open contract questions rather than authoring +// mistakes — inline `{ en, 'zh-CN' }` label maps on three published platform +// pages against an `I18nLabelSchema` that is a plain `z.string()` (#5728), and +// keys objectui's renderers honour that this file does not declare. The +// warning-period inventory is the acceptance baseline for the error upgrade. +// +// The verdict is pinned in `component.test.ts` and in the `ui/` tables of // `docs/audits/2026-07-unknown-key-strictness-ledger.md` — change all three // together or none. // ---------------------------------------------------------------------------