Skip to content

feat(ci): ADR-0087 台账完备性门禁 —— 声明为 breaking 的 changeset 必须写明台账处置 (#6148) - #6342

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-6148-adr-0087-completeness-gate
Aug 7, 2026
Merged

feat(ci): ADR-0087 台账完备性门禁 —— 声明为 breaking 的 changeset 必须写明台账处置 (#6148)#6342
os-zhuang merged 1 commit into
mainfrom
claude/issue-6148-adr-0087-completeness-gate

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #6148

前提复核(在当前 origin/main 上)

前提仍然成立,逐条实测:

实现的规则

新增 scripts/check-adr-0087-registration.mjs,与 check-empty-changeset.mjs 同族:判 PR 的 diff(始终从 merge-base(base, head) 起算,#6129),不判库存。

本 PR 新增(或新变为 breaking)且声明了 breaking 的 changeset,必须携带且仅携带一条处置标记:

<!-- adr-0087: registered SOME-MIGRATION-ID -->
<!-- adr-0087: not-required (unpublished) why -->
<!-- adr-0087: not-required (already-registered SOME-MIGRATION-ID) why -->
<!-- adr-0087: not-required (no-migration-prescription) why -->

「声明了 breaking」取三种拼法的并集(frontmatter major / 正文 **BREAKING/ summary 行的 conventional-commit !)。库存 1304 条中命中 227 条。取并集而非其一:#6048 用的是 (1)+(2) 而没有 (3),而 check-changeset-no-major.mjs 在 pre 模式外会把 breaking 压成 minor,那时只剩 (2)/(3) 承载信号。

不做跨包退役自动检测 —— 那正是维护者裁定要绕开的部分。作者已经声明过「这是 breaking」,门禁只需注意到它对台账只字未提。

逃生舱如何保持诚实

四种处置里三种可机械复验,每次运行重新验证:

处置 每次运行复验什么
registered SOME-ID 每个 id 必须在台账中存在,且至少一个是本次 diff 新增的 —— 不能声称一次自己没做的登记
not-required (unpublished) changeset bump 的每个包都必须 private: true;命中已发布包直接红
not-required (already-registered SOME-ID) 命名的 id 必须存在,且在 merge base 就已存在。若是本次新增,诚实的写法是 registered
not-required (no-migration-prescription) 当 changeset 自己的正文带 FROM → TO 迁移说明时拒绝

最后一条是够到 #6048 的那一条:它的 changeset 带着 ### 迁移:FROM → TO 和完整的前后对照代码块 —— 一份发布了改写指令的 changeset,不能同时声称无人需要改写。这是陈述与陈述的自相矛盾检查,不是退役检测器。该模式大小写敏感、只匹配本仓的大写房规拼法(早期大小写不敏感的草稿会误命中普通散文 "from the old value to the new",在 32 条上命中 14 而非 11)。

此外所有 not-required 的理由都会在每次运行时被逐字打印并发出 ::notice 注解,--list 提供常设审计面 —— 一条没人再读的豁免正是本门禁要避免的失效模式。

⚠️ 一个改变了设计形状的实测(请维护者过目)

派单假设逃生舱是例外(「构建工具或 dev-only 包显然不需要」)。实测相反 —— 它是多数路径:

  • main 最近 400 个 first-parent commit:32 条新增的 breaking changeset,仅 5 条(15.6%)动过台账
  • 独立佐证:v17 列车 213 条 breaking changeset 对 step-17 的 29 条 semantic 条目 —— 约 1/7

所以门禁的价值不是判定谁该登记(它做不到,也不该做),而是让这个问题被书面回答#6048 没有回答,也没有任何东西问过它。绝对成本很小:约每 400 个 PR 有 32 次处置,其中约 27 次是一行 not-required

反向验证(预测先写,后运行)

预测写在 predictions.md 后才执行。18 条 self-test 方向预测全部命中;下面是要求的那一对,以及一次真实的预测落空

头条对照 —— 在真实提交上

输入 结果
有对账步骤 --base dca5bd36a~1 --head dca5bd36a(#6048 原样) ,指名 .changeset/tidy-donkeys-yawn.md:declares a breaking change (major, BREAKING) but no adr-0087: disposition marker
去掉对账步骤 同一输入,把「缺 marker」改为不判 绿(exit 0)

三处 ablation 各自让 self-test 对应红路径转绿,证明 self-test 不是空转:

ablation 真实 #6048 回放 self-test
去掉「必须有 marker」 绿 FAIL — R1、R9 转绿
去掉 FROM→TO 矛盾检查 红(它同时也缺 marker,两检查独立) FAIL — R2 转绿
去掉「registered 必须是本次新增」 红(同上) FAIL — R4 转绿

❗ 落空的预测(如实报告)

我第一次做 B2a ablation 时预测 #6048 回放转绿,实际仍是红。原因不是门禁的性质,而是我的 ablation 本身不干净:把 if (!d.ok) 改成 if (false && !d.ok) 后,控制流落穿进了 not-required 分支,d.categoryundefined,于是报出 unknown not-required category "undefined" —— 依然红,但理由是胡话,既没指出真正的问题也没给出修法。

两个收获,都留在了代码里:

  1. 换成语义正确的 ablation(缺 marker 即不判)后,预测的绿如期出现。
  2. 这暴露了一处真实的脆弱性:registerednot-required 的分派此前依赖前面的 continue 而非一个明说的条件。已改为显式判 d.verdict !== 'not-required',并把这次 ablation 的经过写进了注释 —— 依赖「上面提前返回过」才成立的正确性,离出错只有一次编辑。

顺带一提,self-test 在这次落空中报出了正确的诊断(R1 断言的是报错文本而不只是颜色),这正是它该有的行为。

历史回放

267 个「新增过 changeset」的提交逐个回放:33 红 / 234 绿(12.4%)。33 条全部是「声明 breaking 但没有 marker」—— 标记当时还不存在,这是采纳前的基线,不是误报率。dca5bd36a 在红集中。红集里 *-retired.md / *-removed.md 命名的有 5 条,正是台账服务的退役类。

main 上是绿的

  • node scripts/check-adr-0087-registration.mjs --base origin/main --head origin/main绿merge-base(main, main) == main,diff 为空。
  • --list:库存 227 条声明 breaking 的 changeset 全部豁免 —— 门禁判 diff,不判库存,所以采纳无需任何回填。

#4690:缺输入永远不是通过

出任何判决之前断言全部输入非空:.changeset/ 非空、两个台账源存在且解析出非零 id、spec-changes.json 可解析。另有两道专门防止本门禁腐化成绿色空转的断言:

  • 解析器腐化 —— 生成物 spec-changes.json 里的每个 migrationId 都必须能被源码解析器看见。方向是刻意的(投影 ⊆ 源码):新增条目尚未重生是 check:spec-changes 的红,不是这道的,两者永不重复报同一事实。

    这道断言是被实测出来的:id 字符类最初写成小写,静悄悄地把 39 条里的 object-titleFormat-to-nameField 漏掉、只抽出看起来很像样的 38 条。

  • 约定改写 —— 库存中至少要有一条能被 breaking 检测器命中,否则说明拼法被整体改写,门禁会匹配不到任何东西并静默放行一切。

落地位置

放在 pr-automation.ymlCheck Changeset job 里、check-empty-changeset 旁边,而不是 ci.yml 的 lint family —— 它的判决是 PR diff 的函数,需要同一个 $MERGE_BASE。lint job 里的副本没有分叉点可判,只能退回读库存,那正是 #6129 要防的「拿 PR 开着期间 main 新增的东西去问责作者」。

性能

首版每次运行 5.25s(库存 1304 条各 spawn 一次 git show,外加无条件读 ~90 份 workspace manifest)。改为单次 git cat-file --batch 批读 + manifest 惰性求值(只有真出现 unpublished 主张才读)后 0.77s

Changeset 决策

skip-changeset 标签,不写 changeset。 改动为 root scripts/、root package.json(private: true)、.github/workflows/AGENTS.md —— 无一属于已发布包,发布物为空。⛔ 未使用空 frontmatter changeset(#6059/#5471 已禁)。

范围

⛔ 未触碰 packages/spec/src/**/*.zod.ts、strictness ledger、content/docs/releases/,以及 build-schemas.ts / build-docs.ts / lib/format-type.ts(#6309#6308 在飞)。台账本身一条未改。


Generated by Claude Code

ADR-0087 台账原有两道门禁(check:spec-changes / check:upgrade-guide)钉的都是
「台账 ↔ 生成物同步」。生成物是 registry 的纯投影,所以当条目**从一开始就不存在**
时,两者互相一致 —— 全仓全绿。PR #6048 删除 ctx.user.roles 正是这个形状,靠人眼
比对才发现(#6011),补登记又另派了一轮(PR #6138)。

按维护者裁定,门禁以 changeset 自己的 breaking/major 声明为驱动,绕开「什么算一次
需要登记的退役」这个跨包不可判定问题:作者已经声明过「这是 breaking」,门禁只需
注意到它对台账**只字未提**。⛔ 不做跨包退役自动检测。

四种处置,三种可机械复验、每次运行都重新验证:
  registered <id>          —— id 必须在台账中存在,且至少一个是本次 diff 新增的
  not-required (unpublished)        —— 所有 bump 的包必须 private: true
  not-required (already-registered) —— 命名的 id 必须在 merge base 就已存在
  not-required (no-migration-prescription)
                           —— 当 changeset 自己带 FROM → TO 迁移说明时**拒绝**:
                              一份发布了改写指令的 changeset 不能同时声称无人需要改写

实测(main 最近 400 个 first-parent commit):32 条 breaking changeset 中仅 5 条
(15.6%)动过台账,故 not-required 是常态而非例外 —— 门禁的价值不是判定谁该登记,
而是让这个问题**被书面回答**。#6048 没有回答,也没有任何东西问过它。

反向验证:在真实提交 dca5bd3(#6048)上门禁转红并指名 tidy-donkeys-yawn.md;
去掉「必须有 marker」这一步后同一输入转绿。三处 ablation 各自让 self-test 相应
红路径转绿,证明 self-test 不是空转。

#4690:所有输入都断言非空,另有两道防空转断言 —— 生成物中的 migrationId 必须
都能被源码解析器看见(解析器腐化),以及库存中至少有一条能被 breaking 检测器
命中(约定改写)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wsZeReNTqiceBfLb5Pyf5
@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 2:18pm

Request Review

@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation ci/cd dependencies Pull requests that update a dependency file labels 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
@os-zhuang
os-zhuang marked this pull request as ready for review August 7, 2026 15:06
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

PM 验收:ACCEPT — 已 ready + auto-merge。你提的 open question 我在下面裁掉:维持 A(按现状)。

CI 复核:26 个 check 全部 completed、零 failure。ESLint success、TypeScript Type Check success(14:33:18)、Test Core 1..3/3 + 聚合 success、Check Changeset success(14:30:38)、Validate Package Dependencies success。名单在场守卫已过。

裁决:维持 A,理由是 B 与维护者裁决相矛盾

你的测量把派发令的一个预设推翻了 —— 我写的是「不是每个 breaking changeset 都需要台账条目,比如构建工具的破坏性变更显然不需要」,当成例外来写。实测它是多数路径:近 400 个 first-parent commit 里,32 个 declared-breaking changeset 只有 5 个(15.6%)碰了 ADR-0087 registry;从存量看 v17 车次 213 个 breaking changeset 对 29 条 step-17 semantic 条目 —— 两条独立口径都是约 1/7。这个更正是对的,我采纳。

但它不改变结论,因为 B 有一个致命问题:它会把「声明检查」变回「推断」。维护者裁决的全部要点,就是绕开「什么算需登记的退役」这个无法自动判定的问题 —— 靠作者已经写下的 breaking 声明。B 用「changeset 里是否带 FROM→TO 处方」来收窄触发,那正是一个启发式退役检测器,即裁决明确路由掉的东西。而且你实测它会漏掉 5 个真实登记里的 1 个 —— 一个连已知正例都盖不全的启发式,不值得用「精确度」换。

这道门的价值不是判准谁必须登记(它做不到,裁决也说了不许试),而是让沉默变得不可能。 #6048 什么都没说,而没有任何东西问过它。A 的代价是每 400 个 PR 里约 32 行书面处置,其中 6/7 是一行 not-required —— 这个代价我认,因为它买到的是「ADR-0087 这个问题在每次破坏性变更上都被书面回答过」。

三处我要特别记下的做法

① 逃生口不是空白支票,而且是语句对语句的矛盾才拒绝。 四种处置里三种每次运行机械复验(registered 的 id 必须存在在本次 diff 里是新增的;unpublished 要求所有被 bump 的包 private: true;already-registered 的 id 必须在 merge base 就存在),而兜底的 no-migration-prescriptionchangeset 自己的正文带 FROM→TO 处方时被拒绝。这一条正是关死 #6048 的那一条 —— 用作者自己的两句话互相矛盾来判,而不是用我们的推断。

② 那条落空的预测,暴露了真实的脆弱性。 你预测「把『必须有标记』那一支消融掉,#6048 的回放会转绿」,实测仍然红 —— 但红的原因是你的消融本身不成立:if (false && !d.ok) 让控制流落进 not-required 分支且 category === undefined,报出 unknown not-required category "undefined",是一个无意义的红。self-test 之所以抓住它,是因为 R1 断言的是消息而不只是颜色。重做后预测的绿才出现。而这次落空暴露了一处真实隐患:registered / not-required 的分派原本依赖前面一个 continue 而不是一个写出来的条件,现在改成显式的 d.verdict !== 'not-required'「断言消息而不只是颜色」这条,今天第二次证明自己值钱。

③ 那条反腐断言是被挣到的,不是设计出来的。 你第一版 id 提取用了只含小写的字符类,静默地只捞到 39 个里的 38 个 —— 漏掉 object-titleFormat-to-nameField,一个看起来完全合理的近似漏网。所以门禁现在断言 spec-changes.json 里每个 migrationId 都能被源解析器看见,且方向明确是「投影 ⊆ 源」 —— 这样「条目还没重生成」仍然归 check:spec-changes 报红,两道门不会对同一个事实重复报告。方向选对了。

④ 落点判断也对:接进 pr-automation.yml 的 Check Changeset job 而非 ci.yml 的 lint 族,因为判定是 diff 的函数、需要同一个 $MERGE_BASE。这是被 job 的输入决定的,不是被文件名归类决定的。

采纳后即刻见效的证据

#6350 —— 你在存量 227 条 declared-breaking changeset 里抽查了 7 条带 FROM→TO 处方却没碰台账的,3 抽 2 中:runtime-httpserver-wrapper-retired(#5122,台账里唯一的 HttpServer 字样只是另一条目正文里的顺带提及)、record-details-sections-object-form(#5611,RecordDetails/hideFields 零命中)。这满足了 #6148 分诊席自己写下的重启条件——「第二次由人手发现的漏登记」。门还没落地就已经找到两个真的,这比任何论证都有说服力。

--list 显示 227 条存量全部豁免,采纳无需回填 —— 这一点让它可以直接上线而不是先付一轮迁移。


Generated by Claude Code

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

Labels

ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] ADR-0087 台账没有「完备性」门禁:已发生的退役漏登记时全仓全绿,只有人工能发现(#6011 即如此)

2 participants