Skip to content

formatZodError 把 union 分支的拒绝信息压成 "Invalid input" —— #4001 策展的散文在 CLI 路径上到不了作者 #4971

Description

@xuyushun441-sys

发现于 #4001 批 10(automation/state-machine.zod.ts 收紧),未在该 PR 中修复:改共享格式化器会改变全仓每一个 union 的 CLI 输出,不该搭在一个 spec 收紧 PR 上。

现象

formatZodError(packages/spec/src/shared/error-map.zod.ts,从包根导出)只 map 顶层 error.issues,从不下降进 invalid_union 的 issue.errors[]。zod 对 union 只抛一个 invalid_union issue,其 message 是字面量 "Invalid input",各分支的真实 issue 嵌在下一层。

实测,同一个文件里的对照组(批 10 的测试里就钉着这一对):

=== formatZodError over a PLAIN strictObject rejection (control) ===
Transition (1 issue):

  ✗ (root): Unrecognized key(s) on this state transition: `guard`. … Did you mean `guard` → `cond`?

=== formatZodError over a UNION branch rejection ===
Action ref (1 issue):

  ✗ (root): Invalid input

第二例的处方并没有丢,它就在 payload 里:

issues[0].errors[1][0].message
  = "Unrecognized key(s) on this action reference: `args`. Until #4001 …"

REST 层(packages/rest/src/rest-server.ts:610)把 error.issues 整体透传,所以 API 消费者拿得到;ZodError.message(JSON 序列化)也带着。只有压成单行的消费者会丢,而 formatZodError 的文档用途正是 CLI 输出(os validate / os compile)。

为什么值得单开

这是本战役第三类教训(「仪器谎报覆盖率」)的一个变体,而且方向更糟:不是闸门漏报,是拒绝信息本身在到达作者前被裁掉。战役自己的规矩是「拒绝信息里的散文是行为,不是文档」—— 对 union 后面的每一个 schema,这条在 CLI 路径上今天不成立。

受影响面不止 ActionRef / GuardRef:任何 union 成员是 strictObject 的地方都一样,ui/view.zod.ts 的 union 形状是最大的一处。随着 #4001 继续收紧,union 分支只会变多。

处方(仅清点,未实施)

formatZodIssue 对 code === 'invalid_union' 递归展开 issue.errors,每个分支缩进一层;或只展开最有信息量的那个分支(unrecognized_keys 优于 invalid_type,避免把 "expected string" 的噪音也印出来)。

需要一并考虑的:

  1. formatZodError 是公开导出(packages/spec/src/index.ts:128),输出变化对下游是可见行为变化 —— 大概率要 changeset。
  2. 展开策略要有测试证明它不把 union 的 N 个分支全印一遍(view 曾经因为 union 遍历「一个未知键报两次」,见 未知键静默剥离仍是全仓默认:把 #3405 的 strict 收紧从一个 schema 推广到整个可授权面(ADR-0078 完整性闸门) #4001 批 6c 的记录)。
  3. 先证红:本 issue 的对照实验(plain vs union,同一格式化器)可以直接搬进测试。

批 10 已就地钉住现状(packages/spec/src/automation/state-machine.test.ts 的 CONTROL — a non-union shape renders its full prescription through formatZodError),所以修好的那天这条钉会红,提醒把断言翻过来。

Activity

  1. self-assigned this
    on Aug 4, 2026
  2. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    Contributor

    认领:PM 循环第 1 轮(v17 ci-tooling 工作集 / C 包 #5163)
    会话:session_01FTszibd6C8sUCCZnM4VcrL
    分支:claude/issue-4971-union-error-prose
    Worktree:objectstack-issue-4971
    域:domain:spec-tooling
    文件面:packages/spec/src/shared/error-map.zod.ts(formatZodError 展开 invalid_union)、packages/spec/src/automation/state-machine.test.ts(CONTROL pin 翻转)、断言错误散文的相关 pin 测试、.changeset/*

    ⚠️ 触 packages/spec/src/** zod 文件 —— 已按 #5163 卡片规则在卡上留言与 spec 车道协调(与其在飞面无重叠)。
    ⛔ 本单不碰 shared/suggestions.zod.ts(#4990 因同 touch state-machine.test.ts 与本单串行,下轮派发)、不碰 packages/rest/**(同族 #5014 另单处理)。


    Generated by Claude Code

  3. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    Contributor

    PM 复核(第 1 轮):ACCEPT → PR #5342

    会话 session_01FTszibd6C8sUCCZnM4VcrL。

    ⚠️ 首先记一条本单正文的前提错误(dev 证伪,PM 独立复核确认)

    本 issue 正文写的「formatZodError 的文档用途正是 CLI 输出(os validate / os compile)」是错的。 dev 在实施中发现并更正,我按「不取报告自述」的规矩独立核验 origin/main:

    packages/cli/src/utils/format.ts:167:  export function formatZodErrors(error: ZodError)   ← CLI 自己那份
    packages/cli/src/commands/validate.ts:101:  formatZodErrors(result.error as unknown as ZodError);
    packages/cli/src/commands/compile.ts:162:   formatZodErrors(result.error as unknown as ZodError);
    

    两个命令调的是 CLI 本地的 formatZodErrors(复数),不经过 spec 的 formatZodError。JSDoc 那句话是历史遗留,不是事实。已立 #5341 承接 CLI 那一半,并入队(domain:cli,target:v17)。本条更正留在此处,免得下一个读本 issue 正文的人继承这个错误前提。

    这不影响本单结论,dev 也给出了修复非空转的证据:defineStack 的严格模式抛错走的正是 formatZodError(stack.zod.ts:1250),任何加载 stack 配置的命令都会执行它。

    升级体验这条线的真实形状因此需要更新为三个消费者、三份独立代码:

    消费者 文件 状态
    formatZodError(spec 公开导出,defineStack) packages/spec/src/shared/error-map.zod.ts 本单已修
    zodIssuesToFields(REST wire) packages/rest/src/rest-server.ts #5014 待修
    formatZodErrors(CLI 终端,作者实际撞上的那条) packages/cli/src/utils/format.ts #5341 待修

    通过项

    • 先证后改:改前实测 ActionRefSchema 的 invalid_union payload 结构与 ✗ (root): Invalid input 输出,与正文所述一致。
    • 分支是「挑」的不是「倒」的 —— 本单唯一必须避免的失败模式(未知键静默剥离仍是全仓默认:把 #3405 的 strict 收紧从一个 schema 推广到整个可授权面(ADR-0078 完整性闸门) #4001 批 6c:一个未知键被 N 个分支各报一次,submitBehavior 因此改用 discriminatedUnion)有专门的钉 reports one unknown key ONCE, not once per branch 守着。选择规则(丢弃只报「值的种类不对」的分支 → issue 最少者胜 → unrecognized_keys 破平局 → 声明顺序兜底)有理有据:作者真正在写的那个成员只抱怨那一个多余的键,这是机制本身而非事后去重补丁。
    • 保守的那一半同样被钉住:全部分支都是种类不匹配时(z.union([z.string(), z.number()]) 收到对象)完全不展开,输出与改前逐字节相同并有 toBe(...) 精确断言。原始类型 union 是全仓最常见的 union,这个取舍正确。
    • 表头计数仍取 error.issues.length —— CLI 与 REST 错误体、ZodError.message 对「错了几处」保持同一口径,这一点单独有钉。
    • 测试证据诚实:9 条新钉里 6 条改前必红,另外 3 条两边都绿并主动说明是设计如此(钉的是「不许变」的不变量),没有凑成「全红转全绿」。批 10 预言的 CONTROL 钉按其自述翻转,且保留了反空洞性质。
    • 消费半径普查到位:本仓唯一非测试调用点 stack.zod.ts:1250,objectui / cloud 两仓零引用。
    • state-machine.zod.ts 的仅注释越界改动批准 —— 该 JSDoc 原文说「formatZodError 是丢弃者之一,filed 而非在此修」,本 PR 之后这句是假的,留着会误导下一个读者。与 AREA_REQUIRED_PERMISSIONS_RETIRED 的处方在 #4722 之后过时:仍写着「the server does not walk areas」 #4749 同一判断。
    • changeset 定 minor 合理。PM 补充核验:仓库当前在 changeset pre 模式(.changeset/pre.json mode=pre / tag=rc,spec 已 17.0.0-rc.2),major 已累积并支配 minor,GA 版本号不受影响,只递增 rc 序号。
    • 范围合规:未手改 strictness 台账(dev 正确识别该文件的 state-machine.zod.ts 行现已过时,但那是 spec 车道独占面,留给该车道 —— 判断正确);⛔ content/docs/releases/ 零改动;未碰 packages/rest/**。

    已知限界(dev 如实记录,不作返工项)

    深度上限 3 层时,最内层的处方不会到达作者(stops expanting after three levels 那条钉直接断言 not.toContain('bogus'))。当前仓内实际最深两层(StateMachine → on.GO → actions[0]),留有余量;把它写成断言而不是藏起来是对的。

    后续


    Generated by Claude Code

  4. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    Contributor

    复核补记(我的 ACCEPT 之后 PR 又推了三个提交,如实记录差异):

    我发出 ACCEPT 时,PR 的 changeset 里仍然写着那句被证伪的前提——「formatZodError is documented for, and used on, the CLI path (os validate / os compile)」。我复核了正文与代码,却没抓到这句话还留在 changeset 里。dev 随后自己发现并推了 0810656(连同 1a7f283 / 25a14a8 两处注释),改成真实到达面:

    …What dropped it is every consumer that flattens to one line, and formatZodError is one: it is this package's documented CLI formatter and what defineStack throws through, so an author whose stack config has a union mistake reads it on every command that loads the stack.

    这条要紧在哪:changeset 是发布说明的输入,那句话会随 v17 发出去。一个已被本单实测推翻的前提,差点以「已发布事实」的身份进入客户读的文档 —— 而它错的方向恰恰会让读者以为 os validate 的 union 报错已经修好了(真正修那条路径的是 #5341,尚未动工)。

    判决不变:ACCEPT,且比我复核时更强。三个新提交均为文案/注释,零逻辑改动。


    Generated by Claude Code

  5. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    Contributor

    已落地 —— PR #5342 经合并队列合入 main(546ab3c49)

    会话 session_01FTszibd6C8sUCCZnM4VcrL。落地后复核判据本身(队列合并同样走 os-regen 驱动):

    $ git grep -c "selectUnionBranches" origin/main -- packages/spec/src/shared/error-map.zod.ts
    3
    $ git grep -c "invalid_union" origin/main -- packages/spec/src/shared/error-map.zod.ts
    7
    $ git grep -c "this action reference" origin/main -- packages/spec/src/automation/state-machine.test.ts
    2      # 批 10 预言的 CONTROL 钉已按其自述翻转
    

    分支选择实现体与翻转后的 pin 都在,无一侧被吞。C 包升级体验 4 单的第一单落地。

    升级体验这条线的真实形状(本单证伪原前提后修正,记在这里供后续两单参考):三个消费者、三份各自独立的代码 ——

    消费者 文件 状态
    formatZodError(spec 公开导出,defineStack 抛错走它) packages/spec/src/shared/error-map.zod.ts ✅ 本单已落地
    zodIssuesToFields(REST wire) packages/rest/src/rest-server.ts #5014 待派
    formatZodErrors(CLI 终端,os validate 实际走的那条) packages/cli/src/utils/format.ts #5341 待派

    后两单派发时应照抄本单已落地的分支选择策略(丢弃只报「值的种类不对」的分支 → issue 最少者胜 → unrecognized_keys 破平局 → 跨分支相同判决去重 → 绝对路径 → 深度上限),让三条路径输出口径一致,而不是各自重新发明。

    #4990 随本单落地解锁(两单同 touch state-machine.test.ts,现已安全)。


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions