Skip to content

Commit a85a124

Browse files
committed
feat(lint,spec): dispatch ComponentPropsMap by type — the SDUI props bag gets its parse (#5068)
`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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D
1 parent 889ae47 commit a85a124

14 files changed

Lines changed: 906 additions & 133 deletions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/spec": minor
4+
---
5+
6+
feat(lint,spec): SDUI 组件 props 接上解析闸门 —— `ComponentPropsMap` 不再是「声明了、从不被 parse」(#5068)
7+
8+
`PageComponent.properties``z.record(z.string(), z.unknown())` 这个开放口袋。
9+
`PageComponentSchema` 自 ADR-0089 D3a 起是 `.strict()`,但**严格性不递归**:它守住
10+
component 节点自己的键,`properties` 里面一个字都没人看。于是 `ComponentPropsMap`
11+
里 31 个 typed props schema 从来没有被任何东西 parse 过(#4001 批 17 的 `no gate`
12+
判定:载体活着、parse 缺席)。后果不是无害的 —— objectui 的 `SchemaRenderer` 会把
13+
`properties` 整个 hoist 到节点上,再把 deny-list 之外的每个键 spread 成 React prop,
14+
所以一个拼错的键既不被拒绝也不被丢弃:它一路走到渲染器,在那里被忽略,而作者拿到
15+
的是一张成功回执。这正是 ADR-0078 要消灭的形状。
16+
17+
**新规则(两个诊断 id,均为 warning 级)**,落在 `@objectstack/lint`,按维护者对
18+
#5068 的裁定走方向 A —— 在载体自己的授权门上分派解析,而不是改 `page` 协议的形状:
19+
20+
- **`component-props-unknown-key`** —— props schema 未声明的键,包括 props 包自身
21+
这一层和它底下每一个 strip 姿态的对象。走的是 `lintUnknownKeysAgainstSchema`
22+
(本次从 `@objectstack/spec` 导出,即 `lintUnknownAuthoringKeys` 用在每个 metadata
23+
集合上的同一个 walker),所以 strip/strict/passthrough 的姿态规则与改名建议都是
24+
单一实现,这里不重新推导一遍。
25+
- **`component-props-invalid`** —— props schema 拒绝的值:类型不对、必填缺失、枚举
26+
越界。
27+
28+
配套的一条契约细节:走到第二层。`readonly`(#5176)挂在 `RecordHighlightsField`
29+
联合体的对象成员上,即 `fields[]` 数组项里面 —— authorable-surface walk 严格一层、
30+
到不了那里(#5607 的更正)。本闸门到得了,并且两个方向都钉了测试:声明过的
31+
`readonly` 必须静默,拼错的 `readOnly` 必须报出来并指名正确拼法。
32+
33+
**未注册 type 一律跳过**,这是必须语义而不是宽松:`PageComponentSchema.type`
34+
`z.union([PageComponentType, z.string()])`,光是仓内 example 语料就授权了 10 种本
35+
map 不承载的类型、共 87 个节点(`flex``grid``object-metric``object-chart`
36+
`record:line_items` …),它们的契约在 objectui 注册表和 ADR-0080 manifest 里。拿一个
37+
不存在的 schema 去审判它们,只会把每一个都报成坏的。
38+
39+
**为什么本步只落 warning。**接上 parse 是执法的前置条件,不是执法本身(#5020 在隔壁
40+
表面上的同一课)。闸门落在真实语料上会报 52 条:其中 34 条是三个已发布平台页把
41+
`{ en, 'zh-CN' }` 内联多语言 map 写进了声明为纯 `z.string()``I18nLabelSchema`
42+
(#5728,裁定中),另有 8 条是 `element:text.content` 上同一形状。今天就 gate 掉它们,
43+
等于用平台自己都不遵守的声明去否掉平台自己的页面。warning 期的违例清单就是 error
44+
升级的验收基线,升级本身是独立一步。
45+
46+
作者侧不变:`properties` 仍然照原样解析、原样保留,没有任何东西开始被拒绝 ——
47+
`os validate` / `os build` / `os lint` 多了一类建议性诊断而已。存储路径(`saveMetaItem`
48+
/ REST `/meta`)仍然不校验 props 包,这一点被如实记录、未在本次修复。

docs/audits/2026-07-unknown-key-strictness-ledger.counts.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,11 @@ Remaining strip sites by class:
2929

3030
| Bucket | Sites |
3131
|---|---|
32-
| authorable — the ruling's forced scope | 13 |
32+
| authorable — the ruling's forced scope | 43 |
3333
| unresolved — needs a per-schema verdict | 33 |
3434
| wire / open — out of forced scope | 107 |
3535
| no door — no carrier, ADR-0049 territory | 14 |
36-
| no gate — carrier live, no parse | 30 |
36+
| no gate — carrier live, no parse | 0 |
3737

3838
## Posture, per triaged directory
3939

@@ -171,11 +171,11 @@ over it is here.
171171

172172
| Bucket | Sites |
173173
|---|---|
174-
| authorable — the ruling's forced scope | 4 |
174+
| authorable — the ruling's forced scope | 34 |
175175
| unresolved — needs a per-schema verdict | 0 |
176176
| wire / open — out of forced scope | 3 |
177177
| no door — no carrier, ADR-0049 territory | 14 |
178-
| no gate — carrier live, no parse | 30 |
178+
| no gate — carrier live, no parse | 0 |
179179

180180
### `data/` — open
181181

docs/audits/2026-07-unknown-key-strictness-ledger.md

Lines changed: 11 additions & 7 deletions
Large diffs are not rendered by default.

packages/lint/src/authoring-rules.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ import { validateWidgetBindings } from './validate-widget-bindings.js';
105105
import { validateDashboardActionRefs } from './validate-dashboard-action-refs.js';
106106
import { validateFilterTokens } from './validate-filter-tokens.js';
107107
import { validateReferenceIntegrity } from './reference-integrity-suite.js';
108+
import { validateComponentProps } from './validate-component-props.js';
108109
import { validateResponsiveStyles } from './validate-responsive-styles.js';
109110
import { validateJsxPages } from './validate-jsx-pages.js';
110111
import { validateReactPages } from './validate-react-pages.js';
@@ -454,6 +455,42 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
454455
runtimeTypes: ['flow'],
455456
run: (stack) => validateReferenceIntegrity(stack),
456457
},
458+
// ADR-0078 / #5068 — the SDUI component-props gate. `PageComponent.properties`
459+
// is `z.record(z.string(), z.unknown())` and ADR-0089 D3a strictness does not
460+
// recurse into it, so until this entry existed the 31 typed prop schemas in
461+
// `ComponentPropsMap` were parsed by NOTHING (#4001 批 17's `no gate`
462+
// verdict): an undeclared or wrongly-typed prop parsed clean, was retained,
463+
// and reached objectui's renderer to be ignored there. This dispatches on
464+
// `type` and judges the bag; unregistered types are skipped, which is a
465+
// required semantic (`type` is an open union — the example corpus authors 87
466+
// nodes of 10 types this map does not carry).
467+
//
468+
// `normalized` for a reason worth stating, since the props bag survives the
469+
// Zod parse UNCHANGED and both tiers would otherwise carry the same data: the
470+
// ADR-0087 conversion layer runs inside `normalizeStackInput`, so a converted
471+
// alias (`page-header-subtitle-alias` rewrites `properties.description` →
472+
// `subtitle`) is already canonical here and is never reported as undeclared —
473+
// while a schema error elsewhere in the stack cannot take these findings down
474+
// with it.
475+
//
476+
// Advisory, deliberately, and this is the whole shape of #5068's first step:
477+
// wiring the parse is the precondition for enforcement, not the enforcement
478+
// (#5020, one surface over). The live corpus violates the declarations in two
479+
// places that are open contract questions — inline i18n label maps on three
480+
// published platform pages (#5728) and the record picker's declared-but-unread
481+
// `displayField` (#5775) — so gating today would fail the platform's own pages
482+
// to enforce declarations the platform does not keep. The error upgrade is a
483+
// separate step, once the warning-period inventory is empty.
484+
{
485+
name: 'validateComponentProps',
486+
tier: 'advisory',
487+
input: 'normalized',
488+
commands: ALL,
489+
source: 'packages/lint/src/validate-component-props.ts',
490+
surfaces: CLI_ONLY,
491+
surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
492+
run: (stack) => validateComponentProps(stack),
493+
},
457494
// ADR-0065 — a styled node's responsiveStyles must be scopable (needs an
458495
// `id`), name real CSS properties + design tokens, and carry a `large` base.
459496
{

packages/lint/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,13 @@ export type { ActionLocationsFinding, ActionLocationsSeverity } from './validate
280280
export { validatePageFieldBindings, PAGE_FIELD_UNKNOWN } from './validate-page-field-bindings.js';
281281
export type { PageFieldFinding, PageFieldSeverity } from './validate-page-field-bindings.js';
282282

283+
export {
284+
validateComponentProps,
285+
COMPONENT_PROPS_UNKNOWN_KEY,
286+
COMPONENT_PROPS_INVALID,
287+
} from './validate-component-props.js';
288+
export type { ComponentPropsFinding, ComponentPropsSeverity } from './validate-component-props.js';
289+
283290
export {
284291
validateChartBindings,
285292
CHART_DIMENSION_UNKNOWN,

0 commit comments

Comments
 (0)