Skip to content

chore(scripts): 教学面 prose 的 OBJUI-001 棘轮 —— 文档代码块的 type 必须是注册键 - #4900

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-4823-prose-type-ratchet
Aug 17, 2026
Merged

chore(scripts): 教学面 prose 的 OBJUI-001 棘轮 —— 文档代码块的 type 必须是注册键#4900
yinlianghui merged 2 commits into
mainfrom
claude/issue-4823-prose-type-ratchet

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#4823

catalog 侧从 #4616 起就有棘轮(examples/schema-catalog/test/catalog-gallery-render.test.tsx 渲染每条 entry,出 OBJUI-001 就红)。教学面 prose 没有等价物:content/docs/**.mdx 的代码块不被任何东西渲染、解析或比对,所以片段可以写任意 type 字符串而 CI 全绿 —— 而照抄的读者拿到红框。同一形状已复发三次(#4786stats-card,#4796plugin:grid / plugin:map),三次都靠人工探针发现。本 PR 按卡内裁量取最小静态门补上缺失的那一半。

⛔ 第二维度(片段其余键 vs renderer 读点)按卡明确不做。

门的形状

新脚本 scripts/check-doc-component-types.mjs + 其测试 + 独立 workflow。判据一句话:

文档代码块里的每一个type 字面量都是候选 SDUI 组件键。值在派生出的注册键全集里就过;不在,该文件必须在 DOC_TYPE_EXEMPTIONS声明它,并写明它真正属于哪个词汇表。其余一律红。

注册键全集:派生,不是清单

⛔ 没有硬编码键表 —— 每次运行都从 register 调用本身派生,且不需要 build(这正是它能作为 per-PR 轻量检查、和 check-doc-links.mjs 同级运行的原因):

  • DIRECT —— X.register('key', …) / registerLazy,namespaceskipFallback该调用自己的配对实参跨度里读,不是定长窗口。这一点是承重的,而且是量出来的:用 1500 字符窗口时,plugin-grid/src/index.tsx:129object-grid 会读到 12 行之下 grid 那次注册的 skipFallback: true,裸键 object-grid 被悄悄丢掉 —— 而文档里有 13 处正确地教它。派生的窗口 bug 表现为对正确文案的假红,所以跨度是配对出来的,不是猜的。
  • LOOP —— for (const v of ['a','b'])for (const v of ARR)ARR.forEach(v => …)(html-elements.tsxTAGSsemantic.tsxtagsapps/console 的三个 variant 循环)。
  • INDIRECT —— 两个从集合注册的 helper(placeholders.tsxPROTOCOL_COMPONENTSfields/src/index.tsxfieldWidgetMap + FIELD_TYPES_SKIP_FALLBACK),各自在 INDIRECT_REGISTRATIONS 里指名它读的集合,每次重新派生;集合没了就报 stale-indirect-registration 而不是让全集缩水。
  • OPEN —— 静态不可知的两处(PluginScopeImpl 转发第三方插件自己的 type、WidgetRegistrymanifest.type)在 OPEN_REGISTRATION_SITES 里带理由列出。其余任何解析不出键的调用点都让门红(unresolved-registration):漏掉一条动态注册路径会缩小全集,而缩小的全集把正确文档判红。

全集刻意:union 仓内任何包/app 能注册的键,不建模「哪个 host 加载了哪个包」。判据是「这个字符串命名的组件存在吗」,不是「它在这页所描述的 host 里注册了吗」—— 后者每页答案不同,猜它正是产生假红、进而让门被删掉的路子。

判别策略:为什么没有结构判别器

type 在这些页面里不是一个词汇表。全量测过 143 个文件、代码块内 558 处 type 字面量,至少七种词汇表共用这个键:

词汇表声明处
SDUI 组件键{ type: 'object-grid' }ComponentRegistry(本门唯一判的)
action schemaaction: { type: 'submit' }@object-ui/types ActionSchema
block schematype: 'block-instance'packages/types/src/blocks.ts
theme / report schematype: 'theme-switcher''matrix'types/src/theme.tsreports.ts
field / JSON-Schema 数据类型type: 'string''currency'变量 / 属性声明
校验规则validation: [{ type: 'minLength' }]表单规则判别式
nav / feed itemtype: 'item''comment'菜单项、动态流条目

显而易见的判别器 —— 按外层键路径分类(validation 下的是规则,children 下的是节点)—— 先建后量再否。两个测量结果杀掉了它:

  1. 片段是 TypeScript 的次数不比 JSON 少,而 TS 注解对括号追踪器来说和对象键一模一样:const heroBlock: BlockSchema = {heroBlock 成了其内一切的外层键,92 处 off-registry 里有 31 处算出人看不懂的路径。
  2. 更致命的是它不收敛:items 在一页承载 nav 条目、在另一页承载可渲染 children,任何全局的父键规则必然在其中一页上是静默假绿。误分类的判别器比没有判别器更坏 —— 它的错误在两个方向上都不可见。

所以规则是平的、写明的,判别落在显式豁免表上,键为 (file, value):

  • ⛔ 不做整文件豁免 —— blocks/block-schema.mdx 同一份文档里同时有 type: 'block'type: 'div'
  • ⛔ 不做纯 value 豁免 —— 那会让树上任何一页都能把 submit 教成组件。
  • 每条必须带写明的理由,指出它真正属于哪个词汇表及其声明处。
  • 每条每次运行重新派生:文件不再拼写该值就报 stale-exemption(红)。

判别规则原文写在脚本头,连同上面这次测量。

mdx 全扫读数

首轮全扫:143 个 mdx 文件、632 个代码块、558 处 type 字面量,对 661 个派生键(来自 135 个源文件:236 个直接调用点、131 个间接、2 个 open)。

  • 469 处命中注册键(直接过)
  • 89 处豁免,分布在 23 个文件、35 条 (file, value) 声明
  • 3 处真缺陷,同 PR 修正(见下)
  • 0 处 stale-exemption / unresolved-registration

修正的三处(同 #4823 三次复发同形)

文件教的事实改为
content/docs/utilities/runner.mdx:322heading仓内无人注册h1(html-elements.tsxTAGS 注册,渲染节点 children);无读点的 level 一并去掉
content/docs/utilities/vscode-extension.mdx:90heading同上同上
content/docs/plugins/plugin-form.mdx:210multi-step-form该字符串除这段片段外仓内不存在object-form + formType: 'wizard' + sections,即 WizardFormSchema 自己声明的形状(WizardForm.tsx:46-68);section 列字段,因为向导从对象元数据解析字段

豁免清单(23 文件 / 35 条)

文件词汇表
blocks/authentication.mdxecommerce.mdxforms.mdxsubmitActionSchema(节点 action 键下)
blocks/dashboard.mdxnavigate同上
blocks/marketing.mdxanalytics同上
blocks/block-schema.mdxblockblock-instanceblock-libraryblock-editorpackages/types/src/blocks.ts 的判别式 + Zod
blocks/block-schema.mdxslot⚠️ 见下 —— 已立 #4895
blocks/block-schema.mdxstringBlockVariable.type(变量数据类型)
components/complex/filter-ui.mdxdate-range筛选控件类型(crud.ts:329views.ts:804)
components/complex/view-switcher.mdxshareTS 联合类型的首个成员(视图动作 id)
core/app-schema.mdxitemgroupAppSchema 菜单条目种类
core/enhanced-actions.mdxactionmessageActionSchema(整页讲 action 词汇表)
core/report-schema.mdxlinepage-breakreport-builderstring图表系列 / reports.ts:210 段落枚举 / reports.ts:464 / 报表字段数据类型
core/theme-schema.mdxthemetheme-previewtheme-switchertypes/src/theme.ts 的判别式 + Zod
core/schema-renderer.mdxmy-widget「注册你自己的组件」教学占位符
guide/objectos-integration.mdxmy-custom-widget同上(lazy 自定义 widget 走查)
utilities/runner.mdxmy-componentyour-component同上(加载你自己的插件走查)
fields/object.mdxarraystringJSON Schema 属性类型
plugins/plugin-dashboard.mdxbarlinewidgets[] 的 widget 种类
plugins/plugin-detail.mdxcommentfield_changeFeedItem 种类
plugins/plugin-form.mdxminLengthmaxLengthvalidation[] 的规则判别式
plugins/plugin-grid.mdxcount_uniquecolumns[].summary 的聚合
plugins/plugin-report.mdxmatrixjoinedReportInput 种类
utilities/vscode-extension.mdxajaxapionSubmit 的 action / dataSource 的数据源种类

⚠️slot 是唯一一条「已知有问题、但不在本单裁量内」的豁免:BlockSchema.template 声明为 SchemaNode,所以它在渲染路径上,而 slot 仓内不存在。正确写法不唯一(给插槽占位符定一个真节点类型并注册,还是改走已声明的 slotContent),两种读法通向不同架构 —— 已立 #4895,豁免理由里写明「#4895 落地时删除本条」,stale-exemption 检查让这条指令可执行而不只是一句话。

CI 接线

新 workflow .github/workflows/doc-component-types.yml,不是ci.yml 里的一步。理由比同族三个更硬:ci.ymltype-check job 用一个排除 content/**git diff 决定是否跑它的门,所以只改 content/docs/**.mdx 的 PR 会报 context 却一个门都不跑 —— 而 docs-only PR 正是引入这个缺陷的那种改动。把门放那儿,它就看不见任何能触发它的变更。

这是仓内该形状的第五例(前四:docs-links.ymlcontrol-bytes.ymlchangeset-guard.ymlskills-paths.yml),⛔ 无 paths / paths-ignore,无 install,无 build —— checkout 加一次 node 调用。测试断言:脚本只 import node: 内建、workflow 里没有 pnpm install、没有任何 path filter、且只有一个家

同时补了 content/docs/guide/ci-cd-pipeline.md(该仓有门禁要求每个 workflow 有自己的章节 + inventory 行)。

测试

scripts/__tests__/check-doc-component-types.test.ts,24 例,按「门会怎么坏」的顺序排:

  1. 派生(键漏掉才是贵的方向)—— 命名空间 + 裸回退、skipFallback 归属(即上面那个定长窗口 bug 的定型)、三种循环形态、注释/字符串里的 register 不算注册、解析不出的键报错而不是丢、测试文件里的注册不入全集。
  2. 文档扫描 —— JSON / 对象字面量两种拼法、⛔ 不把 JSX type=schema.type 当站点、未闭合围栏报错而不是猜。
  3. 判决 —— 注册键(裸 + 带命名空间)过;finding: 教学面 prose(content/docs/**.mdx 代码块)没有 catalog 侧 #4616 那样的「不许出现 OBJUI-001」棘轮,同一缺陷已复发三次 #4823 那三次复发(stats-card / plugin:grid / plugin:map)逐条红,含 file:line。
  4. 豁免表承重且被重新派生 —— 活表零 stale;清空表则本仓变红(方向先预判后跑);无理由的条目不算豁免。
  5. 解析非空下限 —— 空树 0 站点;本仓远超每条 floor。
  6. 接线 —— 见上。

夹具一律临时树,⛔ 不用真 content/docs:committed 的夹具页必须含一个故意写错的 type,而这道门自己会扫到它。

验证读数

scripts/__tests__ 全量 48 files / 1117 tests passed
turbo run type-check 81 successful, 81 total (exit 0)
node scripts/check-control-bytes.mjs OK (4374 tracked text files)
node scripts/check-doc-links.mjs Links are valid across 13 scan roots
node scripts/check-doc-component-types.mjs green(读数见上)

反向验证(三个方向,均先预判后跑)

方向预判实测
fields/grid.mdx 的真键改回 plugin:grid(即 #4796 原缺陷)红,指名文件、行、值✅ 红,exit 1,content/docs/fields/grid.mdx:180 [unregistered-doc-type] type 'plugin:grid' + 源行;还原后绿
清空 DOC_TYPE_EXEMPTIONS恰好 89 条 unregistered-doc-type(= 当前豁免站点数),0 条 stale-exemption(表空则无条目可失效)✅ 89 条,全为 unregistered-doc-type,0 exempted;还原后绿
把配对跨度改回 1500 字符定长窗口object-grid 掉出全集 → 13 处正确站点变红(派生 bug 的表现是对正确文案假红)✅ 恰 13 条(26 行 = 13 findings × 2);还原后绿

第三个方向是刻意加的:这道门最贵的坏法不是漏报,而是派生缺键导致的假红,而那正是量出来、也是脚本头里写明的那处。

顺带发现


Generated by Claude Code

…4823)
`content/docs/**.mdx` code blocks are rendered by nothing, parsed by nothing and
compared against nothing, so a snippet could teach a `type` that no package
registers while every check in the repo stayed green — and a reader who copied
it got the renderer's red "Unknown component type" panel (OBJUI-001). The
catalog side has had the equivalent ratchet since #4616
(`examples/schema-catalog/test/catalog-gallery-render.test.tsx`); this is the
missing half. The same defect had already landed three times on the teaching
surface — #4786 `stats-card`, #4796 `plugin:grid` and `plugin:map` — each found
by a human probe rather than by a check.
`scripts/check-doc-component-types.mjs` derives the registered-key universe from
the `register(…)` / `registerLazy(…)` calls themselves (direct literals, the
three loop forms, and two collection-driven helpers named explicitly), reading
`namespace` and `skipFallback` out of each call's own balanced argument span
rather than a fixed window — a window bug there drops real keys and turns
correct prose red, which is measurable on `plugin-grid/src/index.tsx` where
`object-grid` sits 12 lines above a `skipFallback` that is not its own. A
registration whose key cannot be resolved fails the gate instead of being
skipped, for the same reason.
`type` is not one vocabulary in these pages: 558 literals across 143 files spell
action schemas, block schemas, theme/report schemas, field and JSON-Schema data
types, validation rules and nav items under the same key. A structural
discriminator (classify by enclosing key path) was built and rejected on
measurement — TypeScript annotations read as object keys to a brace tracker, and
`items` carries nav entries on one page and renderable children on another, so
any global rule is a silent false green somewhere. So every literal is a
candidate component key, and a value outside the universe must be DECLARED in
`DOC_TYPE_EXEMPTIONS`, keyed by (file, value) with a written reason naming the
vocabulary it belongs to. Entries are re-derived per run; a stale one is red.
Three snippets of #4823's own shape are fixed here, found by the first full
scan: `utilities/runner.mdx` and `utilities/vscode-extension.mdx` taught
`heading` (nothing registers it — now `h1`, which `html-elements.tsx` registers
and which renders the node's `children`; the unread `level` key goes with it),
and `plugins/plugin-form.mdx` taught `multi-step-form`, a string that appears
nowhere else in the repo — now the `object-form` + `formType: 'wizard'` +
`sections` shape `WizardFormSchema` itself declares.
Wired as its own workflow, not a step in `ci.yml`: that job's relevance filter
excludes `content/**`, so a docs-only PR — the change that introduces this
defect — would start the gate nowhere. Fifth instance of the shape after
`docs-links.yml`, `control-bytes.yml`, `changeset-guard.yml` and
`skills-paths.yml`; no install, no build.
Not in scope, per the issue: whether a snippet's OTHER keys are read by the
renderer its type resolves to.
Co-authored-by: Claude <noreply@anthropic.com>
…cate add (#4823)
`content/docs/blocks/block-schema.mdx` teaches `{ type: 'slot' }` inside
`BlockSchema.template`, which is declared as a `SchemaNode` — so unlike the four
block discriminants beside it, that one sits on the render path and nothing
registers it. The correct spelling is not one thing (register a slot node, or
route the snippet through the declared `slotContent` key), which is a direction
#4823 deliberately does not pre-decide, so it is filed as #4895 and the
exemption's reason now names it plus the instruction to delete the entry when
#4895 lands. The stale-exemption check makes that instruction enforceable rather
than a note.
Also collapses the redundant second `add()` for `PROTOCOL_COMPONENTS` — its
entries are already namespaced strings, so the bare form is what the namespace
branch above had produced a line earlier. Same key set, one site list per key.
Co-authored-by: Claude <noreply@anthropic.com>
@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM 验收:ACCEPT(#4823,批次 15,PM 会话 session_01GTRjn8xBqp75dk7kFupVRt)

实物核验(已过)

  • scripts/check-doc-component-types.mjs(902 行)prose type 棘轮门:661 个合法键沿四条路径派生(非手抄清单,随源演进),存量豁免收敛为 89 条 (file,value) 精确表 —— 棘轮方向正确:存量封顶、新增即红。
  • 3 处 mdx 真缺陷(门跑出来的实锤)在同 PR 修正,门与修一体交付。
  • diff --stat 对账一致;模型标识 grep 0;content/docs/releases/** 零触碰。

反向验证

  • 三方向变异证负:伪造非法 type → 红;删豁免表条目 → 红;派生路径断供 → 红(防解析器静默空集假绿)。
  • NUL 分隔符缺陷自纠:拼接键具化出真 NUL 字节使文件对 grep 隐形,dev 改用 JSON.stringify([file,value]) 做键后复验 —— 这类自纠正是反向验证要抓的。

CI(亲读终态):19 项 check runs 全 completed,17 success + 2 skipped(path-filter 计绿),零失败;新门 Doc Component Type Check 自身已在本 PR 上跑绿

附带产出:新 finding #4895 已立卡入池。

→ undraft + auto-merge (SQUASH)。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 17, 2026 02:41
@yinlianghui
yinlianghui added this pull request to the merge queueAug 17, 2026
Merged via the queue into main with commit 97da1b0Aug 17, 2026
20 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4823-prose-type-ratchet branch August 17, 2026 02:42
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@yinlianghui@claude