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-action options label / value(必填,即立案样本)
ui/bulk-action params name / label / help / type
ui/view gantt ×2 startDateField / endDateField / titleField …(ListView、ObjectListView 各一份)
ui/view tree ×2 parentField / labelField / fields / defaultExpandedDepth
ui/dashboard widget options dateGranularity / sortBy / sortOrder / limit
api/protocol messages ×3 role / content / parts
system/auth-config socialProviders clientId / clientSecret / enabled / scope(嵌在 Record< string, … > 的值位)
kernel/startup-orchestrator plugin name / 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

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 5, 2026 12:36am

Request Review

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

Copy link
Copy Markdown
Contributor Author

已同步 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 free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants