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 全绿 —— 而照抄的读者拿到红框。同一形状已复发三次(#4786 stats-card,#4796 plugin: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 schema action: { type: 'submit' } @object-ui/types ActionSchema
block schema type: 'block-instance' packages/types/src/blocks.ts
theme / report schema type: 'theme-switcher''matrix' types/src/theme.tsreports.ts
field / JSON-Schema 数据类型 type: 'string''currency' 变量 / 属性声明
校验规则 validation: [{ type: 'minLength' }] 表单规则判别式
nav / feed item type: '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:322 heading 仓内无人注册 h1(html-elements.tsxTAGS 注册,渲染节点 children);无读点的 level 一并去掉
content/docs/utilities/vscode-extension.mdx:90 heading 同上 同上
content/docs/plugins/plugin-form.mdx:210 multi-step-form 该字符串除这段片段外仓内不存在 object-form + formType: 'wizard' + sections,即 WizardFormSchema 自己声明的形状(WizardForm.tsx:46-68);section 列字段,因为向导从对象元数据解析字段

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

文件 词汇表
blocks/authentication.mdxecommerce.mdxforms.mdx submit ActionSchema(节点 action 键下)
blocks/dashboard.mdx navigate 同上
blocks/marketing.mdx analytics 同上
blocks/block-schema.mdx blockblock-instanceblock-libraryblock-editor packages/types/src/blocks.ts 的判别式 + Zod
blocks/block-schema.mdx slot ⚠️ 见下 —— 已立 #4895
blocks/block-schema.mdx string BlockVariable.type(变量数据类型)
components/complex/filter-ui.mdx date-range 筛选控件类型(crud.ts:329views.ts:804)
components/complex/view-switcher.mdx share TS 联合类型的首个成员(视图动作 id)
core/app-schema.mdx itemgroup AppSchema 菜单条目种类
core/enhanced-actions.mdx actionmessage ActionSchema(整页讲 action 词汇表)
core/report-schema.mdx linepage-breakreport-builderstring 图表系列 / reports.ts:210 段落枚举 / reports.ts:464 / 报表字段数据类型
core/theme-schema.mdx themetheme-previewtheme-switcher types/src/theme.ts 的判别式 + Zod
core/schema-renderer.mdx my-widget 「注册你自己的组件」教学占位符
guide/objectos-integration.mdx my-custom-widget 同上(lazy 自定义 widget 走查)
utilities/runner.mdx my-componentyour-component 同上(加载你自己的插件走查)
fields/object.mdx arraystring JSON Schema 属性类型
plugins/plugin-dashboard.mdx barline widgets[] 的 widget 种类
plugins/plugin-detail.mdx commentfield_change FeedItem 种类
plugins/plugin-form.mdx minLengthmaxLength validation[] 的规则判别式
plugins/plugin-grid.mdx count_unique columns[].summary 的聚合
plugins/plugin-report.mdx matrixjoined ReportInput 种类
utilities/vscode-extension.mdx ajaxapi onSubmit 的 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

claude added 2 commits August 17, 2026 02:09
…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>

Copy link
Copy Markdown
Collaborator Author

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 queue Aug 17, 2026
Merged via the queue into main with commit 97da1b0 Aug 17, 2026
20 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4823-prose-type-ratchet branch August 17, 2026 02:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding: 教学面 prose(content/docs/**.mdx 代码块)没有 catalog 侧 #4616 那样的「不许出现 OBJUI-001」棘轮,同一缺陷已复发三次

2 participants