Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65ab48ae5a |
+12
-3
@@ -242,8 +242,11 @@ flag 迁移。
|
||||
Schema compatibility 使用同一组 base、stable、candidate refs,以及 base-owned flag
|
||||
与 command migration ledgers。merge-base-owned checker 分别规范化 merge-base 与
|
||||
stable 的完整 Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过
|
||||
Interface lifecycle 的 exact rename、command move 或 flag extraction 规范化到当前历史
|
||||
副本,不会维护第二份 allowlist,也不会放宽其他 Schema 历史字段。
|
||||
Interface lifecycle 的 exact rename、command move、flag extraction、availability
|
||||
hardening 或完整 product retirement 规范化到当前历史副本,不会维护第二份 allowlist,
|
||||
也不会放宽其他 Schema 历史字段。产品退役必须在共享 command ledger 中精确列出
|
||||
merge-base / stable 的全部 historical command 与 Schema tool 并集;candidate 不能用同一
|
||||
PR 新增记录并删除产品。
|
||||
|
||||
For a release-seal branch that archives rendered fragments:
|
||||
|
||||
@@ -353,6 +356,11 @@ helper、fixture 或在同一 PR 新增 self-approval 记录来放行 breaking c
|
||||
bootstrap 仍由 merge-base 已有的 modern helper 做无豁免比较,并只接受 candidate
|
||||
提交中的规范空清单;完整边界见下方治理文档。
|
||||
|
||||
首次增加旧 merge-base 不认识的 migration `kind` 必须拆成三个 PR:先只落 parser、
|
||||
lifecycle、CLI/Schema adapters、wrapper capability 与 hostile tests;再在 surface 不变时
|
||||
新增 `pending`;最后才由产品 PR 消费为 `consumed`。CLI 与 Schema wrappers 会在旧
|
||||
authority 首次看到 `product_retirement` 时提前拒绝,不能把机制与审批记录塞进同一 PR。
|
||||
|
||||
精确的两阶段 flag 迁移生命周期见
|
||||
[CLI flag 兼容迁移治理](cli-interface-flag-migrations.md)。治理 PR 只能在
|
||||
surface 未变化时新增 `pending`;后续产品 PR 达到审批的精确 surface 后,才能
|
||||
@@ -363,7 +371,8 @@ shorthand、no-opt 和任何无关漂移仍然阻塞。Schema 可以新增;历
|
||||
tool、parameter、mapping、positional execution、constraint 与 safety 语义继续
|
||||
受保护。`alias_of` 只是一项由 `FlagSpec.Aliases` 产生的框架关系证据,不是 payload
|
||||
等价证明;产品 PR 仍须证明 canonical 与 legacy 的最终运行 payload 等价并在 transport
|
||||
前拒绝冲突输入。当前迁移清单为空,不授权 PR #904。
|
||||
前拒绝冲突输入。`product_retirement` 机制 PR 不含任何 pending 退役记录,因此自身不授权
|
||||
删除产品。
|
||||
|
||||
## Required GitHub repository settings
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
两种原语都只放行清单精确登记的变化,不是通用 breaking-change 豁免,也不得在同一 command/flag 上叠加以绕过 rename 的 requiredness 保持规则。
|
||||
|
||||
同一套 base-owned lifecycle 也治理两类跨命令迁移:旧命令保留执行能力但从 Help / Schema 导航隐藏,并迁到新的公开命令路径;或把旧命令中的一个可选 flag 拆成新的专用命令。跨命令迁移只允许清单精确声明的 `command_became_hidden` / `flag_became_hidden` 及其 Schema 投影,不是通用 command-path breaking-change 豁免。
|
||||
同一套 base-owned lifecycle 也治理跨命令迁移和完整产品退役:旧命令保留执行能力但从 Help / Schema 导航隐藏,并迁到新的公开命令路径;把旧命令中的一个可选 flag 拆成新的专用命令;或在独立审批后整体删除一个 product root 及其全部 CLI / Schema surface。清单只允许每种 `kind` 精确声明的 finding 与 Schema 投影,不是通用 command-path breaking-change 豁免。
|
||||
|
||||
同名 flag 的精确类型迁移属于另一类评审机制,只能进入
|
||||
`internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename
|
||||
@@ -38,7 +38,7 @@ Smoke fixture,不参与迁移审批。
|
||||
同时提供 `--base` 与 `--stable`;核心 lifecycle 也拒绝缺失 stable 的非空清单,避免
|
||||
调用方因漏传历史参考而提前清理 consumed receipt。
|
||||
|
||||
PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入 flag 机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空 flag 清单。后续引入 command migration 扩展时,base 已拥有 flag comparator;bootstrap 仍只执行 base-owned 普通比较,不向旧 helper 传入新的 command ledger,因此允许随治理 PR 提交仍处于 before 的 pending 计划,也不会授予任何迁移豁免。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此本治理 PR 的新 parser、lifecycle、launcher 与 hostile tests 仍是必须由真人评审的受保护策略变更;它们合入后才成为后续 PR 的 base-owned authority。
|
||||
PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入 flag 机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空 flag 清单。首次引入整个 command ledger 时,base 已拥有 flag comparator;bootstrap 仍只执行 base-owned 普通比较,不向旧 helper 传入新的 command ledger,也不会授予任何迁移豁免。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此新 parser、lifecycle、launcher 与 hostile tests 必须先由真人评审,合入后才成为后续 PR 的 base-owned authority。
|
||||
|
||||
这条边界保护比较规则和审批数据,不是任意代码沙箱。GitHub workflow / launcher 的变更仍由仓库保护规则和真人评审负责;candidate Cobra 构建也会执行 candidate 代码,因此对同一 runner 上的主动恶意代码,需要独立进程或文件系统隔离,不能把本门禁描述成已经解决。
|
||||
|
||||
@@ -51,16 +51,56 @@ scripts/policy/interface-migrations/approved-command-migrations-v1.json
|
||||
|
||||
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。历史未声明 `kind` 的记录按 `flag_rename` 解释;新增同名 requiredness 迁移必须显式写 `kind: requiredness_change` 和单一 `flag` before/after。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
|
||||
|
||||
首次引入一个旧 merge-base 不认识的新 `kind` 时,机制 PR 不得同时写入该 kind 的 pending 记录,因为旧的 base-owned 严格解析器会拒绝未知字段。必须先合入 parser、lifecycle、CLI/Schema adapter 与 hostile tests;待这些实现成为新的 merge-base authority 后,再用独立治理审批 PR 新增 pending,最后才由产品 PR 消费。
|
||||
首次引入一个旧 merge-base 不认识的新 `kind` 时,必须使用三个独立 PR:机制 PR 只合入 parser、lifecycle、CLI/Schema adapter、wrapper capability 与 hostile tests,清单保持不含该 kind;待实现成为新的 merge-base authority 后,治理审批 PR 只新增 `pending` 且产品 surface 不变;最后产品 PR 才能删除或迁移 surface 并把记录改成 `consumed`。两个 authoritative wrapper 都会在旧 authority 首次收到 `product_retirement` 时提前拒绝并提示三阶段流程;旧严格 parser 的 unknown-kind 拒绝仍是兜底。
|
||||
|
||||
## 跨命令迁移原语
|
||||
|
||||
`approved-command-migrations-v1.json` 只接受两种 `kind`:
|
||||
`approved-command-migrations-v1.json` 接受以下四种 `kind`:
|
||||
|
||||
| kind | CLI after 状态 | Schema 允许的精确投影 |
|
||||
|---|---|---|
|
||||
| `command_move` | legacy 命令仍 runnable、由 visible 变 hidden;replacement 由 absent 变 visible runnable | 同一 stable tool identity 的 `primary_cli_path` 改到 replacement;只允许清单列出的参数改名,参数类型、property、requiredness、default 等必须等价 |
|
||||
| `flag_extraction` | legacy 命令保持 visible runnable;指定 legacy flag 仍可执行但由 visible 变 hidden;replacement 由 absent 变 visible runnable | source tool 只删除指定参数;replacement tool 必须位于精确的新路径,并保持 source 的 interface 与 safety identity;清单必须完整列出每个 source 参数到 replacement 参数或常量 property 的承接关系 |
|
||||
| `schema_availability_hardening` | 精确命令保持 runnable,并按记录变 hidden 或保持 compatibility-visible | 同一 tool 只能从记录的 `availability.before` 变为 `availability.after`,其他字段仍由普通兼容检查约束 |
|
||||
| `product_retirement` | 一个精确 product root 及其全部后代从已声明的 before surface 整体变为 absent | 删除同名 Schema product;`commands` 与 `schema.tools` 必须分别完整覆盖 merge-base / stable 的历史并集 |
|
||||
|
||||
### 完整产品退役
|
||||
|
||||
`product_retirement` 的 `legacy.command` 必须是 `dws <product_id>` 这一层的精确 product root;不接受子路径、alias、prefix 或 wildcard。root 的 before 必须是 runnable,并精确声明当时的 `hidden` 值,因此既能治理公开 root,也能治理真实存在的 hidden runnable root;after 只能是 absent。该 kind 不声明 replacement、legacy flag、参数映射或 availability。
|
||||
|
||||
记录必须包含两个已排序、无重复的完整集合:
|
||||
|
||||
- `commands`:merge-base 与 stable Interface Snapshot 中该 root 本身和全部后代的 canonical path 并集;root 必须包含在内。任一历史快照出现未登记后代,或登记了两份历史都不存在的命令,都会失败。
|
||||
- `schema.tools`:merge-base 与 stable normalized Schema 中同名 product 的全部 canonical tool ID 并集;每个 ID 必须以 `<product_id>.` 开头。stable 可以没有该产品或只含较早子集,但两份历史都必须受同一记录约束。
|
||||
|
||||
治理审批 PR 中,candidate 的完整命令对象集合和 Schema product 必须与 merge-base 深度相等;只删一个 child、改 flag / alias / safety、改 tool contract,或在同一 PR 删除产品都会被视为 self-approval。产品 PR 消费 base-owned pending 时,只接受 CLI subtree 和 Schema product 同时整体 absent。普通 Interface Compare 会把整棵树的删除折叠为 root `command_removed`,但 lifecycle 会在过滤该 finding 前独立核对每一个历史 command,因此折叠不会扩大授权。
|
||||
|
||||
下面只是结构示例,不代表已审批产品;首次落地该 kind 的机制 PR 也不得加入这条记录:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "product_retirement",
|
||||
"legacy": {
|
||||
"command": "dws edu-app",
|
||||
"before": {"present": true, "runnable": true, "hidden": true},
|
||||
"after": {"present": false}
|
||||
},
|
||||
"commands": [
|
||||
"dws edu-app",
|
||||
"dws edu-app task",
|
||||
"dws edu-app task list"
|
||||
],
|
||||
"schema": {
|
||||
"product_id": "edu-app",
|
||||
"tools": [
|
||||
"edu-app.query_all_task",
|
||||
"edu-app.query_publish_task"
|
||||
]
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Retire the reviewed education application product surface."
|
||||
}
|
||||
```
|
||||
|
||||
`command_move` 只能隐藏没有子命令的 legacy leaf,且 legacy 与 replacement
|
||||
不得互为祖先路径;整棵命令树的迁移需要单独设计逐叶治理,不能复用这一原语。
|
||||
@@ -102,7 +142,7 @@ bool 常量证据属于 bootstrap;一旦任一历史快照已记录该
|
||||
replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run` 非空时不得删除或改值;
|
||||
历史未声明时允许 replacement 新增 dry-run。这与普通 Schema 兼容规则保持同一单调边界。
|
||||
|
||||
两种迁移都要求旧 argv 继续可执行。删除旧命令、删除旧 flag、把 legacy 改成 non-runnable、改变未登记的历史参数、改变 interface / safety,或只完成部分 before → after 转换都会 fail closed。命令别名会先规范到 reference 的 canonical path,但清单本身仍只能记录精确 canonical 命令,不能用 alias 或前缀扩大授权。
|
||||
`command_move` 与 `flag_extraction` 都要求旧 argv 继续可执行。删除旧命令、删除旧 flag、把 legacy 改成 non-runnable、改变未登记的历史参数、改变 interface / safety,或只完成部分 before → after 转换都会 fail closed。命令别名会先规范到 reference 的 canonical path,但清单本身仍只能记录精确 canonical 命令,不能用 alias 或前缀扩大授权。`product_retirement` 是唯一允许整体删除命令的 kind,并受上一节的完整集合证明约束。
|
||||
|
||||
跨命令清单复用下文同一套 `pending → consumed → inert/cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。
|
||||
|
||||
@@ -114,8 +154,8 @@ rename 以 `(kind, command, legacy flag, canonical flag)` 为唯一精确键;r
|
||||
|
||||
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|
||||
|---|---|---|
|
||||
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
|
||||
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | rename 的 legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`,canonical requiredness 保持不变;requiredness change 只把同名 flag 从 optional 提升为 required |
|
||||
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;产品退役还要求 command subtree 与 Schema product 深度相等 |
|
||||
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | rename 的 legacy 仍存在但由 visible 变 hidden;requiredness change 只提升同名 flag;产品退役则要求完整 CLI subtree 与 Schema product 同时 absent |
|
||||
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
|
||||
| 4. 惰性保留或清理 | 当 merge-base 和 stable 都已经是 `after`,该记录不再提供任何授权;后续 PR 可以原样保留或删除 | 两份参考快照均精确匹配 `after`;保留时仍必须是不可改写的 `consumed`,接口偏离 `after` 继续失败 |
|
||||
|
||||
@@ -211,6 +251,8 @@ rename 以 `(kind, command, legacy flag, canonical flag)` 为唯一精确键;r
|
||||
2. required legacy 被新增的 required canonical 替代时产生的 `required_flag_added`;如果 canonical 在 before 阶段只是 hidden 占位符,则允许它在转为公开拼写时继承 legacy 的 requiredness。已有的 visible canonical 不允许借 rename 改变 requiredness。
|
||||
3. `requiredness_change` 中同名 flag 从 optional 提升为 required 时产生的 `flag_became_required`。
|
||||
|
||||
跨命令记录另有各自的窄授权。`product_retirement` 只有在 lifecycle 已证明 current 整棵 subtree absent、且每个 merge-base / stable 历史命令都被完整登记后,才会移除该 product root 的 `command_removed`(以及同一 root 的 alias removal)finding;它不会按 prefix 过滤任意 child finding,也不会授权其他产品。
|
||||
|
||||
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
|
||||
|
||||
- 删除 legacy、canonical、命令或其他 flag;
|
||||
@@ -251,6 +293,8 @@ canonical-only `after` 状态时不需要再次投影;adapter 保持 baseline
|
||||
format、enum、`required_when`、constraints、positionals 与 safety 等全部字段仍交给原 checker,
|
||||
任何不相干漂移继续阻塞。
|
||||
|
||||
`product_retirement` 的 Schema adapter 读取同一条 command ledger 记录,不维护第二份 allowlist。checker 必须同时收到 merge-base 与 stable 两份 normalized Schema:pending 的 `schema.tools` 等于两份历史的精确并集,candidate-added pending 还要求 current product 与 merge-base 深度相等;消费时 current 不得再发布该 product。adapter 只从当前被检查的历史副本删除该完整 product,再交给普通兼容检查,其他 product 不变。
|
||||
|
||||
## 本地验证
|
||||
|
||||
先确保 merge-base 和 stable tag 已在本地,然后运行与 CI 相同的权威门禁:
|
||||
|
||||
@@ -9,24 +9,31 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const CommandMigrationManifestVersion = 1
|
||||
|
||||
// productRetirementMigrationCapability is inspected only in the trusted
|
||||
// merge-base source by the authoritative compatibility wrappers. A mechanism
|
||||
// PR must land this capability before a later PR may add a pending retirement.
|
||||
const productRetirementMigrationCapability = "dws.command-migration.product-retirement.v1"
|
||||
|
||||
const (
|
||||
CommandMigrationPending = "pending"
|
||||
CommandMigrationConsumed = "consumed"
|
||||
|
||||
CommandMigrationMove = "command_move"
|
||||
CommandMigrationFlagExtraction = "flag_extraction"
|
||||
CommandMigrationAvailability = "schema_availability_hardening"
|
||||
CommandMigrationMove = "command_move"
|
||||
CommandMigrationFlagExtraction = "flag_extraction"
|
||||
CommandMigrationAvailability = "schema_availability_hardening"
|
||||
CommandMigrationProductRetirement = "product_retirement"
|
||||
)
|
||||
|
||||
// CommandMigrationManifest governs compatibility-preserving surface moves that
|
||||
// cannot be represented as an in-command flag rename. The merge-base owns the
|
||||
// authorization; the candidate copy is only a lifecycle receipt.
|
||||
// CommandMigrationManifest governs reviewed cross-command transitions and
|
||||
// whole-product retirement. The merge-base owns the authorization; the
|
||||
// candidate copy is only a lifecycle receipt.
|
||||
type CommandMigrationManifest struct {
|
||||
Version int `json:"version"`
|
||||
Migrations []CommandMigration `json:"migrations"`
|
||||
@@ -37,6 +44,7 @@ type CommandMigration struct {
|
||||
Legacy CommandMigrationSide `json:"legacy"`
|
||||
Replacement CommandMigrationSide `json:"replacement"`
|
||||
LegacyFlag CommandMigrationFlag `json:"legacy_flag"`
|
||||
Commands []string `json:"commands,omitempty"`
|
||||
Schema CommandMigrationSchema `json:"schema"`
|
||||
State string `json:"state"`
|
||||
Reason string `json:"reason"`
|
||||
@@ -64,6 +72,7 @@ type CommandMigrationSchema struct {
|
||||
ProductID string `json:"product_id"`
|
||||
SourceToolID string `json:"source_tool_id"`
|
||||
ReplacementToolID string `json:"replacement_tool_id"`
|
||||
Tools []string `json:"tools,omitempty"`
|
||||
Parameters []CommandParameterMigration `json:"parameters"`
|
||||
Availability *CommandAvailabilityChange `json:"availability,omitempty"`
|
||||
}
|
||||
@@ -133,9 +142,22 @@ func (m CommandMigrationManifest) Validate() error {
|
||||
}
|
||||
|
||||
func (m CommandMigration) validate() error {
|
||||
if m.Kind != CommandMigrationMove && m.Kind != CommandMigrationFlagExtraction && m.Kind != CommandMigrationAvailability {
|
||||
if m.Kind != CommandMigrationMove && m.Kind != CommandMigrationFlagExtraction &&
|
||||
m.Kind != CommandMigrationAvailability && m.Kind != CommandMigrationProductRetirement {
|
||||
return fmt.Errorf("invalid kind %q", m.Kind)
|
||||
}
|
||||
if strings.TrimSpace(m.Reason) == "" || m.Reason != strings.TrimSpace(m.Reason) {
|
||||
return fmt.Errorf("migration must include a non-empty trimmed reason")
|
||||
}
|
||||
if m.State != CommandMigrationPending && m.State != CommandMigrationConsumed {
|
||||
return fmt.Errorf("invalid state %q", m.State)
|
||||
}
|
||||
if m.Kind == CommandMigrationProductRetirement {
|
||||
return m.validateProductRetirement()
|
||||
}
|
||||
if m.Commands != nil {
|
||||
return fmt.Errorf("%s must not declare commands", m.Kind)
|
||||
}
|
||||
if m.Kind == CommandMigrationAvailability {
|
||||
if !isExactCommandPath(m.Legacy.Command) {
|
||||
return fmt.Errorf("legacy must be an exact command path rooted at dws")
|
||||
@@ -151,12 +173,6 @@ func (m CommandMigration) validate() error {
|
||||
return fmt.Errorf("legacy and replacement command paths must differ")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(m.Reason) == "" || m.Reason != strings.TrimSpace(m.Reason) {
|
||||
return fmt.Errorf("migration must include a non-empty trimmed reason")
|
||||
}
|
||||
if m.State != CommandMigrationPending && m.State != CommandMigrationConsumed {
|
||||
return fmt.Errorf("invalid state %q", m.State)
|
||||
}
|
||||
states := map[string]CommandMigrationState{
|
||||
"legacy before": m.Legacy.Before,
|
||||
"legacy after": m.Legacy.After,
|
||||
@@ -246,6 +262,42 @@ func (m CommandMigration) validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m CommandMigration) validateProductRetirement() error {
|
||||
if !isExactCommandPath(m.Legacy.Command) || m.Legacy.Command == "dws" {
|
||||
return fmt.Errorf("product_retirement legacy must be an exact product root below dws")
|
||||
}
|
||||
if m.Replacement != (CommandMigrationSide{}) {
|
||||
return fmt.Errorf("product_retirement must not declare replacement")
|
||||
}
|
||||
if m.LegacyFlag != (CommandMigrationFlag{}) {
|
||||
return fmt.Errorf("product_retirement must not declare legacy_flag")
|
||||
}
|
||||
if !m.Legacy.Before.Present || !m.Legacy.Before.Runnable || m.Legacy.After != (CommandMigrationState{}) {
|
||||
return fmt.Errorf("product_retirement root must migrate exactly from its declared runnable visibility to absent")
|
||||
}
|
||||
if len(strings.Fields(m.Legacy.Command)) != 2 || strings.TrimPrefix(m.Legacy.Command, "dws ") != m.Schema.ProductID {
|
||||
return fmt.Errorf("product_retirement root must exactly match schema product_id")
|
||||
}
|
||||
if len(m.Commands) == 0 {
|
||||
return fmt.Errorf("product_retirement commands must be a non-empty array")
|
||||
}
|
||||
rootFound := false
|
||||
for index, command := range m.Commands {
|
||||
if !isExactCommandPath(command) ||
|
||||
(command != m.Legacy.Command && !strings.HasPrefix(command, m.Legacy.Command+" ")) {
|
||||
return fmt.Errorf("product_retirement command %d must be an exact path in root %q", index, m.Legacy.Command)
|
||||
}
|
||||
if index > 0 && m.Commands[index-1] >= command {
|
||||
return fmt.Errorf("product_retirement commands must be sorted and unique")
|
||||
}
|
||||
rootFound = rootFound || command == m.Legacy.Command
|
||||
}
|
||||
if !rootFound {
|
||||
return fmt.Errorf("product_retirement commands must include root %q", m.Legacy.Command)
|
||||
}
|
||||
return m.Schema.validate(m.Kind)
|
||||
}
|
||||
|
||||
func (s CommandMigrationState) validate(label string) error {
|
||||
if !s.Present {
|
||||
if s.Runnable || s.Hidden {
|
||||
@@ -286,6 +338,30 @@ func (f CommandMigrationFlag) validate() error {
|
||||
}
|
||||
|
||||
func (s CommandMigrationSchema) validate(kind string) error {
|
||||
if kind == CommandMigrationProductRetirement {
|
||||
if !isExactSchemaIdentifier(s.ProductID) {
|
||||
return fmt.Errorf("schema product_id must be an exact identifier")
|
||||
}
|
||||
if s.SourceToolID != "" || s.ReplacementToolID != "" || s.Parameters != nil || s.Availability != nil {
|
||||
return fmt.Errorf("product_retirement schema must declare only product_id and tools")
|
||||
}
|
||||
if len(s.Tools) == 0 {
|
||||
return fmt.Errorf("product_retirement schema tools must be a non-empty array")
|
||||
}
|
||||
prefix := s.ProductID + "."
|
||||
for index, tool := range s.Tools {
|
||||
if !isExactSchemaIdentifier(tool) || !strings.HasPrefix(tool, prefix) || len(tool) == len(prefix) {
|
||||
return fmt.Errorf("product_retirement schema tool %d must be an exact canonical path in product %q", index, s.ProductID)
|
||||
}
|
||||
if index > 0 && s.Tools[index-1] >= tool {
|
||||
return fmt.Errorf("product_retirement schema tools must be sorted and unique")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if s.Tools != nil {
|
||||
return fmt.Errorf("%s must not declare schema tools", kind)
|
||||
}
|
||||
for label, value := range map[string]string{
|
||||
"product_id": s.ProductID,
|
||||
"source_tool_id": s.SourceToolID,
|
||||
@@ -386,7 +462,7 @@ func (m CommandMigration) key() string {
|
||||
}
|
||||
|
||||
func (m CommandMigration) displayKey() string {
|
||||
if m.Kind == CommandMigrationAvailability {
|
||||
if m.Kind == CommandMigrationAvailability || m.Kind == CommandMigrationProductRetirement {
|
||||
return fmt.Sprintf("%s %q", m.Kind, m.Legacy.Command)
|
||||
}
|
||||
return fmt.Sprintf("%s %q -> %q", m.Kind, m.Legacy.Command, m.Replacement.Command)
|
||||
@@ -481,6 +557,11 @@ func evaluateCommandMigrationLifecycle(
|
||||
candidateByKey := commandMigrationIndex(candidate)
|
||||
authorizations := make([]CommandMigration, 0, len(authority.Migrations))
|
||||
for _, approved := range authority.Migrations {
|
||||
if approved.Kind == CommandMigrationProductRetirement && approved.State == CommandMigrationPending {
|
||||
if err := validateProductRetirementCommandScope(references, approved); err != nil {
|
||||
return nil, fmt.Errorf("approved command migration %s: %w", approved.displayKey(), err)
|
||||
}
|
||||
}
|
||||
basePhase := matchCommandMigrationPhase(mergeBase, approved)
|
||||
wantBase := commandMigrationBefore
|
||||
if approved.State == CommandMigrationConsumed && !isCompatibilityVisibleAvailabilityMigration(approved) {
|
||||
@@ -495,6 +576,10 @@ func evaluateCommandMigrationLifecycle(
|
||||
return nil, fmt.Errorf("candidate modified base-owned command migration %s", approved.displayKey())
|
||||
}
|
||||
currentPhase := matchCommandMigrationPhase(current, approved)
|
||||
if approved.Kind == CommandMigrationProductRetirement && currentPhase == commandMigrationBefore && exists &&
|
||||
!reflect.DeepEqual(productRetirementCommands(current, approved), productRetirementCommands(mergeBase, approved)) {
|
||||
return nil, fmt.Errorf("candidate changed pending product retirement surface %s", approved.displayKey())
|
||||
}
|
||||
if isCompatibilityVisibleAvailabilityMigration(approved) {
|
||||
if currentPhase != commandMigrationBefore {
|
||||
return nil, fmt.Errorf("candidate drifted from compatibility-visible command migration %s", approved.displayKey())
|
||||
@@ -567,12 +652,21 @@ func evaluateCommandMigrationLifecycle(
|
||||
if proposed.State != CommandMigrationPending {
|
||||
return nil, fmt.Errorf("candidate-added command migration %s must start pending", proposed.displayKey())
|
||||
}
|
||||
if proposed.Kind == CommandMigrationProductRetirement {
|
||||
if err := validateProductRetirementCommandScope(references, proposed); err != nil {
|
||||
return nil, fmt.Errorf("candidate-added command migration %s: %w", proposed.displayKey(), err)
|
||||
}
|
||||
}
|
||||
if matchCommandMigrationPhase(mergeBase, proposed) != commandMigrationBefore {
|
||||
return nil, fmt.Errorf("candidate-added command migration %s does not match the merge-base before state", proposed.displayKey())
|
||||
}
|
||||
if matchCommandMigrationPhase(current, proposed) != commandMigrationBefore {
|
||||
return nil, fmt.Errorf("candidate-added command migration %s cannot authorize its own interface change", proposed.displayKey())
|
||||
}
|
||||
if proposed.Kind == CommandMigrationProductRetirement &&
|
||||
!reflect.DeepEqual(productRetirementCommands(current, proposed), productRetirementCommands(mergeBase, proposed)) {
|
||||
return nil, fmt.Errorf("candidate-added command migration %s changed the product surface in its approval PR", proposed.displayKey())
|
||||
}
|
||||
}
|
||||
return authorizations, nil
|
||||
}
|
||||
@@ -614,6 +708,9 @@ func isPendingAvailabilityCompatibilityRefinement(approved, proposed CommandMigr
|
||||
}
|
||||
|
||||
func matchCommandMigrationPhase(snapshot Snapshot, migration CommandMigration) commandMigrationPhase {
|
||||
if migration.Kind == CommandMigrationProductRetirement {
|
||||
return matchProductRetirementPhase(snapshot, migration)
|
||||
}
|
||||
commands := commandIndex(snapshot)
|
||||
legacy := commandMigrationStateForCommand(commands, migration.Legacy.Command)
|
||||
if migration.Kind == CommandMigrationAvailability {
|
||||
@@ -651,6 +748,55 @@ func matchCommandMigrationPhase(snapshot Snapshot, migration CommandMigration) c
|
||||
return commandMigrationPartial
|
||||
}
|
||||
|
||||
func matchProductRetirementPhase(snapshot Snapshot, migration CommandMigration) commandMigrationPhase {
|
||||
commands := productRetirementCommands(snapshot, migration)
|
||||
if len(commands) == 0 {
|
||||
return commandMigrationAfter
|
||||
}
|
||||
root, exists := commandIndex(snapshot)[migration.Legacy.Command]
|
||||
if !exists || commandMigrationStateForCommand(map[string]Command{root.Path: root}, root.Path) != migration.Legacy.Before {
|
||||
return commandMigrationPartial
|
||||
}
|
||||
approved := make(map[string]bool, len(migration.Commands))
|
||||
for _, path := range migration.Commands {
|
||||
approved[path] = true
|
||||
}
|
||||
for _, command := range commands {
|
||||
if !approved[command.Path] {
|
||||
return commandMigrationPartial
|
||||
}
|
||||
}
|
||||
return commandMigrationBefore
|
||||
}
|
||||
|
||||
func productRetirementCommands(snapshot Snapshot, migration CommandMigration) []Command {
|
||||
commands := make([]Command, 0, len(migration.Commands))
|
||||
for _, command := range snapshot.Commands {
|
||||
if command.Path == migration.Legacy.Command || strings.HasPrefix(command.Path, migration.Legacy.Command+" ") {
|
||||
commands = append(commands, command)
|
||||
}
|
||||
}
|
||||
sort.Slice(commands, func(i, j int) bool { return commands[i].Path < commands[j].Path })
|
||||
return commands
|
||||
}
|
||||
|
||||
func validateProductRetirementCommandScope(references map[string]Snapshot, migration CommandMigration) error {
|
||||
union := make(map[string]bool, len(migration.Commands))
|
||||
for label, snapshot := range references {
|
||||
phase := matchProductRetirementPhase(snapshot, migration)
|
||||
if phase == commandMigrationPartial {
|
||||
return fmt.Errorf("%s product command surface is outside the reviewed commands", label)
|
||||
}
|
||||
for _, command := range productRetirementCommands(snapshot, migration) {
|
||||
union[command.Path] = true
|
||||
}
|
||||
}
|
||||
if len(union) != len(migration.Commands) {
|
||||
return fmt.Errorf("reviewed commands do not exactly match the historical product command union")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func commandMigrationReplacementConstantsMatch(command Command, migration CommandMigration) bool {
|
||||
expected := make(map[string]bool)
|
||||
for _, parameter := range migration.Schema.Parameters {
|
||||
@@ -738,6 +884,11 @@ func commandMigrationAuthorizesChange(current, reference Snapshot, change Change
|
||||
if change.Kind == "flag_became_hidden" && change.Flag == migration.LegacyFlag.Name {
|
||||
return true
|
||||
}
|
||||
case CommandMigrationProductRetirement:
|
||||
if (change.Kind == "command_removed" || change.Kind == "command_alias_removed") &&
|
||||
change.Flag == "" && canonicalPath == migration.Legacy.Command {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -651,6 +651,239 @@ func TestCrossPlatformCoverageCommandMigrationLifecycleAndExactFiltering(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProductRetirementValidation(t *testing.T) {
|
||||
valid := productRetirementCommandMigration(CommandMigrationPending)
|
||||
if err := valid.validate(); err != nil {
|
||||
t.Fatalf("valid product retirement: %v", err)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*CommandMigration)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "root must be below dws",
|
||||
mutate: func(m *CommandMigration) { m.Legacy.Command = "dws" },
|
||||
want: "exact product root below dws",
|
||||
},
|
||||
{
|
||||
name: "root must be one product segment",
|
||||
mutate: func(m *CommandMigration) { m.Legacy.Command = "dws edu-app task" },
|
||||
want: "exactly match schema product_id",
|
||||
},
|
||||
{
|
||||
name: "root must match product id",
|
||||
mutate: func(m *CommandMigration) { m.Schema.ProductID = "college-contact" },
|
||||
want: "exactly match schema product_id",
|
||||
},
|
||||
{
|
||||
name: "replacement forbidden",
|
||||
mutate: func(m *CommandMigration) { m.Replacement.Command = "dws replacement" },
|
||||
want: "must not declare replacement",
|
||||
},
|
||||
{
|
||||
name: "legacy flag forbidden",
|
||||
mutate: func(m *CommandMigration) { m.LegacyFlag.Name = "legacy" },
|
||||
want: "must not declare legacy_flag",
|
||||
},
|
||||
{
|
||||
name: "root must be runnable",
|
||||
mutate: func(m *CommandMigration) { m.Legacy.Before.Runnable = false },
|
||||
want: "declared runnable visibility to absent",
|
||||
},
|
||||
{
|
||||
name: "after must be absent",
|
||||
mutate: func(m *CommandMigration) { m.Legacy.After.Present = true },
|
||||
want: "declared runnable visibility to absent",
|
||||
},
|
||||
{
|
||||
name: "commands required",
|
||||
mutate: func(m *CommandMigration) { m.Commands = nil },
|
||||
want: "commands must be a non-empty array",
|
||||
},
|
||||
{
|
||||
name: "root command required",
|
||||
mutate: func(m *CommandMigration) { m.Commands = m.Commands[1:] },
|
||||
want: "must include root",
|
||||
},
|
||||
{
|
||||
name: "command outside product",
|
||||
mutate: func(m *CommandMigration) { m.Commands[1] = "dws edu-group task" },
|
||||
want: "exact path in root",
|
||||
},
|
||||
{
|
||||
name: "commands sorted",
|
||||
mutate: func(m *CommandMigration) {
|
||||
m.Commands[1], m.Commands[2] = m.Commands[2], m.Commands[1]
|
||||
},
|
||||
want: "sorted and unique",
|
||||
},
|
||||
{
|
||||
name: "schema tools required",
|
||||
mutate: func(m *CommandMigration) { m.Schema.Tools = nil },
|
||||
want: "tools must be a non-empty array",
|
||||
},
|
||||
{
|
||||
name: "schema tool outside product",
|
||||
mutate: func(m *CommandMigration) { m.Schema.Tools[0] = "edu-group.query_task" },
|
||||
want: "canonical path in product",
|
||||
},
|
||||
{
|
||||
name: "schema tool requires leaf",
|
||||
mutate: func(m *CommandMigration) { m.Schema.Tools[0] = "edu-app." },
|
||||
want: "canonical path in product",
|
||||
},
|
||||
{
|
||||
name: "schema tools sorted",
|
||||
mutate: func(m *CommandMigration) {
|
||||
m.Schema.Tools[0], m.Schema.Tools[1] = m.Schema.Tools[1], m.Schema.Tools[0]
|
||||
},
|
||||
want: "sorted and unique",
|
||||
},
|
||||
{
|
||||
name: "source tool forbidden",
|
||||
mutate: func(m *CommandMigration) { m.Schema.SourceToolID = "edu-app.old" },
|
||||
want: "only product_id and tools",
|
||||
},
|
||||
{
|
||||
name: "parameter migration forbidden",
|
||||
mutate: func(m *CommandMigration) {
|
||||
m.Schema.Parameters = []CommandParameterMigration{}
|
||||
},
|
||||
want: "only product_id and tools",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
migration := cloneCommandMigration(valid)
|
||||
test.mutate(&migration)
|
||||
if err := migration.validate(); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("validate error=%v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
move := commandMigrationManifest(CommandMigrationPending).Migrations[0]
|
||||
move.Commands = []string{"dws chat message old"}
|
||||
if err := move.validate(); err == nil || !strings.Contains(err.Error(), "must not declare commands") {
|
||||
t.Fatalf("non-retirement commands error=%v", err)
|
||||
}
|
||||
move = commandMigrationManifest(CommandMigrationPending).Migrations[0]
|
||||
move.Schema.Tools = []string{"chat.old"}
|
||||
if err := move.validate(); err == nil || !strings.Contains(err.Error(), "must not declare schema tools") {
|
||||
t.Fatalf("non-retirement tools error=%v", err)
|
||||
}
|
||||
|
||||
invalidSchema := valid.Schema
|
||||
invalidSchema.ProductID = "bad/id"
|
||||
if err := invalidSchema.validate(CommandMigrationProductRetirement); err == nil ||
|
||||
!strings.Contains(err.Error(), "product_id must be an exact identifier") {
|
||||
t.Fatalf("invalid retirement Schema product error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProductRetirementLifecycleAndExactScope(t *testing.T) {
|
||||
base := productRetirementSnapshot("full")
|
||||
stable := productRetirementSnapshot("stable-subset")
|
||||
after := productRetirementSnapshot("absent")
|
||||
pending := productRetirementManifest(CommandMigrationPending)
|
||||
consumed := productRetirementManifest(CommandMigrationConsumed)
|
||||
empty := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}}
|
||||
emptyFlags := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}
|
||||
references := map[string]Snapshot{"merge-base": base, "stable": stable}
|
||||
|
||||
if got, err := AuthorizeCommandMigrations(base, references, empty, pending); err != nil || len(got) != 0 {
|
||||
t.Fatalf("candidate pending retirement plan=%#v, %v", got, err)
|
||||
}
|
||||
if _, err := AuthorizeCommandMigrations(after, references, empty, pending); err == nil ||
|
||||
!strings.Contains(err.Error(), "cannot authorize its own interface change") {
|
||||
t.Fatalf("candidate retirement self-approval error=%v", err)
|
||||
}
|
||||
|
||||
authorizations, err := AuthorizeCommandMigrations(after, references, pending, consumed)
|
||||
if err != nil || len(authorizations) != 1 {
|
||||
t.Fatalf("approved product retirement authorizations=%#v, %v", authorizations, err)
|
||||
}
|
||||
ordinary := CompareAll(after, references)
|
||||
var sawRootRemoval, sawRootAliasRemoval bool
|
||||
for _, comparison := range ordinary.Comparisons {
|
||||
if comparison.Reference != "merge-base" {
|
||||
continue
|
||||
}
|
||||
for _, change := range comparison.Blocking {
|
||||
switch {
|
||||
case change.Kind == "command_removed" && change.Path == "dws edu-app":
|
||||
sawRootRemoval = true
|
||||
case change.Kind == "command_alias_removed" && change.Path == "dws education-app":
|
||||
sawRootAliasRemoval = true
|
||||
case strings.HasPrefix(change.Path, "dws edu-app ") || strings.HasPrefix(change.Path, "dws education-app "):
|
||||
t.Fatalf("ordinary comparison did not collapse nested product removal to its roots: %#v", comparison.Blocking)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawRootRemoval || !sawRootAliasRemoval {
|
||||
t.Fatalf("ordinary comparison findings root=%v root-alias=%v: %#v", sawRootRemoval, sawRootAliasRemoval, ordinary.Comparisons)
|
||||
}
|
||||
report, err := CompareAllWithInterfaceMigrations(
|
||||
after,
|
||||
references,
|
||||
emptyFlags,
|
||||
emptyFlags,
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("governed product retirement: %v", err)
|
||||
}
|
||||
if !report.Compatible {
|
||||
t.Fatalf("whole product retirement remained blocking: %#v", report.Comparisons)
|
||||
}
|
||||
|
||||
partial := productRetirementSnapshot("partial")
|
||||
if _, err := AuthorizeCommandMigrations(partial, references, pending, consumed); err == nil ||
|
||||
!strings.Contains(err.Error(), "changed pending product retirement surface") {
|
||||
t.Fatalf("partial product retirement error=%v", err)
|
||||
}
|
||||
|
||||
unlisted := productRetirementSnapshot("unlisted-child")
|
||||
if _, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"merge-base": unlisted, "stable": stable}, pending, consumed); err == nil ||
|
||||
!strings.Contains(err.Error(), "outside the reviewed commands") {
|
||||
t.Fatalf("unlisted historical command error=%v", err)
|
||||
}
|
||||
|
||||
tooBroad := productRetirementManifest(CommandMigrationPending)
|
||||
tooBroad.Migrations[0].Commands = append(tooBroad.Migrations[0].Commands, "dws edu-app task missing")
|
||||
if _, err := AuthorizeCommandMigrations(base, references, empty, tooBroad); err == nil ||
|
||||
!strings.Contains(err.Error(), "do not exactly match") {
|
||||
t.Fatalf("absent reviewed command error=%v", err)
|
||||
}
|
||||
|
||||
mutatedCurrent := productRetirementSnapshot("mutated")
|
||||
if _, err := AuthorizeCommandMigrations(mutatedCurrent, references, empty, pending); err == nil ||
|
||||
!strings.Contains(err.Error(), "changed the product surface in its approval PR") {
|
||||
t.Fatalf("candidate pending surface mutation error=%v", err)
|
||||
}
|
||||
|
||||
migration := pending.Migrations[0]
|
||||
missingRoot := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws edu-app task"),
|
||||
testCommand("dws edu-app task list"),
|
||||
)
|
||||
if phase := matchProductRetirementPhase(missingRoot, migration); phase != commandMigrationPartial {
|
||||
t.Fatalf("missing retirement root phase=%s, want %s", phase, commandMigrationPartial)
|
||||
}
|
||||
wrongRootState := productRetirementSnapshot("full")
|
||||
for index := range wrongRootState.Commands {
|
||||
if wrongRootState.Commands[index].Path == migration.Legacy.Command {
|
||||
wrongRootState.Commands[index].Hidden = false
|
||||
}
|
||||
}
|
||||
if phase := matchProductRetirementPhase(wrongRootState, migration); phase != commandMigrationPartial {
|
||||
t.Fatalf("wrong retirement root state phase=%s, want %s", phase, commandMigrationPartial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagExtractionRequiresReplacementConstantEvidence(t *testing.T) {
|
||||
before := commandMigrationSnapshot(false, false)
|
||||
after := commandMigrationSnapshot(true, false)
|
||||
@@ -834,6 +1067,8 @@ func flagExtractionCommandMigrationManifest(state string) CommandMigrationManife
|
||||
|
||||
func cloneCommandMigration(source CommandMigration) CommandMigration {
|
||||
cloned := source
|
||||
cloned.Commands = append([]string(nil), source.Commands...)
|
||||
cloned.Schema.Tools = append([]string(nil), source.Schema.Tools...)
|
||||
if source.Schema.Parameters != nil {
|
||||
cloned.Schema.Parameters = make([]CommandParameterMigration, len(source.Schema.Parameters))
|
||||
copy(cloned.Schema.Parameters, source.Schema.Parameters)
|
||||
@@ -848,6 +1083,65 @@ func cloneCommandMigration(source CommandMigration) CommandMigration {
|
||||
return cloned
|
||||
}
|
||||
|
||||
func productRetirementManifest(state string) CommandMigrationManifest {
|
||||
return CommandMigrationManifest{
|
||||
Version: CommandMigrationManifestVersion,
|
||||
Migrations: []CommandMigration{productRetirementCommandMigration(state)},
|
||||
}
|
||||
}
|
||||
|
||||
func productRetirementCommandMigration(state string) CommandMigration {
|
||||
return CommandMigration{
|
||||
Kind: CommandMigrationProductRetirement,
|
||||
Legacy: CommandMigrationSide{
|
||||
Command: "dws edu-app",
|
||||
Before: CommandMigrationState{Present: true, Runnable: true, Hidden: true},
|
||||
},
|
||||
Commands: []string{
|
||||
"dws edu-app",
|
||||
"dws edu-app task",
|
||||
"dws edu-app task list",
|
||||
},
|
||||
Schema: CommandMigrationSchema{
|
||||
ProductID: "edu-app",
|
||||
Tools: []string{
|
||||
"edu-app.query_all_task",
|
||||
"edu-app.query_publish_task",
|
||||
},
|
||||
},
|
||||
State: state,
|
||||
Reason: "Retire the reviewed education application product surface.",
|
||||
}
|
||||
}
|
||||
|
||||
func productRetirementSnapshot(variant string) Snapshot {
|
||||
root := testCommandWithAliases("dws edu-app", []string{"education-app"})
|
||||
root.Hidden = true
|
||||
commands := []Command{
|
||||
testCommand("dws"),
|
||||
root,
|
||||
testCommandWithAliases("dws edu-app task", []string{"job"}),
|
||||
testCommandWithAliases("dws edu-app task list", []string{"ls"}, Flag{Name: "status", Type: "string"}),
|
||||
testCommand("dws other list"),
|
||||
}
|
||||
switch variant {
|
||||
case "full":
|
||||
case "stable-subset":
|
||||
commands = append(commands[:3], commands[4:]...)
|
||||
case "absent":
|
||||
commands = []Command{commands[0], commands[4]}
|
||||
case "partial":
|
||||
commands = []Command{commands[0], root, commands[4]}
|
||||
case "unlisted-child":
|
||||
commands = append(commands, testCommand("dws edu-app task secret"))
|
||||
case "mutated":
|
||||
commands[3].LocalFlags[0].Required = true
|
||||
default:
|
||||
panic("unknown product retirement snapshot variant: " + variant)
|
||||
}
|
||||
return testSnapshot(commands...)
|
||||
}
|
||||
|
||||
func singleCommandMigrationManifest(state string) CommandMigrationManifest {
|
||||
manifest := commandMigrationManifest(state)
|
||||
manifest.Migrations = manifest.Migrations[:1]
|
||||
|
||||
@@ -207,6 +207,17 @@ has_any_schema_command_migration_governance_artifact() {
|
||||
return 1
|
||||
}
|
||||
|
||||
base_supports_product_retirement() {
|
||||
grep -Fq \
|
||||
'productRetirementMigrationCapability = "dws.command-migration.product-retirement.v1"' \
|
||||
"$BASE_WORKTREE/$COMMAND_MIGRATIONS_REL"
|
||||
}
|
||||
|
||||
candidate_declares_product_retirement() {
|
||||
grep -Fq '"product_retirement"' \
|
||||
"$CANDIDATE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL"
|
||||
}
|
||||
|
||||
check_candidate_const_params_source_policy() {
|
||||
source_policy_failed=false
|
||||
for token in attachInterfaceBoolConstParams InterfaceBoolConstParams interfaceBoolConstParamsRegistry; do
|
||||
@@ -363,6 +374,11 @@ if has_complete_schema_command_migration_governance "$BASE_COMMIT"; then
|
||||
APPROVED_COMMAND_MANIFEST="$BASE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL"
|
||||
CANDIDATE_COMMAND_MANIFEST="$CANDIDATE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL"
|
||||
require_complete_candidate_schema_command_governance
|
||||
if candidate_declares_product_retirement && ! base_supports_product_retirement; then
|
||||
printf '%s\n' \
|
||||
'merge-base command migration authority does not support product_retirement; land capability, pending approval, and consumption in three separate PRs' >&2
|
||||
exit 2
|
||||
fi
|
||||
require_base_identical_command_migration_bridges
|
||||
elif has_any_schema_command_migration_governance_artifact "$BASE_COMMIT"; then
|
||||
printf 'error: merge-base contains an incomplete Schema command migration governance artifact set: %s\n' \
|
||||
@@ -407,6 +423,10 @@ CHECKER_SUPPORTS_MIGRATION_BASE_SCHEMA=false
|
||||
if "$CHECKER" --help 2>&1 | grep -Fq -- 'migration-base-schema'; then
|
||||
CHECKER_SUPPORTS_MIGRATION_BASE_SCHEMA=true
|
||||
fi
|
||||
CHECKER_SUPPORTS_MIGRATION_STABLE_SCHEMA=false
|
||||
if "$CHECKER" --help 2>&1 | grep -Fq -- 'migration-stable-schema'; then
|
||||
CHECKER_SUPPORTS_MIGRATION_STABLE_SCHEMA=true
|
||||
fi
|
||||
|
||||
mkdir -p "$TMP_ROOT/base-home" "$TMP_ROOT/stable-home" "$TMP_ROOT/candidate-home"
|
||||
HOME="$TMP_ROOT/base-home" DWS_LANG=zh \
|
||||
@@ -488,6 +508,10 @@ check_with_migrations() {
|
||||
[ "$CHECKER_SUPPORTS_MIGRATION_BASE_SCHEMA" = true ]; then
|
||||
set -- "$@" --migration-base-schema "$BASELINE"
|
||||
fi
|
||||
if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ] &&
|
||||
[ "$CHECKER_SUPPORTS_MIGRATION_STABLE_SCHEMA" = true ]; then
|
||||
set -- "$@" --migration-stable-schema "$STABLE_BASELINE"
|
||||
fi
|
||||
check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" "$@"
|
||||
}
|
||||
|
||||
|
||||
@@ -200,6 +200,17 @@ has_any_command_migration_governance_artifact() {
|
||||
return 1
|
||||
}
|
||||
|
||||
base_supports_product_retirement() {
|
||||
grep -Fq \
|
||||
'productRetirementMigrationCapability = "dws.command-migration.product-retirement.v1"' \
|
||||
"$BASE_WORKTREE/$COMMAND_MIGRATIONS_REL"
|
||||
}
|
||||
|
||||
candidate_declares_product_retirement() {
|
||||
grep -Fq '"product_retirement"' \
|
||||
"$CANDIDATE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL"
|
||||
}
|
||||
|
||||
check_candidate_const_params_source_policy() {
|
||||
source_policy_failed=false
|
||||
for token in attachInterfaceBoolConstParams InterfaceBoolConstParams interfaceBoolConstParamsRegistry; do
|
||||
@@ -336,6 +347,11 @@ if has_complete_command_migration_governance "$BASE_COMMIT"; then
|
||||
APPROVED_COMMAND_MANIFEST="$BASE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL"
|
||||
CANDIDATE_COMMAND_MANIFEST="$CANDIDATE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL"
|
||||
require_complete_candidate_command_governance
|
||||
if candidate_declares_product_retirement && ! base_supports_product_retirement; then
|
||||
printf '%s\n' \
|
||||
'merge-base command migration authority does not support product_retirement; land capability, pending approval, and consumption in three separate PRs' >&2
|
||||
exit 2
|
||||
fi
|
||||
require_base_identical_command_migration_bridges
|
||||
elif has_any_command_migration_governance_artifact "$BASE_COMMIT"; then
|
||||
printf 'merge-base contains an incomplete command migration governance artifact set: %s\n' \
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"example.com/dws-command-compat-authority-test/internal/interfacesnapshot"
|
||||
)
|
||||
@@ -52,7 +53,7 @@ func main() {
|
||||
stable := flags.String("stable", "", "stable snapshot")
|
||||
_ = flags.String("approved-flag-migrations", "", "approved manifest")
|
||||
_ = flags.String("approved-command-migrations", "", "approved command manifest")
|
||||
_ = flags.String("candidate-command-migrations", "", "candidate command manifest")
|
||||
candidateCommandManifest := flags.String("candidate-command-migrations", "", "candidate command manifest")
|
||||
_ = flags.Parse(os.Args[2:])
|
||||
for _, path := range []string{*current, *base, *stable} {
|
||||
content, err := os.ReadFile(path)
|
||||
@@ -77,6 +78,17 @@ func main() {
|
||||
return
|
||||
}
|
||||
}
|
||||
if *candidateCommandManifest != "" {
|
||||
content, err := os.ReadFile(*candidateCommandManifest)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
if strings.Contains(string(content), "product_retirement") {
|
||||
fmt.Fprintln(os.Stderr, "LEGACY_COMMAND_PARSER_REACHED_PRODUCT_RETIREMENT")
|
||||
os.Exit(29)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "BASE_COMPARATOR_ENFORCED")
|
||||
os.Exit(23)
|
||||
default:
|
||||
@@ -202,13 +214,15 @@ func main() {
|
||||
`
|
||||
|
||||
type authorityScenario struct {
|
||||
name string
|
||||
baseGovernance string
|
||||
commandGovernance string
|
||||
commandManifestChanged bool
|
||||
candidateMutation string
|
||||
want string
|
||||
wantAuthorityDecision bool
|
||||
name string
|
||||
baseGovernance string
|
||||
commandGovernance string
|
||||
commandManifestChanged bool
|
||||
productRetirementCapability bool
|
||||
productRetirementCandidate bool
|
||||
candidateMutation string
|
||||
want string
|
||||
wantAuthorityDecision bool
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandCompatibilityUsesBaseOwnedAuthority(t *testing.T) {
|
||||
@@ -295,6 +309,21 @@ func TestCrossPlatformCoverageCommandCompatibilityUsesBaseOwnedAuthority(t *test
|
||||
commandManifestChanged: true,
|
||||
wantAuthorityDecision: true,
|
||||
},
|
||||
{
|
||||
name: "product retirement mechanism can land with an empty ledger",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "complete",
|
||||
productRetirementCapability: true,
|
||||
wantAuthorityDecision: true,
|
||||
},
|
||||
{
|
||||
name: "first product retirement kind requires three separate PRs",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "complete",
|
||||
productRetirementCapability: true,
|
||||
productRetirementCandidate: true,
|
||||
want: "land capability, pending approval, and consumption in three separate PRs",
|
||||
},
|
||||
{
|
||||
name: "unchanged command manifest allows independent corecmd change",
|
||||
baseGovernance: "complete",
|
||||
@@ -468,7 +497,11 @@ func runBaseOwnedAuthorityCase(t *testing.T, test authorityScenario) {
|
||||
)
|
||||
}
|
||||
if test.commandGovernance == "complete" || test.commandGovernance == "bootstrap" {
|
||||
writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644)
|
||||
commandMigrationsSource := "package interfacesnapshot\n"
|
||||
if test.productRetirementCapability {
|
||||
commandMigrationsSource += "\nconst productRetirementMigrationCapability = \"dws.command-migration.product-retirement.v1\"\n"
|
||||
}
|
||||
writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), commandMigrationsSource, 0o644)
|
||||
if test.commandGovernance == "bootstrap" {
|
||||
writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), constParamsRegistrySource("CANDIDATE"), 0o644)
|
||||
}
|
||||
@@ -476,6 +509,9 @@ func runBaseOwnedAuthorityCase(t *testing.T, test authorityScenario) {
|
||||
if test.commandManifestChanged {
|
||||
commandManifest = "{\"version\":1,\"migrations\":[{\"candidate\":\"PENDING\"}]}\n"
|
||||
}
|
||||
if test.productRetirementCandidate {
|
||||
commandManifest = "{\"version\":1,\"migrations\":[{\"kind\":\"product_retirement\"}]}\n"
|
||||
}
|
||||
writeFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), commandManifest, 0o644)
|
||||
}
|
||||
switch test.candidateMutation {
|
||||
@@ -560,6 +596,9 @@ func runBaseOwnedAuthorityCase(t *testing.T, test authorityScenario) {
|
||||
if strings.Contains(got, "UNCOMMITTED_MANIFEST_USED") {
|
||||
t.Fatalf("compatibility script mixed the live manifest with the committed candidate surface; output:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "LEGACY_COMMAND_PARSER_REACHED_PRODUCT_RETIREMENT") {
|
||||
t.Fatalf("legacy command parser received a first-use product retirement record; output:\n%s", got)
|
||||
}
|
||||
if test.wantAuthorityDecision {
|
||||
if count := strings.Count(got, "BASE_HELPER_GENERATE=BASE"); count != 3 {
|
||||
t.Fatalf("compatibility script generated %d/3 snapshots with the base-owned internal helper; output:\n%s", count, got)
|
||||
|
||||
@@ -5,8 +5,10 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -384,6 +386,262 @@ func TestCrossPlatformCoverageSchemaCommandMigrationNormalizationEdges(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaProductRetirementExactDualBaselineScope(t *testing.T) {
|
||||
base := schemaProductRetirementContract(
|
||||
"edu-app.query_all_task",
|
||||
"edu-app.query_publish_task",
|
||||
)
|
||||
stable := schemaProductRetirementContract("edu-app.query_all_task")
|
||||
current := cloneContract(base)
|
||||
delete(current.Products, "edu-app")
|
||||
migration := schemaProductRetirementMigration(interfacesnapshot.CommandMigrationPending)
|
||||
authority := interfacesnapshot.CommandMigrationManifest{
|
||||
Version: interfacesnapshot.CommandMigrationManifestVersion,
|
||||
Migrations: []interfacesnapshot.CommandMigration{migration},
|
||||
}
|
||||
candidate := authority
|
||||
candidate.Migrations = append([]interfacesnapshot.CommandMigration(nil), authority.Migrations...)
|
||||
candidate.Migrations[0].State = interfacesnapshot.CommandMigrationConsumed
|
||||
|
||||
if err := validateSchemaProductRetirementScopes(base, stable, current, authority, candidate); err != nil {
|
||||
t.Fatalf("exact base/stable product retirement scope: %v", err)
|
||||
}
|
||||
normalized, err := normalizeSchemaCommandMigrations(base, current, []interfacesnapshot.CommandMigration{migration})
|
||||
if err != nil {
|
||||
t.Fatalf("normalize base product retirement: %v", err)
|
||||
}
|
||||
if _, exists := normalized.Products["edu-app"]; exists {
|
||||
t.Fatal("normalized base retained retired product")
|
||||
}
|
||||
if !reflect.DeepEqual(normalized.Products["doc"], base.Products["doc"]) {
|
||||
t.Fatal("product retirement changed an unrelated Schema product")
|
||||
}
|
||||
if failures := checkCompatibility(normalized, current); len(failures) != 0 {
|
||||
t.Fatalf("retired product remained incompatible: %v", failures)
|
||||
}
|
||||
|
||||
stableAbsent := schemaProductRetirementContract()
|
||||
if err := validateSchemaProductRetirementScopes(base, stableAbsent, current, authority, candidate); err != nil {
|
||||
t.Fatalf("stable baseline without product should be allowed: %v", err)
|
||||
}
|
||||
|
||||
unlisted := cloneContract(stable)
|
||||
product := unlisted.Products["edu-app"]
|
||||
product.Tools["edu-app.unreviewed"] = product.Tools["edu-app.query_all_task"]
|
||||
unlisted.Products["edu-app"] = product
|
||||
if err := validateSchemaProductRetirementScopes(base, unlisted, current, authority, candidate); err == nil ||
|
||||
!strings.Contains(err.Error(), "unreviewed tool") {
|
||||
t.Fatalf("unlisted stable tool error=%v", err)
|
||||
}
|
||||
|
||||
tooBroad := migration
|
||||
tooBroad.Schema.Tools = append(append([]string(nil), migration.Schema.Tools...), "edu-app.unpublished")
|
||||
tooBroadAuthority := authority
|
||||
tooBroadAuthority.Migrations = []interfacesnapshot.CommandMigration{tooBroad}
|
||||
if err := validateSchemaProductRetirementScopes(base, stable, current, tooBroadAuthority, candidate); err == nil ||
|
||||
!strings.Contains(err.Error(), "do not exactly match") {
|
||||
t.Fatalf("absent reviewed Schema tool error=%v", err)
|
||||
}
|
||||
|
||||
pendingCurrent := cloneContract(base)
|
||||
product = pendingCurrent.Products["edu-app"]
|
||||
tool := product.Tools["edu-app.query_all_task"]
|
||||
tool.Risk = "high"
|
||||
product.Tools["edu-app.query_all_task"] = tool
|
||||
pendingCurrent.Products["edu-app"] = product
|
||||
emptyAuthority := interfacesnapshot.CommandMigrationManifest{
|
||||
Version: interfacesnapshot.CommandMigrationManifestVersion,
|
||||
Migrations: []interfacesnapshot.CommandMigration{},
|
||||
}
|
||||
if err := validateSchemaProductRetirementScopes(base, stable, pendingCurrent, emptyAuthority, authority); err == nil ||
|
||||
!strings.Contains(err.Error(), "changed the Schema product in its approval PR") {
|
||||
t.Fatalf("candidate Schema self-approval error=%v", err)
|
||||
}
|
||||
|
||||
if _, err := normalizeSchemaCommandMigrations(base, base, []interfacesnapshot.CommandMigration{migration}); err == nil ||
|
||||
!strings.Contains(err.Error(), "still publishes the product") {
|
||||
t.Fatalf("still-published product normalization error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaProductRetirementScopeEdges(t *testing.T) {
|
||||
base := schemaProductRetirementContract(
|
||||
"edu-app.query_all_task",
|
||||
"edu-app.query_publish_task",
|
||||
)
|
||||
stable := schemaProductRetirementContract("edu-app.query_all_task")
|
||||
absent := schemaProductRetirementContract()
|
||||
pending := schemaProductRetirementMigration(interfacesnapshot.CommandMigrationPending)
|
||||
consumed := schemaProductRetirementMigration(interfacesnapshot.CommandMigrationConsumed)
|
||||
nonRetirement := interfacesnapshot.CommandMigration{Kind: interfacesnapshot.CommandMigrationMove}
|
||||
empty := interfacesnapshot.CommandMigrationManifest{
|
||||
Version: interfacesnapshot.CommandMigrationManifestVersion,
|
||||
Migrations: []interfacesnapshot.CommandMigration{},
|
||||
}
|
||||
|
||||
if err := validateSchemaProductRetirementScopes(
|
||||
base,
|
||||
stable,
|
||||
absent,
|
||||
interfacesnapshot.CommandMigrationManifest{Version: interfacesnapshot.CommandMigrationManifestVersion, Migrations: []interfacesnapshot.CommandMigration{nonRetirement}},
|
||||
interfacesnapshot.CommandMigrationManifest{Version: interfacesnapshot.CommandMigrationManifestVersion, Migrations: []interfacesnapshot.CommandMigration{nonRetirement}},
|
||||
); err != nil {
|
||||
t.Fatalf("non-retirement migrations must be ignored: %v", err)
|
||||
}
|
||||
|
||||
consumedAuthority := interfacesnapshot.CommandMigrationManifest{
|
||||
Version: interfacesnapshot.CommandMigrationManifestVersion,
|
||||
Migrations: []interfacesnapshot.CommandMigration{consumed},
|
||||
}
|
||||
if err := validateSchemaProductRetirementScopes(base, stable, absent, consumedAuthority, empty); err != nil {
|
||||
t.Fatalf("consumed retirement with reviewed historical subsets: %v", err)
|
||||
}
|
||||
if err := validateSchemaProductRetirementScopes(base, absent, absent, consumedAuthority, empty); err != nil {
|
||||
t.Fatalf("consumed retirement with absent stable product: %v", err)
|
||||
}
|
||||
|
||||
unreviewed := cloneContract(stable)
|
||||
product := unreviewed.Products["edu-app"]
|
||||
product.Tools["edu-app.unreviewed"] = product.Tools["edu-app.query_all_task"]
|
||||
unreviewed.Products["edu-app"] = product
|
||||
if err := validateSchemaProductRetirementScopes(base, unreviewed, absent, consumedAuthority, empty); err == nil ||
|
||||
!strings.Contains(err.Error(), "contains unreviewed tool") {
|
||||
t.Fatalf("consumed retirement unreviewed historical tool error=%v", err)
|
||||
}
|
||||
if err := validateSchemaProductRetirementScopes(base, stable, base, consumedAuthority, empty); err == nil ||
|
||||
!strings.Contains(err.Error(), "current Schema still publishes the product") {
|
||||
t.Fatalf("consumed authority still-published product error=%v", err)
|
||||
}
|
||||
|
||||
tooBroad := pending
|
||||
tooBroad.Schema.Tools = append(append([]string(nil), pending.Schema.Tools...), "edu-app.unpublished")
|
||||
tooBroadCandidate := interfacesnapshot.CommandMigrationManifest{
|
||||
Version: interfacesnapshot.CommandMigrationManifestVersion,
|
||||
Migrations: []interfacesnapshot.CommandMigration{tooBroad},
|
||||
}
|
||||
if err := validateSchemaProductRetirementScopes(base, stable, base, empty, tooBroadCandidate); err == nil ||
|
||||
!strings.Contains(err.Error(), "pending product retirement") {
|
||||
t.Fatalf("pending retirement invalid tool union error=%v", err)
|
||||
}
|
||||
|
||||
consumedCandidate := interfacesnapshot.CommandMigrationManifest{
|
||||
Version: interfacesnapshot.CommandMigrationManifestVersion,
|
||||
Migrations: []interfacesnapshot.CommandMigration{consumed},
|
||||
}
|
||||
if err := validateSchemaProductRetirementScopes(base, stable, base, empty, consumedCandidate); err == nil ||
|
||||
!strings.Contains(err.Error(), "current Schema still publishes the product") {
|
||||
t.Fatalf("consumed candidate still-published product error=%v", err)
|
||||
}
|
||||
|
||||
if normalized, err := normalizeSchemaCommandMigrations(absent, absent, []interfacesnapshot.CommandMigration{consumed}); err != nil ||
|
||||
!reflect.DeepEqual(normalized, absent) {
|
||||
t.Fatalf("retirement absent from historical baseline normalized=%#v error=%v", normalized, err)
|
||||
}
|
||||
narrow := consumed
|
||||
narrow.Schema.Tools = []string{"edu-app.query_all_task"}
|
||||
if _, err := normalizeSchemaCommandMigrations(base, absent, []interfacesnapshot.CommandMigration{narrow}); err == nil ||
|
||||
!strings.Contains(err.Error(), "historical Schema contains unreviewed tool") {
|
||||
t.Fatalf("normalization unreviewed historical tool error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaProductRetirementRunRequiresBothHistoricalContracts(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
baselinePath := filepath.Join(directory, "stable-schema.json")
|
||||
baseSchemaPath := filepath.Join(directory, "base-schema.json")
|
||||
stableSchemaPath := filepath.Join(directory, "stable-lineage-schema.json")
|
||||
currentPath := filepath.Join(directory, "current-schema.json")
|
||||
approvedPath := filepath.Join(directory, "approved-commands.json")
|
||||
candidatePath := filepath.Join(directory, "candidate-commands.json")
|
||||
currentSnapshotPath := filepath.Join(directory, "current-snapshot.json")
|
||||
baseSnapshotPath := filepath.Join(directory, "base-snapshot.json")
|
||||
stableSnapshotPath := filepath.Join(directory, "stable-snapshot.json")
|
||||
|
||||
base := schemaProductRetirementContract(
|
||||
"edu-app.query_all_task",
|
||||
"edu-app.query_publish_task",
|
||||
)
|
||||
stable := schemaProductRetirementContract("edu-app.query_all_task")
|
||||
current := cloneContract(base)
|
||||
delete(current.Products, "edu-app")
|
||||
writeSchemaContractFile(t, baselinePath, stable)
|
||||
writeSchemaContractFile(t, baseSchemaPath, base)
|
||||
writeSchemaContractFile(t, stableSchemaPath, stable)
|
||||
writeRawSchemaContractFile(t, currentPath, current)
|
||||
writeSchemaProductRetirementManifestFile(t, approvedPath, interfacesnapshot.CommandMigrationPending)
|
||||
writeSchemaProductRetirementManifestFile(t, candidatePath, interfacesnapshot.CommandMigrationConsumed)
|
||||
writeInterfaceSnapshotFile(t, currentSnapshotPath, schemaProductRetirementSnapshot("absent"))
|
||||
writeInterfaceSnapshotFile(t, baseSnapshotPath, schemaProductRetirementSnapshot("full"))
|
||||
writeInterfaceSnapshotFile(t, stableSnapshotPath, schemaProductRetirementSnapshot("stable-subset"))
|
||||
|
||||
args := []string{
|
||||
"--check", baselinePath,
|
||||
"--current", currentPath,
|
||||
"--migration-base-schema", baseSchemaPath,
|
||||
"--migration-stable-schema", stableSchemaPath,
|
||||
"--approved-command-migrations", approvedPath,
|
||||
"--candidate-command-migrations", candidatePath,
|
||||
"--migration-current-snapshot", currentSnapshotPath,
|
||||
"--migration-base-snapshot", baseSnapshotPath,
|
||||
"--migration-stable-snapshot", stableSnapshotPath,
|
||||
}
|
||||
var stdout, stderr strings.Builder
|
||||
if code := run(args, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("product retirement run code=%d stderr=%s", code, stderr.String())
|
||||
}
|
||||
|
||||
withoutBaseSchema := append([]string(nil), args[:4]...)
|
||||
withoutBaseSchema = append(withoutBaseSchema, args[6:]...)
|
||||
stderr.Reset()
|
||||
if code := run(withoutBaseSchema, &stdout, &stderr); code != 2 ||
|
||||
!strings.Contains(stderr.String(), "--migration-stable-schema requires") {
|
||||
t.Fatalf("stable Schema without base code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
|
||||
stderr.Reset()
|
||||
if code := run([]string{
|
||||
"--check", baselinePath,
|
||||
"--current", currentPath,
|
||||
"--migration-stable-schema", stableSchemaPath,
|
||||
}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "--migration-stable-schema requires") {
|
||||
t.Fatalf("stable Schema without command manifests code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
|
||||
missingBaseArgs := append([]string(nil), args...)
|
||||
missingBaseArgs[5] = filepath.Join(directory, "missing-base-schema.json")
|
||||
stderr.Reset()
|
||||
if code := run(missingBaseArgs, &stdout, &stderr); code != 2 ||
|
||||
!strings.Contains(stderr.String(), "read product retirement merge-base Schema contract") {
|
||||
t.Fatalf("missing retirement base Schema code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
|
||||
missingStableArgs := append([]string(nil), args...)
|
||||
missingStableArgs[7] = filepath.Join(directory, "missing-stable-schema.json")
|
||||
stderr.Reset()
|
||||
if code := run(missingStableArgs, &stdout, &stderr); code != 2 ||
|
||||
!strings.Contains(stderr.String(), "read product retirement stable Schema contract") {
|
||||
t.Fatalf("missing retirement stable Schema code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
|
||||
stillPublishedPath := filepath.Join(directory, "still-published-current-schema.json")
|
||||
writeRawSchemaContractFile(t, stillPublishedPath, base)
|
||||
invalidScopeArgs := append([]string(nil), args...)
|
||||
invalidScopeArgs[3] = stillPublishedPath
|
||||
stderr.Reset()
|
||||
if code := run(invalidScopeArgs, &stdout, &stderr); code != 2 ||
|
||||
!strings.Contains(stderr.String(), "validate Schema product retirement scope") {
|
||||
t.Fatalf("invalid retirement scope code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
|
||||
withoutStableSchema := append([]string(nil), args[:6]...)
|
||||
withoutStableSchema = append(withoutStableSchema, args[8:]...)
|
||||
stderr.Reset()
|
||||
if code := run(withoutStableSchema, &stdout, &stderr); code != 2 ||
|
||||
!strings.Contains(stderr.String(), "requires merge-base and stable Schema contracts") {
|
||||
t.Fatalf("missing stable Schema lineage code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaFlagExtractionRequiresCompleteReplacementProjection(t *testing.T) {
|
||||
t.Run("missing historical mapping", func(t *testing.T) {
|
||||
baseline := schemaCommandMigrationContract(false)
|
||||
@@ -717,15 +975,38 @@ func TestCrossPlatformCoverageSchemaCommandMigrationLifecycleAndRun(t *testing.T
|
||||
t.Fatalf("command migration run code=%d stderr=%s", code, stderr.String())
|
||||
}
|
||||
|
||||
paths := []string{approvedPath, candidatePath, currentSnapshotPath, baseSnapshotPath, stableSnapshotPath}
|
||||
approved, err := readCommandMigrationManifestFile(approvedPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
candidate, err := readCommandMigrationManifestFile(candidatePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paths := []string{currentSnapshotPath, baseSnapshotPath, stableSnapshotPath}
|
||||
for index := range paths {
|
||||
invalid := append([]string(nil), paths...)
|
||||
invalid[index] = filepath.Join(directory, "missing.json")
|
||||
if _, err := authorizeSchemaCommandMigrations(invalid[0], invalid[1], invalid[2], invalid[3], invalid[4]); err == nil {
|
||||
if _, err := authorizeSchemaCommandMigrations(approved, candidate, invalid[0], invalid[1], invalid[2]); err == nil {
|
||||
t.Fatalf("missing command migration input %d was accepted", index)
|
||||
}
|
||||
}
|
||||
|
||||
missingApprovedArgs := append([]string(nil), args...)
|
||||
missingApprovedArgs[5] = filepath.Join(directory, "missing-approved.json")
|
||||
stderr.Reset()
|
||||
if code := run(missingApprovedArgs, &stdout, &stderr); code != 2 ||
|
||||
!strings.Contains(stderr.String(), "read approved Schema command migrations") {
|
||||
t.Fatalf("missing approved command manifest code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
missingCandidateArgs := append([]string(nil), args...)
|
||||
missingCandidateArgs[7] = filepath.Join(directory, "missing-candidate.json")
|
||||
stderr.Reset()
|
||||
if code := run(missingCandidateArgs, &stdout, &stderr); code != 2 ||
|
||||
!strings.Contains(stderr.String(), "read candidate Schema command migrations") {
|
||||
t.Fatalf("missing candidate command manifest code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
|
||||
stderr.Reset()
|
||||
if code := run([]string{"--check", baselinePath, "--current", currentPath, "--approved-command-migrations", approvedPath}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "both command manifests") {
|
||||
t.Fatalf("partial command pair code=%d stderr=%q", code, stderr.String())
|
||||
@@ -745,7 +1026,7 @@ func TestCrossPlatformCoverageSchemaCommandMigrationLifecycleAndRun(t *testing.T
|
||||
}
|
||||
|
||||
badArgs := append([]string(nil), args...)
|
||||
badArgs[5] = filepath.Join(directory, "missing-approved.json")
|
||||
badArgs[9] = filepath.Join(directory, "missing-current-snapshot.json")
|
||||
stderr.Reset()
|
||||
if code := run(badArgs, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "authorize Schema command migrations") {
|
||||
t.Fatalf("command authorization error code=%d stderr=%q", code, stderr.String())
|
||||
@@ -831,7 +1112,7 @@ func TestCrossPlatformCoverageSchemaCommandMigrationComposesHistoricalFlagLineag
|
||||
"--check", baselinePath,
|
||||
"--current", currentPath,
|
||||
"--migration-base-schema", baseSchemaPath,
|
||||
}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "requires both flag and command") {
|
||||
}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "requires the command migration manifest pair") {
|
||||
t.Fatalf("orphan migration base Schema code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
missingBaseSchema := append([]string(nil), args...)
|
||||
@@ -1659,3 +1940,113 @@ func writeCommandMigrationManifestFile(t *testing.T, path string, manifest inter
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func schemaProductRetirementMigration(state string) interfacesnapshot.CommandMigration {
|
||||
return interfacesnapshot.CommandMigration{
|
||||
Kind: interfacesnapshot.CommandMigrationProductRetirement,
|
||||
Legacy: interfacesnapshot.CommandMigrationSide{
|
||||
Command: "dws edu-app",
|
||||
Before: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true, Hidden: true},
|
||||
},
|
||||
Commands: []string{
|
||||
"dws edu-app",
|
||||
"dws edu-app task",
|
||||
"dws edu-app task list",
|
||||
},
|
||||
Schema: interfacesnapshot.CommandMigrationSchema{
|
||||
ProductID: "edu-app",
|
||||
Tools: []string{
|
||||
"edu-app.query_all_task",
|
||||
"edu-app.query_publish_task",
|
||||
},
|
||||
},
|
||||
State: state,
|
||||
Reason: "Retire the reviewed education application product surface.",
|
||||
}
|
||||
}
|
||||
|
||||
func schemaProductRetirementContract(toolIDs ...string) schemaContract {
|
||||
contract := cloneContract(baselineContract())
|
||||
if len(toolIDs) == 0 {
|
||||
return contract
|
||||
}
|
||||
tools := make(map[string]toolSchema, len(toolIDs))
|
||||
for _, toolID := range toolIDs {
|
||||
tools[toolID] = toolSchema{
|
||||
PrimaryCLIPath: "edu-app task list",
|
||||
InterfaceMode: "mcp",
|
||||
Availability: "available",
|
||||
Parameters: map[string]parameterSchema{},
|
||||
Effect: "read",
|
||||
Risk: "low",
|
||||
Confirmation: "not_required",
|
||||
Idempotency: "idempotent",
|
||||
}
|
||||
}
|
||||
contract.Products["edu-app"] = productSchema{Tools: tools}
|
||||
return contract
|
||||
}
|
||||
|
||||
func schemaProductRetirementSnapshot(variant string) interfacesnapshot.Snapshot {
|
||||
command := func(path string) interfacesnapshot.Command {
|
||||
return interfacesnapshot.Command{
|
||||
Path: path,
|
||||
Runnable: true,
|
||||
Aliases: []string{},
|
||||
LocalFlags: []interfacesnapshot.Flag{},
|
||||
InheritedFlags: []interfacesnapshot.Flag{},
|
||||
}
|
||||
}
|
||||
root := command("dws edu-app")
|
||||
root.Hidden = true
|
||||
commands := []interfacesnapshot.Command{
|
||||
command("dws"),
|
||||
root,
|
||||
command("dws edu-app task"),
|
||||
command("dws edu-app task list"),
|
||||
command("dws other"),
|
||||
}
|
||||
switch variant {
|
||||
case "full":
|
||||
case "stable-subset":
|
||||
commands = append(commands[:3], commands[4:]...)
|
||||
case "absent":
|
||||
commands = []interfacesnapshot.Command{commands[0], commands[4]}
|
||||
default:
|
||||
panic("unknown Schema product retirement snapshot variant: " + variant)
|
||||
}
|
||||
return interfacesnapshot.Snapshot{
|
||||
SchemaVersion: interfacesnapshot.SchemaVersion,
|
||||
Rules: interfacesnapshot.Rules{
|
||||
ExcludedCommandSubtrees: []string{"dws __complete", "dws __completeNoDesc", "dws completion", "dws help"},
|
||||
ExcludedFlags: []string{"help"},
|
||||
},
|
||||
Commands: commands,
|
||||
}
|
||||
}
|
||||
|
||||
func writeSchemaProductRetirementManifestFile(t *testing.T, path, state string) {
|
||||
t.Helper()
|
||||
body := fmt.Sprintf(`{
|
||||
"version": 1,
|
||||
"migrations": [{
|
||||
"kind": "product_retirement",
|
||||
"legacy": {
|
||||
"command": "dws edu-app",
|
||||
"before": {"present": true, "runnable": true, "hidden": true},
|
||||
"after": {"present": false}
|
||||
},
|
||||
"commands": ["dws edu-app", "dws edu-app task", "dws edu-app task list"],
|
||||
"schema": {
|
||||
"product_id": "edu-app",
|
||||
"tools": ["edu-app.query_all_task", "edu-app.query_publish_task"]
|
||||
},
|
||||
"state": %q,
|
||||
"reason": "Retire the reviewed education application product surface."
|
||||
}]
|
||||
}
|
||||
`, state)
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,11 @@ type schemaContract struct {
|
||||
Products map[string]productSchema `json:"products"`
|
||||
}
|
||||
|
||||
type schemaContractReference struct {
|
||||
label string
|
||||
contract schemaContract
|
||||
}
|
||||
|
||||
type productSchema struct {
|
||||
Tools map[string]toolSchema `json:"tools"`
|
||||
}
|
||||
@@ -119,7 +124,7 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
var normalizePath, checkPath, mergePath, currentPath string
|
||||
var approvedFlagMigrationsPath, candidateFlagMigrationsPath string
|
||||
var approvedCommandMigrationsPath, candidateCommandMigrationsPath string
|
||||
var migrationBaseSchemaPath string
|
||||
var migrationBaseSchemaPath, migrationStableSchemaPath string
|
||||
var migrationCurrentSnapshotPath, migrationBaseSnapshotPath, migrationStableSnapshotPath string
|
||||
flags := flag.NewFlagSet("schema-compat", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
@@ -132,6 +137,7 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
flags.StringVar(&approvedCommandMigrationsPath, "approved-command-migrations", "", "base-owned approved command migration manifest")
|
||||
flags.StringVar(&candidateCommandMigrationsPath, "candidate-command-migrations", "", "detached candidate command migration manifest")
|
||||
flags.StringVar(&migrationBaseSchemaPath, "migration-base-schema", "", "normalized merge-base Schema contract used to verify cross-migration lineage")
|
||||
flags.StringVar(&migrationStableSchemaPath, "migration-stable-schema", "", "normalized stable Schema contract used to verify product-retirement scope")
|
||||
flags.StringVar(&migrationCurrentSnapshotPath, "migration-current-snapshot", "", "current interface snapshot used for migration authorization")
|
||||
flags.StringVar(&migrationBaseSnapshotPath, "migration-base-snapshot", "", "merge-base interface snapshot used for migration authorization")
|
||||
flags.StringVar(&migrationStableSnapshotPath, "migration-stable-snapshot", "", "stable interface snapshot used for migration authorization")
|
||||
@@ -187,8 +193,12 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
fmt.Fprintln(stderr, "combined Schema flag and command migration authorization requires --migration-base-schema")
|
||||
return 2
|
||||
}
|
||||
if migrationBaseSchemaPath != "" && (!flagMigrationPair || !commandMigrationPair) {
|
||||
fmt.Fprintln(stderr, "--migration-base-schema requires both flag and command migration manifest pairs")
|
||||
if migrationBaseSchemaPath != "" && !commandMigrationPair {
|
||||
fmt.Fprintln(stderr, "--migration-base-schema requires the command migration manifest pair")
|
||||
return 2
|
||||
}
|
||||
if migrationStableSchemaPath != "" && (!commandMigrationPair || migrationBaseSchemaPath == "") {
|
||||
fmt.Fprintln(stderr, "--migration-stable-schema requires the command migration manifest pair and --migration-base-schema")
|
||||
return 2
|
||||
}
|
||||
|
||||
@@ -232,9 +242,19 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
}
|
||||
}
|
||||
if commandMigrationPair {
|
||||
approvedCommandManifest, readErr := readCommandMigrationManifestFile(approvedCommandMigrationsPath)
|
||||
if readErr != nil {
|
||||
fmt.Fprintf(stderr, "read approved Schema command migrations: %v\n", readErr)
|
||||
return 2
|
||||
}
|
||||
candidateCommandManifest, readErr := readCommandMigrationManifestFile(candidateCommandMigrationsPath)
|
||||
if readErr != nil {
|
||||
fmt.Fprintf(stderr, "read candidate Schema command migrations: %v\n", readErr)
|
||||
return 2
|
||||
}
|
||||
commandMigrations, err := authorizeSchemaCommandMigrations(
|
||||
approvedCommandMigrationsPath,
|
||||
candidateCommandMigrationsPath,
|
||||
approvedCommandManifest,
|
||||
candidateCommandManifest,
|
||||
migrationCurrentSnapshotPath,
|
||||
migrationBaseSnapshotPath,
|
||||
migrationStableSnapshotPath,
|
||||
@@ -243,6 +263,32 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
fmt.Fprintf(stderr, "authorize Schema command migrations: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
if commandMigrationManifestsContainProductRetirement(approvedCommandManifest, candidateCommandManifest) {
|
||||
if migrationBaseSchemaPath == "" || migrationStableSchemaPath == "" {
|
||||
fmt.Fprintln(stderr, "Schema product retirement authorization requires merge-base and stable Schema contracts")
|
||||
return 2
|
||||
}
|
||||
migrationBase, readErr := readContract(migrationBaseSchemaPath)
|
||||
if readErr != nil {
|
||||
fmt.Fprintf(stderr, "read product retirement merge-base Schema contract: %v\n", readErr)
|
||||
return 2
|
||||
}
|
||||
migrationStable, readErr := readContract(migrationStableSchemaPath)
|
||||
if readErr != nil {
|
||||
fmt.Fprintf(stderr, "read product retirement stable Schema contract: %v\n", readErr)
|
||||
return 2
|
||||
}
|
||||
if err := validateSchemaProductRetirementScopes(
|
||||
migrationBase,
|
||||
migrationStable,
|
||||
current,
|
||||
approvedCommandManifest,
|
||||
candidateCommandManifest,
|
||||
); err != nil {
|
||||
fmt.Fprintf(stderr, "validate Schema product retirement scope: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
if flagMigrationPair {
|
||||
migrationBase, readErr := readContract(migrationBaseSchemaPath)
|
||||
if readErr != nil {
|
||||
@@ -345,20 +391,12 @@ func readFlagMigrationManifestFile(path string) (interfacesnapshot.FlagMigration
|
||||
}
|
||||
|
||||
func authorizeSchemaCommandMigrations(
|
||||
approvedManifestPath string,
|
||||
candidateManifestPath string,
|
||||
approved interfacesnapshot.CommandMigrationManifest,
|
||||
candidate interfacesnapshot.CommandMigrationManifest,
|
||||
currentSnapshotPath string,
|
||||
baseSnapshotPath string,
|
||||
stableSnapshotPath string,
|
||||
) ([]interfacesnapshot.CommandMigration, error) {
|
||||
approved, err := readCommandMigrationManifestFile(approvedManifestPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read approved command migrations: %w", err)
|
||||
}
|
||||
candidate, err := readCommandMigrationManifestFile(candidateManifestPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read candidate command migrations: %w", err)
|
||||
}
|
||||
current, err := readInterfaceSnapshotFile(currentSnapshotPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migration current snapshot: %w", err)
|
||||
@@ -387,6 +425,120 @@ func readCommandMigrationManifestFile(path string) (interfacesnapshot.CommandMig
|
||||
return interfacesnapshot.ReadCommandMigrationManifest(bytes.NewReader(data))
|
||||
}
|
||||
|
||||
func commandMigrationManifestsContainProductRetirement(manifests ...interfacesnapshot.CommandMigrationManifest) bool {
|
||||
for _, manifest := range manifests {
|
||||
for _, migration := range manifest.Migrations {
|
||||
if migration.Kind == interfacesnapshot.CommandMigrationProductRetirement {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateSchemaProductRetirementScopes(
|
||||
mergeBase schemaContract,
|
||||
stable schemaContract,
|
||||
current schemaContract,
|
||||
authority interfacesnapshot.CommandMigrationManifest,
|
||||
candidate interfacesnapshot.CommandMigrationManifest,
|
||||
) error {
|
||||
references := []schemaContractReference{
|
||||
{label: "merge-base", contract: mergeBase},
|
||||
{label: "stable", contract: stable},
|
||||
}
|
||||
|
||||
for _, migration := range authority.Migrations {
|
||||
if migration.Kind != interfacesnapshot.CommandMigrationProductRetirement {
|
||||
continue
|
||||
}
|
||||
if migration.State == interfacesnapshot.CommandMigrationPending {
|
||||
if err := validateExactSchemaProductRetirementToolUnion(references, migration); err != nil {
|
||||
return fmt.Errorf("approved product retirement %q: %w", migration.Schema.ProductID, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, reference := range references {
|
||||
if err := validateSchemaProductRetirementToolSubset(reference.label, reference.contract, migration); err != nil {
|
||||
return fmt.Errorf("approved product retirement %q: %w", migration.Schema.ProductID, err)
|
||||
}
|
||||
}
|
||||
if _, exists := current.Products[migration.Schema.ProductID]; exists {
|
||||
return fmt.Errorf("consumed product retirement %q current Schema still publishes the product", migration.Schema.ProductID)
|
||||
}
|
||||
}
|
||||
|
||||
for _, migration := range candidate.Migrations {
|
||||
if migration.Kind != interfacesnapshot.CommandMigrationProductRetirement {
|
||||
continue
|
||||
}
|
||||
switch migration.State {
|
||||
case interfacesnapshot.CommandMigrationPending:
|
||||
if err := validateExactSchemaProductRetirementToolUnion(references, migration); err != nil {
|
||||
return fmt.Errorf("pending product retirement %q: %w", migration.Schema.ProductID, err)
|
||||
}
|
||||
baseProduct, baseExists := mergeBase.Products[migration.Schema.ProductID]
|
||||
currentProduct, currentExists := current.Products[migration.Schema.ProductID]
|
||||
if baseExists != currentExists || (baseExists && !reflect.DeepEqual(baseProduct, currentProduct)) {
|
||||
return fmt.Errorf("pending product retirement %q changed the Schema product in its approval PR", migration.Schema.ProductID)
|
||||
}
|
||||
case interfacesnapshot.CommandMigrationConsumed:
|
||||
if _, exists := current.Products[migration.Schema.ProductID]; exists {
|
||||
return fmt.Errorf("consumed product retirement %q current Schema still publishes the product", migration.Schema.ProductID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateExactSchemaProductRetirementToolUnion(
|
||||
references []schemaContractReference,
|
||||
migration interfacesnapshot.CommandMigration,
|
||||
) error {
|
||||
declared := make(map[string]bool, len(migration.Schema.Tools))
|
||||
for _, tool := range migration.Schema.Tools {
|
||||
declared[tool] = true
|
||||
}
|
||||
union := make(map[string]bool, len(declared))
|
||||
for _, reference := range references {
|
||||
product, exists := reference.contract.Products[migration.Schema.ProductID]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
for tool := range product.Tools {
|
||||
if !declared[tool] {
|
||||
return fmt.Errorf("%s Schema product contains unreviewed tool %q", reference.label, tool)
|
||||
}
|
||||
union[tool] = true
|
||||
}
|
||||
}
|
||||
if len(union) != len(declared) {
|
||||
return fmt.Errorf("reviewed tools do not exactly match the merge-base and stable Schema tool union")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSchemaProductRetirementToolSubset(
|
||||
label string,
|
||||
contract schemaContract,
|
||||
migration interfacesnapshot.CommandMigration,
|
||||
) error {
|
||||
product, exists := contract.Products[migration.Schema.ProductID]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
declared := make(map[string]bool, len(migration.Schema.Tools))
|
||||
for _, tool := range migration.Schema.Tools {
|
||||
declared[tool] = true
|
||||
}
|
||||
for tool := range product.Tools {
|
||||
if !declared[tool] {
|
||||
return fmt.Errorf("%s Schema product contains unreviewed tool %q", label, tool)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readInterfaceSnapshotFile(path string) (interfacesnapshot.Snapshot, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -1843,6 +1995,33 @@ func normalizeSchemaCommandMigrations(
|
||||
) (schemaContract, error) {
|
||||
normalized := cloneContract(baseline)
|
||||
for _, migration := range migrations {
|
||||
if migration.Kind == interfacesnapshot.CommandMigrationProductRetirement {
|
||||
oldProduct, productExists := baseline.Products[migration.Schema.ProductID]
|
||||
if !productExists {
|
||||
continue
|
||||
}
|
||||
if _, stillPublished := current.Products[migration.Schema.ProductID]; stillPublished {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved product retirement %q current Schema still publishes the product",
|
||||
migration.Schema.ProductID,
|
||||
)
|
||||
}
|
||||
declared := make(map[string]bool, len(migration.Schema.Tools))
|
||||
for _, tool := range migration.Schema.Tools {
|
||||
declared[tool] = true
|
||||
}
|
||||
for tool := range oldProduct.Tools {
|
||||
if !declared[tool] {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved product retirement %q historical Schema contains unreviewed tool %q",
|
||||
migration.Schema.ProductID,
|
||||
tool,
|
||||
)
|
||||
}
|
||||
}
|
||||
delete(normalized.Products, migration.Schema.ProductID)
|
||||
continue
|
||||
}
|
||||
oldProduct, productExists := baseline.Products[migration.Schema.ProductID]
|
||||
oldTool, toolExists := oldProduct.Tools[migration.Schema.SourceToolID]
|
||||
if !productExists || !toolExists {
|
||||
|
||||
@@ -174,6 +174,7 @@ func main() {
|
||||
baseSnapshot := flag.String("migration-base-snapshot", "", "base interface snapshot")
|
||||
stableSnapshot := flag.String("migration-stable-snapshot", "", "stable interface snapshot")
|
||||
migrationBaseSchema := flag.String("migration-base-schema", "", "merge-base Schema contract")
|
||||
migrationStableSchema := flag.String("migration-stable-schema", "", "stable Schema contract")
|
||||
approvedCommand := flag.String("approved-command-migrations", "", "base command ledger")
|
||||
candidateCommand := flag.String("candidate-command-migrations", "", "candidate command ledger")
|
||||
flag.Parse()
|
||||
@@ -221,6 +222,15 @@ func main() {
|
||||
os.Exit(2)
|
||||
}
|
||||
if commandPair {
|
||||
candidateCommandData, err := os.ReadFile(*candidateCommand)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
if strings.Contains(string(candidateCommandData), "product_retirement") {
|
||||
fmt.Fprintln(os.Stderr, "LEGACY_SCHEMA_PARSER_REACHED_PRODUCT_RETIREMENT")
|
||||
os.Exit(29)
|
||||
}
|
||||
data, err := os.ReadFile(*migrationBaseSchema)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
@@ -231,8 +241,18 @@ func main() {
|
||||
os.Exit(2)
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, "BASE_SCHEMA_LINEAGE=BASE_AUTHORITY")
|
||||
stableData, err := os.ReadFile(*migrationStableSchema)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
if !strings.Contains(string(stableData), "STABLE_RELEASE") {
|
||||
fmt.Fprintf(os.Stderr, "migration stable Schema is not stable-owned: %s\n", stableData)
|
||||
os.Exit(2)
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, "STABLE_SCHEMA_LINEAGE=STABLE_RELEASE")
|
||||
} else {
|
||||
if *migrationBaseSchema != "" {
|
||||
if *migrationBaseSchema != "" || *migrationStableSchema != "" {
|
||||
fmt.Fprintln(os.Stderr, "flag-only Schema check received command migration lineage")
|
||||
os.Exit(2)
|
||||
}
|
||||
@@ -424,14 +444,16 @@ func main() {
|
||||
}
|
||||
|
||||
type authorityScenario struct {
|
||||
name string
|
||||
baseGovernance string
|
||||
commandGovernance string
|
||||
commandManifestChanged bool
|
||||
candidateMutation string
|
||||
checkerMode string
|
||||
want string
|
||||
authorityMarker string
|
||||
name string
|
||||
baseGovernance string
|
||||
commandGovernance string
|
||||
commandManifestChanged bool
|
||||
productRetirementCapability bool
|
||||
productRetirementCandidate bool
|
||||
candidateMutation string
|
||||
checkerMode string
|
||||
want string
|
||||
authorityMarker string
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaCompatibilityUsesBaseOwnedAuthority(t *testing.T) {
|
||||
@@ -531,6 +553,22 @@ func TestCrossPlatformCoverageSchemaCompatibilityUsesBaseOwnedAuthority(t *testi
|
||||
commandManifestChanged: true,
|
||||
authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED",
|
||||
},
|
||||
{
|
||||
name: "Schema product retirement mechanism can land with an empty ledger",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "complete",
|
||||
productRetirementCapability: true,
|
||||
authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED",
|
||||
},
|
||||
{
|
||||
name: "first Schema product retirement kind requires three separate PRs",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "complete",
|
||||
checkerMode: "stable-schema-guard",
|
||||
productRetirementCapability: true,
|
||||
productRetirementCandidate: true,
|
||||
want: "land capability, pending approval, and consumption in three separate PRs",
|
||||
},
|
||||
{
|
||||
name: "unchanged command manifest allows independent corecmd change",
|
||||
baseGovernance: "complete",
|
||||
@@ -707,7 +745,11 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) {
|
||||
}
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"), candidateLedger, 0o644)
|
||||
if test.commandGovernance == "complete" || test.commandGovernance == "bootstrap" {
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644)
|
||||
commandMigrationsSource := "package interfacesnapshot\n"
|
||||
if test.productRetirementCapability {
|
||||
commandMigrationsSource += "\nconst productRetirementMigrationCapability = \"dws.command-migration.product-retirement.v1\"\n"
|
||||
}
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), commandMigrationsSource, 0o644)
|
||||
if test.commandGovernance == "bootstrap" {
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), schemaConstParamsRegistrySource("CANDIDATE"), 0o644)
|
||||
}
|
||||
@@ -715,6 +757,9 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) {
|
||||
if test.commandManifestChanged {
|
||||
commandManifest = "{\"version\":1,\"migrations\":[{\"candidate\":\"PENDING\"}]}\n"
|
||||
}
|
||||
if test.productRetirementCandidate {
|
||||
commandManifest = "{\"version\":1,\"migrations\":[{\"kind\":\"product_retirement\"}]}\n"
|
||||
}
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), commandManifest, 0o644)
|
||||
}
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "SNAPSHOT_MARKER"), "COMMITTED_CANDIDATE\n", 0o644)
|
||||
@@ -813,7 +858,7 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) {
|
||||
if runErr == nil {
|
||||
t.Fatalf("Schema compatibility script unexpectedly succeeded; output:\n%s", got)
|
||||
}
|
||||
for _, forbidden := range []string{"CANDIDATE_NOOP", "LIVE_WORKTREE_BINARY"} {
|
||||
for _, forbidden := range []string{"CANDIDATE_NOOP", "LIVE_WORKTREE_BINARY", "LEGACY_SCHEMA_PARSER_REACHED_PRODUCT_RETIREMENT"} {
|
||||
if strings.Contains(got, forbidden) {
|
||||
t.Fatalf("candidate-controlled authority %q took effect; output:\n%s", forbidden, got)
|
||||
}
|
||||
@@ -828,6 +873,9 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) {
|
||||
if test.checkerMode == "stable-schema-guard" && strings.Count(got, "BASE_SCHEMA_LINEAGE=BASE_AUTHORITY") != 2 {
|
||||
t.Fatalf("governed Schema check did not pass the base-owned Schema lineage to both historical checks; output:\n%s", got)
|
||||
}
|
||||
if test.checkerMode == "stable-schema-guard" && strings.Count(got, "STABLE_SCHEMA_LINEAGE=STABLE_RELEASE") != 2 {
|
||||
t.Fatalf("governed Schema check did not pass the stable Schema lineage to both historical checks; output:\n%s", got)
|
||||
}
|
||||
if test.checkerMode == "flag-only-schema-guard" && strings.Count(got, "FLAG_ONLY_SCHEMA_LINEAGE_OMITTED") != 2 {
|
||||
t.Fatalf("flag-only Schema check received command migration lineage; output:\n%s", got)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user