Skip to content

fix(spec): zodShapeOf 补齐 union 分支与 prefault,并附 #5056 测量轮 (#6098) - #6222

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-6098-zod-graph-unwrap-parity
Aug 7, 2026
Merged

fix(spec): zodShapeOf 补齐 union 分支与 prefault,并附 #5056 测量轮 (#6098)#6222
os-zhuang merged 2 commits into
mainfrom
claude/issue-6098-zod-graph-unwrap-parity

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #6098

zodShapeOf#5317 把管道方向修对之后,仍比三个同族 walker 窄两格:没有 union 分支wrapper 集合缺 prefault。本 PR 把两格补齐,并按 #5056 要求做了完整的测量轮——两格分开量,逐条解释判定移动与桥项变化。

改了什么

packages/spec/scripts/lib/zod-graph.ts

  1. union 分支mergedUnionShape):union 解为全部成员键的合并,递归(view 的 OUT union 第 0 个成员本身又是 union),同名键取第一个成员的实例
  2. prefault 加入 SHAPE_WRAPPER_TYPES。该常量同时被 pipeInIsTransform 读,所以「藏在 prefault 后面的 preprocess transform」两端都能看见。

合并规则为什么跟 keysOf / keyPosture(合并全部成员)而不是 check-liveness shapeOf(取第一个 object 成员):两者被问的问题不同。shapeOf 治理的是「规范可编写容器」的台账,取首个 object 成员是它的设计答案;这个 walker 喂的是可达性,漏一个键只会白白豁免一个 tombstone,所以取最宽的读法。

#5056 测量轮(本单的核心)

方法同 #5317:在 computeSurfaceReachability 末尾临时 dump 全部 1610 个 def 的 reachableVia 判定 + 完整 bridged 表 + 每个桥项的贡献者 + 每个 def 的 holder(从全部 emitted def 出发的反向 BFS),改前/改后各跑一次 gen:schema 做 diff。该 instrumentation 已在提交前完全还原,本 PR 不含 build-schemas.ts 的任何改动。

两格分开量

判定移动 桥项变化 生成物
只加 prefault 0 0(1518 → 1518,新增/丢失均为 0) 无 diff
再加 union 18 1518 → 1519 无 diff

prefault 之所以完全惰性:25 个 metadata-type 根的闭包里一个 prefault 节点都没有,且 .prefault( 在整个 packages/没有任何调用点。它是 parity 而不是 fix——五个同族 walker(check-liveness.mtsmetadata-authoring-lint.tsmetadata-form-zod-reconciliation.test.tsmetadata-type-schemas.test.tsshared/strict-object.ts)都剥 prefault,只有这一个不剥;第一个写下 .prefault() 的人,会恰好只在这个喂删除门禁的 walker 上静默丢掉 shape。

bridged 表:唯一一个新增桥项,逐条解释

1518 → 1519 pairs(1518 → 1519 instances),新增 1、丢失 0
  + children@[instance]   贡献者:ui/NavigationItem(一个 z.lazy 节点)
  • 为什么只有 1 个(也是本轮最重要的结论):放宽这个 walker 原则上不可能扩大桥表。任何解出的 shape 最终都来自某个 object 节点,而 zodChildSchemas 会走 union 的 options、wrapper 的 innerType、pipe 的两侧和 lazy 的 getter——那个 object 节点本来就在 BFS 闭包里,早已贡献过同样的 (name, instance) 对。union 节点自己解出的合并 shape 只是它成员已贡献内容的子集。
  • 那这 1 个是怎么来的NavigationItemSchema = z.lazy(() => z.discriminatedUnion('type', [... .extend({ children })...])),其 getter 没有记忆化(实测 getter() !== getter()),每次调用都会 .extend()全新的 children 实例zodChildSchemas 调一次 getter 入队,zodShapeOf 又调一次拿到另一份——于是桥表里多了一个从未被 BFS 访问过的实例
  • 它是不是 #4001 战役的门测量 BFS 会误报「可达」:zod .describe() 共享 def 对象,任意单属性 bridge 把无关形状连起来 #5056 说的假可达桥?不是,它谁也匹配不上:实测0 个 def 命中该桥项(每次 zodShapeOf 都会 mint 新实例,身份永远对不上)。它是惰性的,不改变任何判定。
  • 由此还看到一个与本单无关的观察项(已另行开 非记忆化 z.lazy getter 让 zodShapeOf 每次解出**新实例**:桥项进得去、永远匹配不上(#6098 测量轮的副产品) #6221,不在本 PR 修):非记忆化 z.lazy 的 shape 实例身份不稳定。今天不产生错误答案——ui/NavigationItem 自身在 visited 里(先于 shape 判定返回 root-graph),data/FilterArray 则根本解不出 shape、维持 fail-closed。

判定移动:18 条,全部发生在查询侧

总量:root-graph 520 → 502null 1084 → 1101derived-clone 6 → 7

18 条移动没有一条来自新桥项——全部来自 reachableViaif (!shape) return 'root-graph' 那个 fail-closed 兜底:这些 def 自己的 shape 现在解得出来了,于是改由真实判定回答。

⚠️ 这里必须诚实纠正派发令的一处预设。 派发令要求特别标注 root-graph → derived-clone#5056 的假可达面)。实测这个方向只有 1 条,而且它不改变严格度——root-graphderived-clone 对门禁都是「可达」,同样要求 tombstone,变的只是提示语。真正需要盯的是派发令没有预期的另一个方向:17 条 root-graph → null,而 null豁免 tombstone。这是本轮唯一的放宽,所以下面逐条给证据。

(a) 唯一一条 root-graph → derived-clone

def 判定 证据
ui/ViewItem root-graph(兜底) → derived-clone 它是 union、不在 visited。现在解出 18 键,其中 name 命中的桥实例 ui/ViewItemName view 根 pipe、其 OUT union、ui/ViewItemWire 及两个扁平 view 成员是同一个实例_lock 等 ADR-0010 provenance 字段同理。这是真桥——ViewItem 确实通过 view 根 union 成员的派生克隆可编写。且该桥项改动前就已存在(由那两个 object 成员贡献),本次只是它自己的 shape 终于解得出来去命中它。

(b) 17 条 root-graph → null(放宽方向,逐条 holder 证据)

全部 17 个都是 union 类型、visited=false(BFS 身份不可达——这点本次改动前后完全一致visited 恒为 4900 个节点),且没有任何桥命中。判定它们「不可达」的最强旁证是 holder:每一个的 holder 家族本来就全部答 null,或者根本没有 holder(顶层 export,没人内嵌)。这正是 #5317ui/InlineAction 用过的同一条论证。

def holders(方括号内为 holder 当前判定)
ai/KnowledgeSourceKind ai/KnowledgeSource[null]
ai/MessageContent ai/ConversationMessage[null]
api/DeviceTokenResponse 无(仅顶层 export)
api/WebSocketMessage 无(仅顶层 export)
data/DataEngineRequest 无(仅顶层 export)
data/FileLikeValue 无(仅顶层 export)
data/GroupByNode data/Query[null]
integration/ConnectorInstanceAuth integration/Connector[null]integration/DeclarativeConnectorEntry[null]
kernel/ManifestPermissions 12 个 holder,全部 [null]kernel/Manifestkernel/InstalledPackagekernel/UpgradeSnapshot 及 9 个 api/*Package* 请求/响应)
system/CRDTState system/CRDTMergeResult[null]
system/MigrationOperation system/ChangeSet[null]
system/OTComponent system/OTOperation[null]system/OTTransformResult[null]
system/SpecifierHandler system/Specifier[null]
system/TenantIsolationConfig 无(仅顶层 export)
ui/ChartGroupBy ui/ChartAggregate[null]
ui/RecordHighlightsField ui/RecordHighlightsProps[null](react-block props 家族,#5317 记录过 ui/Element*Props 早已全体答 null)
ui/WidgetSource ui/WidgetManifest[null]

判定:这 17 条是把兜底换成测量,不是放松门禁。 它们此前答 root-graph 的原因与可达性无关——只是「union 解不出 shape」这一个 walker 缺口;它们的 holder 家族早已全体答 null,现在它们和自己的家族答案一致了。#5317ui/InlineAction 做的正是同一件事(root-graph 兜底 → null 实测),并已随 PR #6102 合入。

合并顺序(同名键取第一个成员)——已测,今天无差别

同名不同实例的碰撞在闭包的 92 个 union 节点里出现在 11 个上(view 的 4 成员 union 就碰撞 21 个键)。因为返回类型是「一个名字一个实例」,后面成员的实例不会进入记录,理论上会漏掉一个桥测试。实测:把同一函数改成 last-member-wins 重跑一遍,1610 个 def 的判定完全一致,桥项总数同为 1519。所以这个选择今天不影响任何结论;真要影响的那天,修法在消费侧的桥(name → instance 集合),而不是在这里放宽测试——这一点写进了函数注释。

分片生成物:零移动

gen:schema 全程没有打印任何 📒 … — commit it.(该行只在分片字节真的变化时才打),改动后 git statusauthorable-surface/json-schema.manifest/api-surface/ 全部干净,check:generated 报告 10/10 up to date

一处对派发令措辞的更正:#6069 之后 gen:schema逐分片打印 IDENTICAL/CHANGED——writeShards 对字节未变的分片直接跳过、不写不报,沉默即 identical,只有真写了才打 touched: 行。所以「零移动」的凭据是「没有那一行 + check:generated 全绿 + git status 干净」这三条。

测试:那条 view 钉子是转换,不是删除

packages/spec/scripts/zod-graph.test.tsdocuments that view's preprocess OUT is a union, so it still derives no shape 钉的是「方向修对但仍解不出 shape」这一诚实现状,本 PR 恰好消除了这个限制。按要求转换resolves view's preprocess OUT union to the MERGE of its members

  • 保留方向断言(pipeAuthorableSide(view) 仍是 union)——那是 活性账本覆盖 worklist:9 个已注册 metadata type 仍未治理(#4487 建立闸门后的剩余债务) #4488 的复发守卫,与 union 分支无关;
  • 新增断言改钉合并后的真相,且不钉键数(今天 89 个,任何 view 特性都会动它),而是各挑一个只有某个成员才声明的键:isPinned(只有第 0 个成员有,而它本身是嵌套 union)、list(容器成员)、pagination(扁平 list 成员)、sections(扁平 form 成员)。合并一旦塌回单一成员,这条就红。

另新增 7 条钉子:union 合并 / 同名取首个实例(钉实例身份)/ 嵌套 union / 无 object 成员的 union 仍返回 null(放宽不得把「没有键可贡献」变成空 shape)/ discriminated union 走同一条分支(并钉住 Zod 4.4.3 下 z.discriminatedUniondef.type 就是 union,所以这里故意不抄同族 walker 那条永远匹配不上的 discriminated_union 分支)/ prefault 剥离 / prefault 后面的 preprocess transform。

反向验证(先写预测再跑):预测「把两格删掉后,7 条新钉子红、其余 10 条绿,其中『无 object 成员的 union 返回 null』因为旧代码 union 恒返回 null 而保持绿」。实测 Tests 7 failed | 10 passed (17),红的正是预测的那 7 条。

Changeset:不加,改用 skip-changeset

packages/specfiles 白名单是 dist / json-schema / liveness / prompts / llms.txt / README.md / src/**/*.zod.ts / CHANGELOG.md / api-surface / spec-changes.json——scripts/ 不在发布物里;而会发布的生成物(json-schemaapi-surface、authorable-surface)本轮逐字节未变。所以这个 PR 不发布任何东西。同一文件面的父 PR #6102#5317)也未带 changeset,先例一致。

门禁

pnpm --filter @objectstack/spec build(含 DTS)        ✓
pnpm --filter @objectstack/spec check:generated       ✓ All 10 generated artifacts are up to date.
pnpm --filter @objectstack/spec typecheck             ✓ tsc --noEmit + check:test-typecheck OK
pnpm --filter @objectstack/spec test                  ✓ 330 files / 8426 tests passed
npx vitest run scripts/zod-graph.test.ts              ✓ 17 passed(含 7 条新/转换钉子)
pnpm --filter @objectstack/spec check:liveness        ✓
pnpm lint                                             ✓
pnpm check:nul-bytes                                  ✓ 5920 files, no raw control bytes
pnpm check:shard-attestation / check:empty-changeset  ✓(合 main 后新落地的两个门禁)

Generated by Claude Code

claude added 2 commits August 7, 2026 11:04
`zodShapeOf` was two spellings narrower than its sibling walkers after #5317
fixed the pipe direction: it had no `union` arm (so a union node derived no
shape at all) and its `SHAPE_WRAPPER_TYPES` omitted `prefault`. Both cells are
here, measured separately.

union: a metadata type may register a UNION of shapes rather than a single
object (#3095). `view` is the shipped specimen — the registry's one
`z.preprocess` root, whose OUT is a 4-member union — so after #5317 it had the
right side and still derived nothing. A union now resolves to the MERGE of its
members' keys, recursively (member 0 is itself a union), first member wins on a
name two members declare. That merging rule follows `keysOf`/`keyPosture` rather
than check-liveness's first-object-member rule, because this walker feeds
reachability, where a missed key can only waive a tombstone.

prefault: measured as inert today — the 25 metadata-type roots reach zero
`prefault` nodes and `.prefault(` has no call site in `packages/`. It is parity
against five sibling walkers that all peel it, so the first author to write one
does not silently lose this walker.

Measured with the #5056 protocol (every def's reachableVia dumped before and
after):

  - bridge table 1518 -> 1519 pairs. The one new entry (`children`, from
    ui/NavigationItem) comes from a `z.lazy` getter that mints fresh instances
    per call, so it matches nothing — 0 defs hit it. Widening this walker cannot
    widen the bridge in general: every object a resolved shape comes from is
    already in the BFS closure and has already contributed the same pairs.
  - 18 verdicts move, all on the QUERY side, all out of the `!shape` fail-closed
    default: 17 root-graph -> null and 1 root-graph -> derived-clone
    (ui/ViewItem, a genuine shared-instance bridge with the view root's own
    union members). Every one of the 17 has holders that already answer null, or
    no holder at all. Totals: root-graph 520 -> 502, null 1084 -> 1101,
    derived-clone 6 -> 7.
  - prefault alone: 0 verdict moves, 0 bridge changes.
  - merge order is immaterial: a last-member-wins build gives identical verdicts
    for all 1610 defs.
  - generated artifacts do not move; check:generated reports all 10 up to date.

The `view` pin in zod-graph.test.ts is CONVERTED, not deleted: it documented
that a corrected direction still derived no shape, and now pins the merged shape
that direction leads to.

Fixes #6098
@vercel

vercel Bot commented Aug 7, 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 7, 2026 11:09am

Request Review

@os-zhuang os-zhuang added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 7, 2026 — with Claude
@github-actions github-actions Bot added the size/m label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 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

PM 验收:ACCEPT — 已 ready + auto-merge。

验收依据取自 GitHub 侧读数,不采信报告自述:

本单真正需要盯的一格,也是我重点复核的一格:这轮唯一的放宽方向不是派发令预期的 root-graph → derived-clone(只有 1 条,且对门禁同为「可达」,严格度不变),而是 17 条 root-graph → null —— null豁免 tombstone。接受它的三条理由:

  1. 这 17 条此前答 root-graph 的原因与可达性无关,只是「union 解不出 shape」这一个 walker 缺口触发的 fail-closed 兜底;换掉的是兜底,不是测量
  2. BFS visited 改动前后恒为 4900 个节点 —— 真实的身份可达性一格没动。
  3. 每一条都给了 holder 证据:holder 家族本来就全体答 null,或根本没有 holder(顶层 export)。这与 build-schemas.tszodShapeOfz.preprocess 走错管道方向(#4488 已在 check-liveness 修过的同一个盲点) #5317ui/InlineAction 用过、并已随 PR fix(spec): zodShapeOf 按管道方向解 z.preprocess 的授权面(#4488 盲点的第四个现场) #6102 合入的是同一条论证。

三项额外加分,记在这里因为它们是我希望在本车道成为常规的做法:

  • 反向验证先写预测再跑:预测「7 红 / 10 绿,且『无 object 成员的 union 返回 null』保持绿」,实测 7 failed | 10 passed,红的正是预测的那 7 条。
  • 两格分开测量prefault 单独 0 移动 / 再加 union 18 移动),而不是合并成一个结论。
  • 主动更正派发令的两处措辞refactor(spec): 三个热点生成物按 category / entrypoint 分片,拆掉合并队列的串行税 (#5837) #6069 之后 writeShards 对字节未变的分片沉默跳过、并不逐分片打印 IDENTICAL/CHANGED;以及派发令预设的假可达面方向不对。派发令写错了就该被纠正,这两处我采纳。

parity 未竟的两格(array→element / record→valueType 不下降、depth cap 12 vs 16/25)今天均 fail-closed,已作为观察项另开 #6232;非记忆化 z.lazy 的实例身份问题已另开 #6221。两者都不在本单修,判断正确。


Generated by Claude Code

Merged via the queue into main with commit 70f132c Aug 7, 2026
30 of 31 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-6098-zod-graph-unwrap-parity branch August 7, 2026 11:45
os-zhuang added a commit that referenced this pull request Aug 7, 2026
生成物本次无冲突且未被合并改动(#6222 触及 lib/zod-graph.ts 但未重生成任何页面)。
仍按 #4675 分两步:本提交只合并,下一提交整体重跑 gen:docs 验证无进一步漂移。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

zodShapeOf 的 unwrap 面比三个同族 walker 窄:缺 union 分支与 prefault(#5317 修完管道方向后剩下的两格)

2 participants