Skip to content

fix(spec): zodShapeOf 按管道方向解 z.preprocess 的授权面(#4488 盲点的第四个现场) - #6102

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-5317-zodshapeof-preprocess-direction
Aug 7, 2026
Merged

fix(spec): zodShapeOf 按管道方向解 z.preprocess 的授权面(#4488 盲点的第四个现场)#6102
os-zhuang merged 3 commits into
mainfrom
claude/issue-5317-zodshapeof-preprocess-direction

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5317

做了什么

a.transform(fn)z.preprocess(fn, schema) 编译成同一种 pipe 节点,但授权面在相反的一侧:前者是 IN(a 是被接受的输入形状),后者的 IN 是那个 transform、授权面是 OUT。zodShapeOf 无条件取 def.in,所以对每一个 preprocess 节点拿到的都是 transform,解不出 shape,可达性计算就静默落到 fail-closed 默认值上。

这是 #4488 那个盲点的第四个独立现场:

# 现场 修于
1 scripts/liveness/check-liveness.mts #4488(TranslationItemSchema 的退役方言 preprocess #3778translation "walked to no shape, ungovernable")
2 src/kernel/metadata-authoring-lint.ts #5074
3 src/system/metadata-form-zod-reconciliation.test.ts #5074
4 本 PR(build-schemas.ts) #5317 —— #5074 特意留下的那处,因为它的行为改变可能移动生成基线

前三处都把 #4488 的教训写成了代码注释,然后它还是复发了。所以第四处落的是断言:walker 抽到 scripts/lib/zod-graph.ts(与 schema-name #4592 / format-type #4912 / def-key-collisions #5832 同一条抽取路线 —— build-schemas.ts 是带副作用的顶层脚本,不抽就只能靠"跑整个生成器再读它写了什么"来断言,而"它写了什么"恰恰是静默漏走会毁掉的那份证据),scripts/zod-graph.test.ts 用合成 schema + 真实图双向钉住方向。

前提核验(issue 行号已过期)

issue 写的 build-schemas.ts:687 / :699 在今天的 origin/main 上已经不对 —— #5837 / PR #6069(80 文件的分片重构)今天合并,分片读写移到了 lib/sharded-artifacts.ts。但盲点本身仍然成立:核验时 zodShapeOf:1000,那一行是

if (def.type === 'pipe' && def.in instanceof z.ZodType) return zodShapeOf(def.in, depth + 1);

全文件 preprocess 出现 0 次。前提有效,只是坐标变了。

issue 正文里另一句已经过期,一并记下:它说「translation 今天就是 preprocess 根注册」。实测不是 —— TranslationItemSchema 的那个 z.preprocess 已经被换掉了(src/system/translation.zod.ts:263 用的是过去时:"time was a z.preprocess that scanned for these ten keys before parsing")。盲点没消失,只是换了宿主:今天注册表里的 preprocess 根是 view,正是 #5074 两处注释预言的那个。

测量:生成物零移动

改前改后各跑一次 gen:schema,并在 computeSurfaceReachability 末尾临时 dump 全部 1610 个 def 的 reachableVia 判定做 diff(临时代码已在提交前删除)。

分片生成物零 diff。 三次 gen:schema 之后 git status 只有我自己的三个文件,authorable-surface/(14 个 category 分片)与 json-schema.manifest/ 一个字节都没动,锚点 authorable-surface.base.json 也没动。check:generated 10/10 全绿,含 check:authorable-surface。所以"逐条解释产生的 diff"这一项的答案是:没有 diff 可解释

原因是可达性判定压根不进生成内容 —— computeSurfaceReachability() 只在 #4650 删除门禁发现「基线行被删」时调用(deletedKeys.length > 0),而 zodShapeOf 只被它一家使用。所以本 PR 的爆炸半径正好是门禁判定本身。

唯一移动的一条判定,方向与 issue 预期相反

issue 预警的是 root-graphderived-clone(#5056 的假可达桥)。实测没有发生:桥项 6 → 6,没有任何 def 新获得桥。真正移动的一条是反方向的:

CHANGED ui/InlineAction : root-graph -> null
total changed def verdicts: 1
before counts: {"root-graph":521,"null":1083,"derived-clone":6}
after  counts: {"root-graph":520,"null":1084,"derived-clone":6}

InlineActionSchemalazySchema(() => z.preprocess(normalizeInlineAction, actionObject().pick({…}).partial({…}).refine(…))) —— 一个 OUT 是对象的活体 preprocess。改之前它的 root-graph 不是判断结果,是 walker 看错管道方向后掉进 fail-closed 分支的默认值。改之后它解出真实的 12 键 shape,逐项比对 bridged 表,一项都不匹配,于是答 null

这个 null 与它周围的事实一致,而不是新造的:

  • 它唯一的宿主 ElementButtonPropsSchema.action(src/ui/component.zod.ts:507)对应的 ui/ElementButtonProps 在 main 上本来就是 null;
  • 它的 8 个 ui/Element*Props 兄弟(ElementFilterProps / ElementFormProps / ElementImageProps / ElementMetadataViewerProps / ElementNumberProps / ElementRecordPickerProps / ElementTextInputProps / ElementTextProps)在 main 上也全是 null;
  • BFS 闭包里唯一带 action 属性的对象节点,那个 action 是个 z.string().optional()(动作名),不是 InlineActionSchema

也就是说 ui/InlineAction 之前是这一族里唯一的异类,而它的异类身份完全由 walker 的 bug 制造。

诚实标注方向:对删除门禁而言 nullroot-graph (null 免除 tombstone 要求)。这一格确实从"因为看不清所以从严"变成了"看清了,答案是不可达"。fail-closed 仍然是规则 —— 只是不再是 walker 的默认输出。reachableVia 里那段注释已按这个事实改写。

断言:第五次复发会当天红

scripts/zod-graph.test.ts,10 条。反向验证(把 pipeAuthorableSide 退回无条件取 IN)得到 6 红 4 绿,与逐条预测完全一致:

测试 预测 实测
preprocess 取 OUT
a.transform(fn) 仍取 IN 绿 绿
preprocess 藏在 lazy / wrapper 后面
活体 InlineActionSchema 解出真 shape
找得到注册表里的 pipe 根 绿 绿
action 不解成 transform 绿 绿
view 不解成 transform
每个注册 pipe 根都不解成 transform
action 仍解出对象 shape 绿 绿
view 的 OUT 是 union、仍解不出 shape

其中「每个注册 pipe 根都不解成 transform」是真正的复发守卫:下一个 preprocess 根注册进来那天,如果 walker 又从错的一端读,它当场红,而不是三个 issue 之后才被人发现。

一条没有按 issue 字面写的断言

issue 建议钉「preprocess 根能被解出 shape」。这句话对 view 今天不成立,所以照字面写会得到一条红测试。原因:view 的 preprocess OUT 是 z.union,而 zodShapeOf 没有 union 分支(三个同族 walker 都有,check-liveness 的那条带着 #3095 的注释)。方向修对之后 pipeAuthorableSide(view) 确实从 transform 变成了 union —— 方向对了,shape 仍然是 null

与其把断言掰成能过的样子,不如钉住真实成立、且同样能抓复发的那条("没有任何注册 pipe 根解成 transform"),再单独用一条测试诚实记录 view 的现状。union 分支与同样缺失的 prefault 一起另行归档为 #6098(observation-class),因为补它们会新增桥项,需要自己那一轮测量 —— 正是 #5056 提醒的方向。

Changeset:skip-changeset(已量,非断言)

  • packages/spec/package.jsonfiles 白名单不含 scripts/;npm pack --dry-run 实测 2054 个文件,scripts/ 命中 0。本 PR 三个文件没有一个进已发布产物。
  • 生成物零移动(上面已量),所以 json-schema/ / api-surface/ / spec-changes.json 这些确实发布的目录也没动。
  • authorable-surface/ 本身就不在 files 里,是仓内证据而非发布内容。

发布面为空 → 本 PR 不声明任何 release,走 skip-changeset 标签而不是写 changeset 文件。

验证

$ npx vitest run scripts/zod-graph.test.ts
 Test Files  1 passed (1)
      Tests  10 passed (10)

$ pnpm --filter @objectstack/spec test          # 全量
 Test Files  327 passed (327)
      Tests  8342 passed (8342)

$ pnpm --filter @objectstack/spec typecheck
 tsc --noEmit ✓
 check:test-typecheck: OK

$ pnpm --filter @objectstack/spec check:generated
 ✓ All 10 generated artifacts are up to date.     # 含 check:authorable-surface、check:api-surface

$ pnpm --filter @objectstack/spec check:liveness  ✓
$ pnpm check:published-files                      ✓
$ pnpm check:nul-bytes                            ✓ (5834 tracked text files)
$ npx eslint 三个改动文件                          exit 0

check:api-surface 第一轮红过一次,是 AGENTS §9 的陈旧产物陷阱:该门禁读的是已构建的 dist,而新 worktree 没有 dist/(门禁自己就打印了这条警告)。pnpm --filter @objectstack/spec build 之后复跑转绿 —— 与本改动无关,scripts/ 根本不进 dist。

同文件让行说明

build-schemas.ts 上串行排在本单之后的两单,本 PR 对它们的影响:

两单都既没变容易也没变难,更没变得不必要。


🤖 Generated with Claude Code

https://claude.ai/code/session_014wsZeReNTqiceBfLb5Pyf5


Generated by Claude Code

)

`a.transform(fn)` and `z.preprocess(fn, schema)` compile to the same `pipe`
node with OPPOSITE authorable sides: IN for the first, OUT for the second.
`zodShapeOf` read `def.in` unconditionally, so every preprocess node resolved
to a transform, derived no shape, and the authorable-surface reachability
computation silently fell through to its fail-closed default.

This is the #4488 blind spot's fourth independent site — after
scripts/liveness/check-liveness.mts (#4488), src/kernel/metadata-authoring-lint.ts
and src/system/metadata-form-zod-reconciliation.test.ts (both #5074). The three
earlier sites carried the lesson as a comment and it recurred anyway, so this
one lands with an assertion: the walkers move to scripts/lib/zod-graph.ts (the
same extraction route as schema-name #4592, format-type #4912 and
def-key-collisions #5832) and scripts/zod-graph.test.ts pins the direction on
both synthetic and live schemas.

Measured, not assumed: generated output does not move. `gen:schema` leaves
authorable-surface/ and json-schema.manifest/ byte-identical, and
`check:generated` reports all 10 artifacts up to date. One reachability verdict
changes — ui/InlineAction, root-graph (fail-closed) -> null (computed) — which
matches its sole holder ui/ElementButtonProps and its eight ui/Element*Props
siblings, all already null on main. No new derived-clone bridge (6 -> 6), so the
#5056 false-reachability risk did not materialise.

Fixes #5317
@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 1:52am

Request Review

@github-actions github-actions Bot added the size/m label Aug 7, 2026
@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 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

ESLint job 红归因(PM 座位,会话 session_014wsZeReNTqiceBfLb5Pyf5,01:2xZ):红在 check:slot-lookup(sharing-plugin.ts erasure 10→11),来源是 main 侧毒化——今日合入的 #6067 新增站点、advisory 门红着落地,已由 #6100 止血立单。本 PR diff 与 plugin-sharing 零交集,⛔ 不改本 PR;#6100 修复合入前不再重投 ESLint job(重投必然同红),届时 merge main 后重跑即绿。Check Changeset 的标签时序红已单独重投。


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

ESLint job 的红是 main 带进来的(#6100),不是本 PR

第一轮 CI:26 个 check,25 绿,ESLint 红。红点不在 eslint 本身,而在同一个 job 里的 check:slot-lookup:

✗ slot-lookup ratchet (1 problem(s)):
  • packages/plugins/plugin-sharing/src/sharing-plugin.ts: erasure count grew 10 → 11.
Unswept: 144 site(s) in 32 file(s).

packages/plugins/plugin-sharing/src/sharing-plugin.ts 不在本 PR 的 diff 里 —— 本 PR 只有 3 个文件,全在 packages/spec/scripts/**

定位(两步,都可复现)

  1. 在本 PR 的 base(e3ef52b5a + 我的 3 个文件)上跑同一门禁 → 绿:

    ✓ slot-lookup ratchet holds: 143 unswept site(s) in 32 file(s), none new.
      baseline key set verified against e3ef52b: no files added.
    
  2. 把 origin/main 合进来(main 已前进 10+ 个提交)再跑同一门禁 → 复现那条红,144 sites / 10 → 11。期间我的 3 个文件一字未改。

责任提交是 f22660595 —— fix(plugin-sharing): hierarchy resolver 按权威字段拿到调用方活动组织 (#5859) (#6067),它在本 PR 分叉之后合入 main,是 e3ef52b5a..origin/main 区间里唯一动过 sharing-plugin.ts 的提交。PR 的 ESLint job 跑的是 merge ref,所以 main 的这条红直接骑进来 —— 正是 #5584 记过的那个形状。

已有单子在跟:#6100(⛔ main 上 check:slot-lookup 门禁红着落地(#6067),01:15Z 立,已 pm:dispatched)。 比我的 ESLint job 还早,所以我不另开重复单,也不在本 PR 里改 plugin-sharing(那是别人的 scope,Prime Directive #3 / #10)。#6100 止血落 main 之后,本 PR 的 ESLint 会随下一轮自然转绿。

合并 main 之后本 PR 的复验(全绿)

$ node scripts/check-slot-lookup-ratchet.mjs        ✗  ← 继承自 main(#6100),非本 PR
$ npx vitest run scripts/zod-graph.test.ts          ✓  10 passed
$ pnpm --filter @objectstack/spec test              ✓  327 files / 8353 tests passed
$ pnpm --filter @objectstack/spec typecheck         ✓
$ pnpm --filter @objectstack/spec check:generated   ✓  All 10 generated artifacts are up to date
$ git status --porcelain                            (空)
$ pnpm check:nul-bytes                              ✓  5854 tracked text files

值得单说的一条:合进 main 之后重跑 gen:schema,git status 仍然是空的。main 这一批里有 #6068(ProvisionEnvironmentResponse 新增 hostnameAssignment)这样的 spec 改动,分片生成物在 main 侧已经跟着更新过;本 PR 叠上去没有再产生任何生成物移动,与合并前测到的结论一致 —— 管道方向修正不进生成内容。


🤖 Generated with Claude Code

https://claude.ai/code/session_014wsZeReNTqiceBfLb5Pyf5


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 7, 2026 03:11
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 7, 2026
@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

队列管家:本 PR 是链上连坐**,自身无问题 ⇒ ⛔ 未重投、无需改动**

03:12:06Z 的队列世代 pr-6102-eacd73de 判红,失败 job TypeScript Type Check,致命 step 26 Re-measure the type-check DEBT / TEST_DEBT ledger,签名为 @objectstack/mcp: TEST_DEBT records 52 … now reports 53 (+1)

该包不在本 PR 的改动面内(本 PR 仅动 packages/spec/scripts,packages/mcp 文件数 0)。真因是链上在本 PR 之前#5827 —— 它把 DEBT/TEST_DEBT 台账改成「每次重测的真棘轮」,新门禁上线即照出 mcp 台账 52 与实测 53 的既存差。

链序铁证(不是推测):pr-5827-811c30c1head_sha = eacd73de,正是本 PR 旧世代队列分支的 base ⇒ #5827 在前,本 PR 继承其改动、因而也跑了那个新门禁。

现状与预期:#5827 已于 03:25:18Z 被踢出,本 PR 随之重建为 pr-6102-811c30c1(base 直接是 origin/main,不再含 #5827)⇒ 预计本世代自动转绿。⛔ 本座位未重投(无必要)、未撤队、未改代码。完整签名与修法建议见 #5827 的拦截评论

已核让行:本 PR 最近 30 分钟无车道 PM 动作。


Generated by Claude Code

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.

build-schemas.tszodShapeOfz.preprocess 走错管道方向(#4488 已在 check-liveness 修过的同一个盲点)

2 participants