feat(ci): ADR-0087 台账完备性门禁 —— 声明为 breaking 的 changeset 必须写明台账处置 (#6148) - #6342
Conversation
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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
|
PM 验收:ACCEPT — 已 ready + auto-merge。你提的 open question 我在下面裁掉:维持 A(按现状)。 CI 复核:26 个 check 全部 completed、零 failure。ESLint 裁决:维持 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 是一行 三处我要特别记下的做法① 逃生口不是空白支票,而且是语句对语句的矛盾才拒绝。 四种处置里三种每次运行机械复验( ② 那条落空的预测,暴露了真实的脆弱性。 你预测「把『必须有标记』那一支消融掉,#6048 的回放会转绿」,实测仍然红 —— 但红的原因是你的消融本身不成立: ③ 那条反腐断言是被挣到的,不是设计出来的。 你第一版 id 提取用了只含小写的字符类,静默地只捞到 39 个里的 38 个 —— 漏掉 ④ 落点判断也对:接进 采纳后即刻见效的证据#6350 —— 你在存量 227 条 declared-breaking changeset 里抽查了 7 条带 FROM→TO 处方却没碰台账的,3 抽 2 中:
Generated by Claude Code |
Fixes #6148
前提复核(在当前
origin/main上)前提仍然成立,逐条实测:
packages/spec/src/migrations/registry.ts现有 39 条 semantic id、conversions/registry.ts88 条;spec-changes.json里 39 条migrationId全部能在源码中找到 —— 生成物是 registry 的纯投影,所以条目从未写过时两者互相一致,全仓全绿。dca5bd36a(PR feat(runtime)!: 退役 ctx.user 的 roles 别名,positions 成为唯一拼法 (#6011) #6048)确实新增了.changeset/tidy-donkeys-yawn.md(@objectstack/runtime: major+**BREAKING**)且没有动过任何台账文件;补登记发生在两个多小时后的7f6270600(PR feat(spec): ADR-0087 台账登记 ctx.user.roles 的立即退役 (#6011) #6138)。实现的规则
新增
scripts/check-adr-0087-registration.mjs,与check-empty-changeset.mjs同族:判 PR 的 diff(始终从merge-base(base, head)起算,#6129),不判库存。本 PR 新增(或新变为 breaking)且声明了 breaking 的 changeset,必须携带且仅携带一条处置标记:
「声明了 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-IDnot-required (unpublished)private: true;命中已发布包直接红not-required (already-registered SOME-ID)registerednot-required (no-migration-prescription)最后一条是够到 #6048 的那一条:它的 changeset 带着
### 迁移:FROM → TO和完整的前后对照代码块 —— 一份发布了改写指令的 changeset,不能同时声称无人需要改写。这是陈述与陈述的自相矛盾检查,不是退役检测器。该模式大小写敏感、只匹配本仓的大写房规拼法(早期大小写不敏感的草稿会误命中普通散文 "from the old value to the new",在 32 条上命中 14 而非 11)。此外所有
not-required的理由都会在每次运行时被逐字打印并发出::notice注解,--list提供常设审计面 —— 一条没人再读的豁免正是本门禁要避免的失效模式。派单假设逃生舱是例外(「构建工具或 dev-only 包显然不需要」)。实测相反 —— 它是多数路径:
所以门禁的价值不是判定谁该登记(它做不到,也不该做),而是让这个问题被书面回答。#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三处 ablation 各自让 self-test 对应红路径转绿,证明 self-test 不是空转:
❗ 落空的预测(如实报告)
我第一次做 B2a ablation 时预测 #6048 回放转绿,实际仍是红。原因不是门禁的性质,而是我的 ablation 本身不干净:把
if (!d.ok)改成if (false && !d.ok)后,控制流落穿进了not-required分支,d.category为undefined,于是报出unknown not-required category "undefined"—— 依然红,但理由是胡话,既没指出真正的问题也没给出修法。两个收获,都留在了代码里:
registered与not-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的红,不是这道的,两者永不重复报同一事实。落地位置
放在
pr-automation.yml的 Check 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。 改动为 rootscripts/、rootpackage.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