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 那个盲点的第四个独立现场:

#现场修于
1scripts/liveness/check-liveness.mts#4488(TranslationItemSchema 的退役方言 preprocess #3778translation "walked to no shape, ungovernable")
2src/kernel/metadata-authoring-lint.ts#5074
3src/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.ininstanceofz.ZodType)returnzodShapeOf(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

vercelBot commented Aug 7, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 7, 2026 1:52am

Request Review

@os-zhuangos-zhuang added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 7, 2026 — with Claude
@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

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

@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

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 queueAug 7, 2026
@claude

claudeBot 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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gateteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-zhuang@claude