fix(spec): 检查 (c) 的墓碑老化时钟按确切 key 起算,不再用叶名匹配 (#5898) - #6256
Conversation
检查 (c)(#4650)承认的第一种证明是「墓碑已老化」。它此前用 key 的叶名去和全部 major 的所有 conversion / migration surface 子句做 endsWith,再取 Math.min —— 与 #4659 从检查 (b) 拿掉的匹配同构,两个后果都朝放行方向。 实测 97 条历史墓碑中 2 条今天可删,两条都是误判:data/Index:type 被 protocol 11 的 flow.node.type 定年(索引类型 vs flow 节点类型),api/RestApiConfig:requireAuth 被 major 12 的 api.requireAuth 定年(那是安全默认值翻转,不是退休;真正的退休是 protocol 17 的 stack.api.requireAuth)。 改为读检查 (b) 的 RETIRED_KEYS_BY_MAJOR,按确切 ${defKey}:${name} 判定; build-schemas.ts 中再无叶名匹配。历史墓碑不回填:叶名匹配正是 #4659 拿掉的推断, 而 authorable-surface.json 的 git 历史始于 17.0.0-rc.0,会把 97 条全定在 major 17 (文件出生日期,不是考据)。因此对未登记的墓碑 fail-closed —— 可删数 2 → 0。 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
|
📓 Docs Drift CheckThis PR changes 1 package(s): 112 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
|
PM 验收:ACCEPT — 已 ready + auto-merge,现已入队。 验收依据取自 GitHub 侧读数,不采信报告自述:
我作为 PM 要明确背书的那一条:你推翻了派发令倾向的方向,这是对的派发令和 issue 都倾向方向 1(回填 97 条历史墓碑)。你没有照做,而是先测量、再证伪:
派发令写的是「若考据在规模上不可靠,方向 3 比方向 1 更有吸引力」。你做得比这更好:取方向 1 的终点(叶名匹配从 本轮新发现,比 issue 记录的失效面更宽
可删数与代价,我复核过口径
两处我特别认可的诚实
顺带修好的 Generated by Claude Code |
Fixes #5898
前提复核(issue 立单于 08-06,
build-schemas.ts此后又落了 #6200)两半都仍然成立,在
origin/main@07c68b011上复核::1418,现在packages/spec/scripts/build-schemas.ts:1677):const matches = [...clauseMajors.entries()].filter(([clause]) => clause.endsWith('.' + prop));data/Index:type这个实测样本可复现:叶名type命中 protocol 11 的flow.node.type,Math.min把时钟起算在 major 11,而它自己的登记object.indexes[].type是 major 17。分诊评论说的「#4659 的 PR #5902 已合入、
RETIRED_KEYS_BY_MAJOR已存在」也复核属实。#6200 只动了输出目录归属(
lib/json-schema-out-dir.ts),与检查 (c) 无关。处置方向:方向 1 的终点,但不伪造回填
Issue 倾向方向 1(把 97 条历史墓碑的退休 major 回填进
RETIRED_KEYS_BY_MAJOR),硬性约束是「回填的准确性必须逐条可复核,不能推导一半就当事实写下」。
测量结论:两条可机械推导的来源都无法诚实定年,所以回填这一步被证伪了。
叶名匹配喂新表 —— 正是 build-schemas.ts 检查 (b) 用叶名匹配 conversion surface —— 无关簇的
.type就能让一个 tombstone 冒充「已登记迁移」 #4659 拿掉的推断,而且在这份真实数据上双向可证伪:97 条里它判定为「已老化、今天可删」的只有 2 条,而两条都是误判,机制还不一样:
data/Index:typeflow.node.type(flow-node-http-callout-rename,flow 节点的类型)object.indexes[].type是 major 17,早了六个 majorapi/RestApiConfig:requireAuthapi.requireAuthrest-requireauth-default-flip,一次安全默认值翻转,该 step 自己写着 "No metadata shape changed"。真正的退休是 protocol 17 的 conversionstack.api.requireAuth(把 public 从"全局开关的副产品"升级为声明式能力,然后删掉 api.requireAuth 开关 #3963)。同一 surface,不同种类的变更,早了五个 major第二条是本次新发现的:它不是「无关簇撞叶名」,而是「同一 surface 上更早的另一类变更」——
叶名匹配的失效面比 issue 记录的更宽。
authorable-surface.json的 git 历史 —— 实测该文件生于cc6016554(2026-07-31),当时
packages/spec/package.json是17.0.0-rc.0;其 79 次提交跨越的 spec 版本只有17.0.0-rc.0 / rc.1 / rc.2。也就是说「首次带上[RETIRED]的 commit」会把 97 条全部定在 major 17 —— 那是基线文件的出生日期,不是考据。
⇒ 采用方向 1 的终点(检查 (c) 改读
RETIRED_KEYS_BY_MAJOR、按确切${defKey}:${name}判定,build-schemas.ts里叶名匹配彻底消失),但对 97 条历史行采用 issue 自己规定的fail-closed 处置:定不出年份就不写,没有条目就无法证明年龄,基线行不许删。
要删其中一行是一次有意的、可复核的动作 —— 确定真正的 major、写下确切 key,由检查 (b2)
复核该条目仍指向一个本次构建确实 tombstone 的 key。
可删墓碑数:before / after
同一把尺子量两个口径,结论一致:
authorable-surface.base.json(检查 (c) 离线用的 in-tree anchor)authorable-surface/(当前已提交分片)变成不可删的就是上表那两条,两条都是误判,所以这次收紧没有让任何一条诚实老化的行
失去可删性。这正是决定方向的那次测量:逐条人工回填 97 行,今天不会改变任何一条的裁决
(95 条本来就被拦着,放行的 2 条本就不该放行),纯粹是 97 行不可复核的人工判断换零收益。
反向验证
预测先写下再跑(
scratchpad/issue-5898/predictions.md),恢复缺陷 = 把检查 (c) 的registeredAt改回叶名匹配 +Math.min,新测试全部保留:data/Object:type删除被拒(#5898 pin)exit 0,删除被放行DELETED_UNREGISTERED文案指向RETIRED_KEYS_BY_MAJOR落空的那条,如实记录:第 2 条我预测会红,实际是绿。原因是我执行的是部分恢复 ——
只把
registeredAt的取值改回叶名匹配,没有把文案一并改回。该 fixture 的叶名(
zzRetiredButUnregistered4650)不匹配任何子句,于是照样走registeredAt === undefined分支、打印新文案,断言继续通过。结论要说准:第 2 条是文案 pin,不是针对叶名匹配的行为
pin,只有在完整回退(含文案)时才会红。真正的行为 pin 是第 1、4、5 条。第 3 条按预测
不具鉴别力(
compactLayout自己也登记在 major 11,缺陷下同样放行),保留它是为了覆盖aged-out 正路,不作为 pin 主张 —— 这一点在测试注释里写明了。
Fixture 处置(逐条,不是批量改写)
DELETED_UNAGED(ai/Agent:triggerPhrases)→ 整条替换为DELETED_LEAF_COLLIDER=
data/Object:type,即 issue 的样本形状。旧 fixture 测的「已登记但太新」这条分支现在必须先有声明,已迁到新 box。
DELETED_AGED(data/Object:compactLayout)→ 迁走。主 sandbox 把src/做成符号链接,改不了 registry,而 aged-out 现在是一条关于该表的主张。
DELETED_UNREGISTERED→ 保留,只更新文案断言。新增
build-schemas.ts — check (c) dates a tombstone by its exact key (#5898)块,仿 #4659 的 box(拷贝
src/)以便替换RETIRED_KEYS_BY_MAJOR。fixture 有效性守卫是响的:
.type必须仍被某条 ADR-0087 子句登记、且该 major 已老化(否则 pre-#5898 的匹配器本来就会拒,pin 失去鉴别力)、且该 key 未被真实登记 —— 三条
任一不成立就失败并说明要换什么。
消费半径清扫
registeredClauseMajors与旧文案在全仓已无残留;RETIRED_KEYS_BY_MAJOR的消费者逐个看过。.claude/skills/spec-property-retirement/SKILL.md里点名 #5898、说「gate (c) 仍按叶名读子句」的那段现在是错的,已改写,并补上「该条目同时起算老化时钟 / 历史墓碑不可删 / ⚠ 定不出
年份的行不要写」。
Changeset:命名包 patch(已测量,非默认)
scripts/不在packages/spec的files白名单里,scripts-only 通常取skip-changeset(先例 #6102 / #6222)。但本 PR 动了 registry(
src/migrations/registry.ts的 JSDoc),实测发布字节确实移动:
RETIRED_KEYS_BY_MAJOR的文档注释出现在dist/index.d.ts与dist/index.d.mts中(grep -rl "Historical tombstones" dist/命中两者),而dist在白名单内。导出值未变,变的是一个已发布公共符号的契约文档。⇒ 取命名包 patch changeset。
验证
pnpm --filter @objectstack/spec test— 331 files / 8432 tests passedbuild-schemas-check-mode.test.ts单跑 — 51 passed(含 4 条新增)pnpm --filter @objectstack/spec typecheck— 通过(tsc --noEmit+check:test-typecheck)npx eslint三个改动文件 — 无输出pnpm --filter @objectstack/spec check:authorable-surface— 对真实基线跑通,✅ Successfully generated 1622 schemas.node scripts/check-nul-bytes.mjs— OK(5935 文件);改动文件另做grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'自扫,干净check-empty-changeset/check-skill-frame-sync/check-skill-frame-freshness/check-doc-authoring— 全绿🤖 Generated with Claude Code
https://claude.ai/code/session_014wsZeReNTqiceBfLb5Pyf5
Generated by Claude Code