fix(spec): ADR-0087 别名转换按值处置被遮蔽的旧拼法(等值删除、异值双留) (#4923) - #5731
Conversation
…keeping it (#4923) `renameKey` / `renameConfigKey` did nothing whenever the canonical key was already present, so every D2 rename left the retired spelling sitting in the converted metadata — the conversion had not finished converting. Invisible while the flow-node config contracts were `.strip`; an execute-time guard refusal once #4001 batch 9 made them strict. Per the maintainer ruling recorded on the issue, the case now splits on whether the two values disagree: - equal (structural equality) -> the alias carries nothing the canonical key does not, so it is deleted and the usual notice fires. Lossless hygiene, inside the D2 contract, and idempotent in both shape and notices. - different -> both keys survive and nothing is emitted. Two spellings with two values is genuine author ambiguity; picking one would edit a config the customer never agreed to. The surviving pair is what lets the strict gates refuse with a prescription naming BOTH keys. `renameConfigKey` now delegates to `renameKey` so the rule has one definition, and the structural-equality predicate is the one the composer already used for "same value composes fine" (#5005), extracted to `shared/deep-equal.ts` so the two surfaces cannot disagree about what "the same" means. `liftNotifySourceShape` follows the same rule: a nested part repeating its flat counterpart is redundant (and `source` is dropped), a part that disagrees leaves the node entirely untouched so `source` reaches the strict contract. The `wait` node's loose-key lift is deliberately NOT covered and says so — it moves keys between locations rather than resolving two spellings of one slot. Batch-9 guidance reworded: a surviving twin is no longer "dead", it holds a value the canonical key does not, so each prescription names both keys and asks for a decision. Also adopts `mapPageComponents` (#5509) for protocol-15 `page-component-visibility-to-visibleWhen` per #5511 — its existing fixture is unchanged and stays green, which is the equivalence acceptance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 109 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…d finish the guidance sweep (#4923) Follow-up on the same ruling. `renameKey(dict, k, k)` would have reached the new equal-value branch, compared the value against itself, called it a redundant twin and deleted the only copy. No registry pair is written that way today (all 40 literal pairs scanned), so this is a guard rather than a fix: one added later now converts nothing instead of erasing data. Pinned by a test on both `renameKey` and `renameConfigKey`. Also completes the batch-9 wording sweep the ruling asked for: the notify guidance test still asserted the old "dead twin" reading, and the `source` prescription said DISAGREES where the rest of the family says DIFFERENT. Both now speak one vocabulary, and the test additionally requires each prescription to say the two values differ — not just that one of them should be deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D
二次提交后的完整复验(最终树)
补的守卫
顺带统一了 notify 一次值得记下的假红首轮消费方扫描时 顺手记录的发现#5732(observation 类, Generated by Claude Code |
Fixes #4923
按 issue 线程中已记录的**维护者裁决(2026-08-04)**执行,纯执行、无自由裁量。
前提复核(对 origin/main)
裁决前提仍然成立,但 issue 正文的定位有一处过期:
renameConfigKey仍在packages/spec/src/conversions/walk.ts(现 283 行,issue 写的 235 行是旧行号),并未如派发单推测的那样迁移到registry.ts;refactor(spec)!: 退役 HookContext.session.roles —— 声明过、被两条死分支读过、从未被生产 (#5050) #5621 动的是 registry 的语义迁移条目,没搬这个 helper。if (config[to] != null) return null;。grid_碰撞不在本单的别名族里;issue 点名的 5 条 fixture(flow-node-crud-object-alias、flow-node-map-flow-alias、flow-node-subflow-flow-alias、flow-node-notify-config-aliases及其liftNotifySourceShape)全部仍在,after半边仍写着被遮蔽的旧拼法。另有一处仓库内的主动指认:
retry-policy-converged的注释原文写着「#4923 is queued to revisit that shadowing rule — this entry deliberately relies on the shared helper's semantics rather than open-coding its own, so it moves with that ruling」。这句话直接回答了「裁决该落在哪个 helper 上」——落在共享 helper,而不是只落在renameConfigKey。因此实现落在renameKey,由renameConfigKey委托,renameKey的其余调用方(datasource driver-config 别名、page:headerdescription、retryDelayMs)随之统一,不留第二套方言。改了什么
按值拆分(
walk.ts):renameKey遇到两个拼法同时存在时——null,双键都保留,不发 notice。作者给一个槽写了两个不同的值,这是真歧义,升级工具替客户择一等于改了客户没同意的配置。活下来的这一对,正是 strict 门能够点名双键拒绝的依据。相等性用结构比较而非
===:两处分别写出的{ status: 'stale' }是同一份声明。这个谓词正是 #5005 的 compose 已经在用的那个(「same value composes fine」),抽到shared/deep-equal.ts共用,免得两个面对「这两个值算不算同一个」给出不同答案(未加入shared/index,不动公共 API 面)。抽取时补了递归深度上限:两个调用点在触顶时都退化为「不相等」,即 compose 报冲突、转换保留双键——都是朝响的方向降级,不会静默丢作者的值。liftNotifySourceShape同理(issue 点名):嵌套部件与扁平键重复则算已交代、source照常删除;不一致则整个节点原样不动(连无歧义的那半也不抬),让source完整抵达 strict 契约。wait节点的松散键 lift 刻意不纳入,并在注释里写明理由:它是跨位置搬迁、且一个 target 对多个候选名,与「一个槽的两种拼法」不是同一个问题,扩张需要另一次裁决。fixture 按新语义修正:原来 5 条 fixture 的
after半边都靠「值不同」才恰好维持原状,但它们的注释写的是「canonical 已存在 → 别名原样留下」——那是旧规则的措辞,且只覆盖了两分支中的一支。逐条改为:歧义节点改用真正不同的值(object: 'ticket'而非'ignored',后者读起来像「这个值被忽略」,在新规则下恰是误导),并各补一个等值节点演示删除。notify 的 n3 由此从「抬一半、丢 source」变成「原样不动」,n4 新增演示等值冗余,expectedNotices7 到 8。批 9 guidance 同步措辞:活下来的孪生键不再是「dead」——转换现在会删掉等值的那种,所以能抵达该 parse 的键必然携带规范键没有的值。每条处方改为点名双键 + 要求作出决定(
builtin-node-config.zod.tsCRUD 与map、io-node-config.zod.tsnotify 五条、schemaless-node-config.zod.tsscript 与 subflow)。Rider(#5511):protocol-15
page-component-visibility-to-visibleWhen的就地走查换成 #5509 的mapPageComponents,25 行手写遍历变 2 行。验证
先证红(方向在跑之前就写死在测试注释里)。 把
renameKey的规则临时还原成旧语义(保留 import,确保红是行为红不是编译红)后跑新增的 7 条:这里要如实说明:两个方向的证红结构并不对称,报告模板预设的「两边都 before-red」在本单不成立。
✓就是它。旧代码对所有成对情形都保留双键,所以「双保留」这半改前改后都是绿的——它是回归钉,不是变更证明。这一方向真正变的是理由和读这条理由的处方,所以证红落在 strict 门那条上(not.toMatch(/already won and this key is dead/)),旧 guidance 的原文正是so a surviving object means objectName already won and this key is dead,红得干净。Rider 等价性:按 #5511 的验收口径,由既有 fixture 判定。
pageComponentVisibilityToVisibleWhen的 fixture 一个字节未动(diff 里apply之后直接进fixture:),在全量跑中保持绿。路径构造也逐字相同:mapPageComponents产出pages[i].regions[j].components[k],与原手写的path + .regions[ri].components[ci](path =pages[pi])一致;非数组regions/components、非 dict region/component 的短路分支也一一对应。无 fixture 需要修改,故不触发 stop-and-report。绿跑
消费半径已按「规则的调用方」而非「改动的包」扫过:
metadata/objectql/service-automation/service-datasource/metadata-protocol/lint(即applyConversions系列与normalizeStackInput的全部调用方)。生成物
check:generated十项全绿,没有任何生成物移动——本单改的是转换的apply行为与 fixture,而spec-changes.json/upgrade-guide投影的是条目的 id / surface / summary / toMajor,这些一个没动。packages/spec/authorable-surface.base.json在本地 build 时被gen:schema重锚了(baseRev指向新的 main,并带进api/Discovery:scoping等别人已合并的键)——与本单无关,按 #5358 已git checkout剔除,未进提交。风险与边界
validate输出的种类不变)。wait/connector_action的 lift 未纳入,已在代码注释中写明是「不同的问题」而非遗漏。Changeset
@objectstack/spec: patch—— 没有新增/移除任何可写的键或导出,是既有 D2 转换在一个边界情形上的行为订正,与仓库里同类行为修正(如webhook-authoring-surface-bridge给 spec 的 patch)同级。变更在升级时对作者可见,changeset 正文写清了作者看得到的两种结果。Generated by Claude Code