Skip to content

fix(spec): gen:docs 保留 passthrough 对象的已声明键 + 开放性标记,不再塌缩成 Record (#4912) - #5339

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-4912-gen-docs-passthrough-shape
Aug 5, 2026
Merged

fix(spec): gen:docs 保留 passthrough 对象的已声明键 + 开放性标记,不再塌缩成 Record (#4912)#5339
os-zhuang merged 2 commits into
mainfrom
claude/issue-4912-gen-docs-passthrough-shape

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#4912

前提复核(先证红)

立案前提在 origin/main(c7406b0)上成立,用当前生成器复现:

content/docs/references/ui/bulk-action.mdx:98
| **options** | `Record< string, any >[]` | optional | Static options for select-style widgets. …

gen:schema 给该节点产出的确实是「两者兼有」:

{ "type": "object",
"properties": { "label": {...}, "value": {...} },
"required": ["label", "value"],
"additionalProperties": {} }

根因

build-docs.tsformatType()先判 additionalProperties、后判 properties,把两者当成了互斥的二选一:

if(prop.type==='object'&&prop.additionalProperties){return`Record< string, ${formatType(prop.additionalProperties,ctx)} >`;// ← 先命中,已声明键再也读不到}if(prop.type==='object'&&prop.properties){/* 渲染形状 */}

JSON Schema 把 .passthrough() / .catchall() 对象同时表达为 properties + additionalProperties,所以只要一个对象既有形状又开放,它的形状就被整个丢掉。声明键与开放性是两个独立的事实,渲染器把它们做成了互斥。

全量语料扫描:130 个节点同时带 properties 与开放的 additionalProperties(全部是 additionalProperties: {}),分布在 40 个 schema 文件里。

修法

{ 已声明形状 } & Record< …, … > —— 声明形状在前(读者必须写的键),开放性在后:

  • 之前:Record< string, any >[]
  • 之后:({ label: string; value: string | number | boolean } & Record< string, any >)[]

三个细节:

  1. 数组元素的括号是必需的。A & B[] 在 TypeScript 里是 A & (B[]),不加括号等于声明了另一种类型。用 hasTopLevelIntersection() 做深度扫描(正确忽略 {} / < > / [] / () 内部的运算符)后再决定是否加括号。
  2. & Record 不是一回事,单元格两者都印。 = 还有更多已声明键(超过四个时省略);& Record = 还接受未声明键。
  3. 空的 properties: {} 不算形状,继续走原来的 record / opaque 渲染,不会印出 { } & Record< … >。语料里有 6 个这样的退化节点,行为逐字保持不变。

渲染器从 build-docs.ts 抽到 packages/spec/scripts/lib/format-type.ts 并配单测。抽出前只能「跑完整个生成器再 grep 产出的 .mdx」才能断言它的输出 —— 这正是该塌缩能在整个 #4001 战役期间无人察觉的原因。抽取本身先证明了行为中性:仅搬移不改逻辑时重生成 244 个文件、diff 为空,然后才落修复。

重生成影响面(逐页确认,共 6 页 12 个单元格)

每一处都是恢复被抹掉的声明键,没有任何一页丢失形状:

页面属性恢复出来的声明键
ui/bulk-actionoptionslabel / value(必填,即立案样本)
ui/bulk-actionparamsname / label / help / type
ui/viewgantt ×2startDateField / endDateField / titleField …(ListView、ObjectListView 各一份)
ui/viewtree ×2parentField / labelField / fields / defaultExpandedDepth
ui/dashboardwidget optionsdateGranularity / sortBy / sortOrder / limit
api/protocolmessages ×3role / content / parts
system/auth-configsocialProvidersclientId / clientSecret / enabled / scope(嵌在 Record< string, … > 的值位)
kernel/startup-orchestratorpluginname / version

130 个节点只落到 12 个单元格,是因为其余节点要么在深度限制之外(嵌套对象按设计保持 opaque),要么不出现在被渲染的属性表位置。

反向验证(方向:红 —— 事前即如此预判)

这些用例断言的是修复产出的正形状,不是「某个 finding 消失」,所以恢复缺陷应当让它们变红,而不是出现计数反转或倒置。把 lib 里的分支顺序改回「先 additionalProperties」并去掉数组括号后:

× renders an array of passthrough objects with BOTH the declared keys and the openness marker
× parenthesizes an intersection element before suffixing `[]`, but not a plain one
× marks required vs optional keys on an open object the same way a closed one does
× keeps the typed catchall in the marker instead of widening it to `any`
× still elides beyond the fourth declared key, and the marker survives the elision
× keeps nested objects opaque so a table cell cannot explode
Tests 6 failed | 6 passed (12)

绿的那 6 个正是「本来就渲染对的形状」(纯 record、additionalProperties: false 的封闭对象、无形状对象、空 properties$ref 链接)—— 这也解释了缺陷为何能存活:渲染器在所有有人想到去看的形状上都是对的。

备选渲染形态

issue 里给了两种:{ … } & Record< … >,或「表格 + 一行『接受额外键』」。本 PR 取前者 —— 参考页的类型单元格历来就是一段可直接抄走的 TypeScript(Enum< 'a' | 'b' >{ a: string }[]Record< string, any > 都是既有惯例),交叉类型无需改动表格结构即可同时承载两个事实。后者要给每张表加一列或一行,收益只在于更显眼,代价是改动所有页面的表结构。

顺带发现(未在本 PR 修)

数组元素是顶层联合时有一模一样的优先级缺陷(string | number[]( string | number )[]),164 处。它早于本次改动,已按 PD#10 立案 #5338(未认领),format-type.ts 内留注释指向该单。一并修会让重生成 diff 从 12 行涨到约 170 行,把真正的 passthrough 修复埋掉。

验证

pnpm --filter @objectstack/spec typecheck → tsc --noEmit,无输出(通过)
pnpm --filter @objectstack/spec test → Test Files 313 passed / Tests 8034 passed
vitest run scripts/format-type.test.ts → 12 passed(新增)
pnpm --filter @objectstack/spec check:docs → ✅ 244 generated files in sync with packages/spec
node scripts/check-nul-bytes.mjs → OK(5379 个文件,无裸 NUL)

未触碰 packages/spec/src/**(无 zod 源改动,故 api-surface / authorable-surface 不受影响)、未触碰 docs/audits/2026-07-unknown-key-strictness-ledger*content/docs/releases/ 零改动。


Generated by Claude Code

… gen:docs (#4912)
The reference-docs type renderer tested `additionalProperties` BEFORE
`properties`, treating them as alternatives. JSON Schema spells a
`.passthrough()` / `.catchall()` object as BOTH at once, so every object that
declared a shape and also accepted extra keys collapsed to a bare
`Record<string, any>` — erasing keys the schema requires.
`BulkActionParam.options` is the specimen: `label` / `value` are required, yet
the page showed `Record<string, any>[]`, i.e. no shape at all. PR #4909
compensated by hand in that key's `.describe()` prose — per-site compensation,
not a fix, and the #4001 campaign keeps producing more such sites.
Declared keys and openness are independent facts and are now printed
independently: `({ label: string; value: string | number | boolean } &
Record<string, any>)[]`. The parentheses are load-bearing — `A & B[]` is
`A & (B[])` in TypeScript, so an unbracketed array element would state a
different type than the schema.
Arrays whose element is a top-level UNION have the identical precedence defect
on 164 sites, but that one predates this change and is filed as #5338 rather
than bundled here.
The renderer moves from build-docs.ts to scripts/lib/format-type.ts with unit
tests: asserting its output previously required running the whole generator and
grepping the emitted .mdx, which is why this collapse went unnoticed. The
extraction was verified behavior-neutral first (244 regenerated files, zero
diff) before the fix landed.
Regenerated: 12 cells across 6 reference pages, every one restoring declared
keys, none losing shape.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FTszibd6C8sUCCZnM4VcrL
@vercel

vercelBot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 5, 2026 12:36am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

No hand-written docs reference the 0 changed package(s). ✅

@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

已同步 main(入队前的 os-regen 复核)

base 从 c7406b0 前进到 c89d18c。期间 main 动了本 PR 要整体重新生成的同一批 merge=os-regen 路径,按硬纪律没有做文本合并:

  1. git merge origin/main —— exit 0,零冲突标记,os-regen-pending为空(两侧改到的生成物路径互不相交,驱动因此根本没被调用);
  2. git checkout origin/main -- <.gitattributes 里全部 9 条 os-regen 路径> —— 先把生成物整体取回 main 的版本(references/ 整棵树先 rm -rf 再取,确保 main 的删除不会以残留文件形式存活);取回后 bulk-action.mdx 确认已退回 Record< string, any >[],即重新生成是一次真实检验;
  3. pnpm install --frozen-lockfile + gen:schema + gen:docs 整体重新生成(1651 → 1619 个 schema,244 → 239 张页面,反映 main 的退役)。

结论:两侧都完好

本 PR 侧:重新生成后,与 origin/main全部 os-regen 路径上的差异恰好是本 PR 的 12 个单元格 / 6 张页面,一字不多。

main 侧:退役的 5 张页(animation / dnd / keyboard / offline / touch)保持删除、没有被复活;ui/index.mdxui/meta.json 与 main 逐字节相同;实现体抽查也在 —— 我重新生成的 ui/view.mdxViewItemWire 仍在(#5074/#5319),ui/theme.mdx 与 main 逐字节相同,退役墓碑在 protocol-upgrade-guide.md 里完好。

一个值得记录的观察:重新生成的产物与 merge commit 完全一致(git commit 报 nothing to commit)。本次两侧生成物路径不相交,所以驱动没有吞掉任何东西 —— 这是独立重新生成给出的旁证,不是「假设没事」。

一处刻意不动:authorable-surface.base.json

build-schemas.ts每次运行(含 --check)都会把它重新锚定到 merge base。在这里那会把锚从 1c3da1f(退役前)推到 c89d18c(退役commit本身),并删掉 110 条已记录键 —— 等于让一个纯文档渲染 PR 顺手把 main 的退役从删除门前挪过去。因此保留 main 的字节,门本身两种版本都绿,并明确报告 trails the merge base by 0 key(s)

重新验证(合并后真实输出)

pnpm --filter @objectstack/spec build → 完成(check:api-surface 需要真实 dist)
pnpm --filter @objectstack/spec check:generated
✓ 全部 9 项 generated artifacts up to date
(spec-changes / upgrade-guide / skill-docs / skill-refs / react-blocks /
authorable-surface / api-surface / docs / strictness-ledger)
pnpm --filter @objectstack/spec check:docs → ✅ 239 generated files in sync with packages/spec
pnpm --filter @objectstack/spec test → Test Files 309 passed / Tests 7897 passed
pnpm --filter @objectstack/spec typecheck → tsc --noEmit,无输出
node scripts/check-nul-bytes.mjs → OK(5371 个文件,无裸 NUL)

正文「验证」一节里的 244 / 313 / 8034 是合并之前的数字,以本条为准(239 / 309 / 7897 —— 差额全部来自 main 的退役,不是本 PR)。

未转 ready、未挂 auto-merge,入队由 PM 执行。


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-zhuang@claude