ci: govern command path migrations
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **CLI compatibility governance** — adds a reviewed two-stage path for hiding retained legacy commands or flags from Help and Schema when capability moves to a dedicated command.
|
||||
+2
-2
@@ -74,9 +74,9 @@ coverage is additionally selected for platform-sensitive code.
|
||||
`make authoritative-interface-integrity BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`.
|
||||
The Make target delegates to the authoritative wrapper; CI does not invoke a
|
||||
second comparator or the legacy fixture checker. See
|
||||
[CLI flag compatibility migration governance](docs/cli-interface-flag-migrations.md)
|
||||
[CLI Help / Schema compatibility migration governance](docs/cli-interface-flag-migrations.md)
|
||||
for the reviewed two-stage `pending` → `consumed` lifecycle.
|
||||
Agent-visible flag migrations must also run
|
||||
Agent-visible flag or command-path migrations must also run
|
||||
`make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`;
|
||||
it consumes the same base-owned ledger rather than a second exception list.
|
||||
5. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may
|
||||
|
||||
@@ -157,6 +157,16 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
"",
|
||||
"candidate flag migration manifest",
|
||||
)
|
||||
approvedCommandMigrationsPath := flags.String(
|
||||
"approved-command-migrations",
|
||||
"",
|
||||
"merge-base-owned approved command migration manifest",
|
||||
)
|
||||
candidateCommandMigrationsPath := flags.String(
|
||||
"candidate-command-migrations",
|
||||
"",
|
||||
"candidate command migration manifest",
|
||||
)
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -174,8 +184,13 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
"--approved-flag-migrations and --candidate-flag-migrations must be provided together",
|
||||
)
|
||||
}
|
||||
if *approvedMigrationsPath != "" && (*basePath == "" || *stablePath == "") {
|
||||
return false, fmt.Errorf("flag migration compare requires both --base and --stable")
|
||||
if (*approvedCommandMigrationsPath == "") != (*candidateCommandMigrationsPath == "") {
|
||||
return false, fmt.Errorf(
|
||||
"--approved-command-migrations and --candidate-command-migrations must be provided together",
|
||||
)
|
||||
}
|
||||
if (*approvedMigrationsPath != "" || *approvedCommandMigrationsPath != "") && (*basePath == "" || *stablePath == "") {
|
||||
return false, fmt.Errorf("migration compare requires both --base and --stable")
|
||||
}
|
||||
|
||||
current, err := readSnapshot(*currentPath)
|
||||
@@ -197,7 +212,39 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
}
|
||||
|
||||
report := interfacesnapshot.CompareAll(current, references)
|
||||
if *approvedMigrationsPath != "" {
|
||||
if *approvedCommandMigrationsPath != "" {
|
||||
flagApproved := interfacesnapshot.FlagMigrationManifest{Version: interfacesnapshot.FlagMigrationManifestVersion, Migrations: []interfacesnapshot.FlagMigration{}}
|
||||
flagCandidate := flagApproved
|
||||
if *approvedMigrationsPath != "" {
|
||||
flagApproved, err = readFlagMigrationManifest(*approvedMigrationsPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read approved flag migrations: %w", err)
|
||||
}
|
||||
flagCandidate, err = readFlagMigrationManifest(*candidateMigrationsPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read candidate flag migrations: %w", err)
|
||||
}
|
||||
}
|
||||
commandApproved, readErr := readCommandMigrationManifest(*approvedCommandMigrationsPath)
|
||||
if readErr != nil {
|
||||
return false, fmt.Errorf("read approved command migrations: %w", readErr)
|
||||
}
|
||||
commandCandidate, readErr := readCommandMigrationManifest(*candidateCommandMigrationsPath)
|
||||
if readErr != nil {
|
||||
return false, fmt.Errorf("read candidate command migrations: %w", readErr)
|
||||
}
|
||||
report, err = interfacesnapshot.CompareAllWithInterfaceMigrations(
|
||||
current,
|
||||
references,
|
||||
flagApproved,
|
||||
flagCandidate,
|
||||
commandApproved,
|
||||
commandCandidate,
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("validate interface migration lifecycle: %w", err)
|
||||
}
|
||||
} else if *approvedMigrationsPath != "" {
|
||||
approved, readErr := readFlagMigrationManifest(*approvedMigrationsPath)
|
||||
if readErr != nil {
|
||||
return false, fmt.Errorf("read approved flag migrations: %w", readErr)
|
||||
@@ -234,6 +281,15 @@ func readFlagMigrationManifest(path string) (interfacesnapshot.FlagMigrationMani
|
||||
return interfacesnapshot.ReadFlagMigrationManifest(file)
|
||||
}
|
||||
|
||||
func readCommandMigrationManifest(path string) (interfacesnapshot.CommandMigrationManifest, error) {
|
||||
file, err := os.Open(filepath.Clean(path))
|
||||
if err != nil {
|
||||
return interfacesnapshot.CommandMigrationManifest{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
return interfacesnapshot.ReadCommandMigrationManifest(file)
|
||||
}
|
||||
|
||||
func validateHelpRendering(root *cobra.Command, snapshot interfacesnapshot.Snapshot) error {
|
||||
for _, command := range snapshot.Commands {
|
||||
path := strings.TrimPrefix(command.Path, "dws")
|
||||
@@ -280,5 +336,5 @@ func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
|
||||
func printUsage(w io.Writer) {
|
||||
fmt.Fprintln(w, "usage:")
|
||||
fmt.Fprintln(w, " interface-snapshot generate [--output FILE]")
|
||||
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE]")
|
||||
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE] [--approved-command-migrations FILE --candidate-command-migrations FILE]")
|
||||
}
|
||||
|
||||
@@ -166,6 +166,102 @@ func TestCrossPlatformCoverageRunCompareRequiresBothFlagMigrationInputs(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareCommandMigrationInputs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
|
||||
emptyFlag := writeManifest(t, dir, "empty-flags.json", `{"version":1,"migrations":[]}`)
|
||||
emptyCommand := writeManifest(t, dir, "empty-commands.json", `{"version":1,"migrations":[]}`)
|
||||
invalid := writeManifest(t, dir, "invalid-commands.json", `{`)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
args := []string{
|
||||
"compare",
|
||||
"--current", snapshotPath,
|
||||
"--base", snapshotPath,
|
||||
"--stable", snapshotPath,
|
||||
"--approved-flag-migrations", emptyFlag,
|
||||
"--candidate-flag-migrations", emptyFlag,
|
||||
"--approved-command-migrations", emptyCommand,
|
||||
"--candidate-command-migrations", emptyCommand,
|
||||
}
|
||||
if exitCode := run(args, &stdout, &stderr); exitCode != 0 {
|
||||
t.Fatalf("combined migration compare exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
approved string
|
||||
candidate string
|
||||
want string
|
||||
}{
|
||||
{"approved flag", invalid, emptyFlag, "read approved flag migrations"},
|
||||
{"candidate flag", emptyFlag, invalid, "read candidate flag migrations"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
testArgs := []string{
|
||||
"compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath,
|
||||
"--approved-flag-migrations", test.approved,
|
||||
"--candidate-flag-migrations", test.candidate,
|
||||
"--approved-command-migrations", emptyCommand,
|
||||
"--candidate-command-migrations", emptyCommand,
|
||||
}
|
||||
if exitCode := run(testArgs, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), test.want) {
|
||||
t.Fatalf("combined flag error exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
approved string
|
||||
candidate string
|
||||
want string
|
||||
}{
|
||||
{"approved", invalid, emptyCommand, "read approved command migrations"},
|
||||
{"candidate", emptyCommand, invalid, "read candidate command migrations"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
testArgs := []string{
|
||||
"compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath,
|
||||
"--approved-command-migrations", test.approved,
|
||||
"--candidate-command-migrations", test.candidate,
|
||||
}
|
||||
if exitCode := run(testArgs, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), test.want) {
|
||||
t.Fatalf("command manifest error exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
stderr.Reset()
|
||||
if exitCode := run([]string{
|
||||
"compare", "--current", snapshotPath, "--base", snapshotPath,
|
||||
"--approved-command-migrations", emptyCommand,
|
||||
}, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), "provided together") {
|
||||
t.Fatalf("one-sided command manifest exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
|
||||
if _, err := readCommandMigrationManifest(filepath.Join(dir, "missing.json")); err == nil {
|
||||
t.Fatal("missing command migration manifest unexpectedly read")
|
||||
}
|
||||
if _, err := readCommandMigrationManifest(invalid); err == nil {
|
||||
t.Fatal("invalid command migration manifest unexpectedly read")
|
||||
}
|
||||
|
||||
pending := writeManifest(t, dir, "pending-command.json", commandMigrationManifestJSON("pending"))
|
||||
consumed := writeManifest(t, dir, "consumed-command.json", commandMigrationManifestJSON("consumed"))
|
||||
stderr.Reset()
|
||||
if exitCode := run([]string{
|
||||
"compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath,
|
||||
"--approved-command-migrations", pending,
|
||||
"--candidate-command-migrations", consumed,
|
||||
}, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), "validate interface migration lifecycle") {
|
||||
t.Fatalf("command lifecycle error exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareRequiresBothReferencesForFlagMigrations(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
|
||||
@@ -567,6 +663,33 @@ func flagMigrationManifestJSON(state string) string {
|
||||
}`, "STATE", state, 1)
|
||||
}
|
||||
|
||||
func commandMigrationManifestJSON(state string) string {
|
||||
return strings.Replace(`{
|
||||
"version": 1,
|
||||
"migrations": [{
|
||||
"kind": "command_move",
|
||||
"legacy": {
|
||||
"command": "dws chat message old",
|
||||
"before": {"present": true, "runnable": true},
|
||||
"after": {"present": true, "runnable": true, "hidden": true}
|
||||
},
|
||||
"replacement": {
|
||||
"command": "dws chat topic new",
|
||||
"before": {"present": false},
|
||||
"after": {"present": true, "runnable": true}
|
||||
},
|
||||
"schema": {
|
||||
"product_id": "chat",
|
||||
"source_tool_id": "chat.move",
|
||||
"replacement_tool_id": "chat.move",
|
||||
"parameters": []
|
||||
},
|
||||
"state": "STATE",
|
||||
"reason": "reviewed command migration"
|
||||
}]
|
||||
}`, "STATE", state, 1)
|
||||
}
|
||||
|
||||
func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == name && flag.Type == flagType {
|
||||
|
||||
+5
-5
@@ -184,11 +184,11 @@ candidate SHA。
|
||||
`check-interface-baseline.sh` 不再作为本地或 CI 的兼容性审批入口,也不能用于批准
|
||||
flag 迁移。
|
||||
|
||||
Schema compatibility 使用同一组 base、stable、candidate refs 和同一份 base-owned flag
|
||||
migration ledger。merge-base-owned checker 分别规范化 merge-base 与 stable 的完整
|
||||
Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过 Interface
|
||||
lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会
|
||||
放宽其他 Schema 历史字段。
|
||||
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 历史字段。
|
||||
|
||||
For a release-seal branch that archives rendered fragments:
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# CLI flag 兼容迁移治理
|
||||
# CLI Help / Schema 兼容迁移治理
|
||||
|
||||
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 breaking-change 豁免。
|
||||
|
||||
同一套 base-owned lifecycle 也治理两类跨命令迁移:旧命令保留执行能力但从 Help / Schema 导航隐藏,并迁到新的公开命令路径;或把旧命令中的一个可选 flag 拆成新的专用命令。跨命令迁移只允许清单精确声明的 `command_became_hidden` / `flag_became_hidden` 及其 Schema 投影,不是通用 command-path breaking-change 豁免。
|
||||
|
||||
同名 flag 的精确类型迁移属于另一类评审机制,只能进入
|
||||
`internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename
|
||||
只能进入本文的 JSON lifecycle ledger。一项迁移不得跨两种机制组合授权。
|
||||
@@ -31,7 +33,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 引入的接口变化。首次引入本机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空清单,不会让 candidate 新增的 comparator 决定本 PR 是否兼容。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 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。
|
||||
|
||||
这条边界保护比较规则和审批数据,不是任意代码沙箱。GitHub workflow / launcher 的变更仍由仓库保护规则和真人评审负责;candidate Cobra 构建也会执行 candidate 代码,因此对同一 runner 上的主动恶意代码,需要独立进程或文件系统隔离,不能把本门禁描述成已经解决。
|
||||
|
||||
@@ -39,10 +41,26 @@ PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁
|
||||
|
||||
```text
|
||||
scripts/policy/interface-migrations/approved-flag-migrations-v1.json
|
||||
scripts/policy/interface-migrations/approved-command-migrations-v1.json
|
||||
```
|
||||
|
||||
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
|
||||
|
||||
## 跨命令迁移原语
|
||||
|
||||
`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 |
|
||||
|
||||
两种迁移都要求旧 argv 继续可执行。删除旧命令、删除旧 flag、把 legacy 改成 non-runnable、改变未登记参数、改变 interface / safety,或只完成部分 before → after 转换都会 fail closed。命令别名会先规范到 reference 的 canonical path,但清单本身仍只能记录精确 canonical 命令,不能用 alias 或前缀扩大授权。
|
||||
|
||||
跨命令清单复用下文同一套 `pending → consumed → cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。
|
||||
|
||||
当前首批 pending 记录覆盖 `chat topic` 收口:`chat group create --thread` 拆到 `chat topic create`,以及 `chat message list-topic-replies` / `forward-topic` 迁到对应的 `chat topic` 命令。产品 PR 消费这些记录时只能把三条 `state` 改为 `consumed`,不得改写其 before、after、Schema mapping 或 reason。
|
||||
|
||||
## 两阶段迁移与回执清理
|
||||
|
||||
每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期:
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package interfacesnapshot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const CommandMigrationManifestVersion = 1
|
||||
|
||||
const (
|
||||
CommandMigrationPending = "pending"
|
||||
CommandMigrationConsumed = "consumed"
|
||||
|
||||
CommandMigrationMove = "command_move"
|
||||
CommandMigrationFlagExtraction = "flag_extraction"
|
||||
)
|
||||
|
||||
// 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.
|
||||
type CommandMigrationManifest struct {
|
||||
Version int `json:"version"`
|
||||
Migrations []CommandMigration `json:"migrations"`
|
||||
}
|
||||
|
||||
type CommandMigration struct {
|
||||
Kind string `json:"kind"`
|
||||
Legacy CommandMigrationSide `json:"legacy"`
|
||||
Replacement CommandMigrationSide `json:"replacement"`
|
||||
LegacyFlag CommandMigrationFlag `json:"legacy_flag"`
|
||||
Schema CommandMigrationSchema `json:"schema"`
|
||||
State string `json:"state"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type CommandMigrationSide struct {
|
||||
Command string `json:"command"`
|
||||
Before CommandMigrationState `json:"before"`
|
||||
After CommandMigrationState `json:"after"`
|
||||
}
|
||||
|
||||
type CommandMigrationState struct {
|
||||
Present bool `json:"present"`
|
||||
Runnable bool `json:"runnable,omitempty"`
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
}
|
||||
|
||||
type CommandMigrationFlag struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Before FlagMigrationState `json:"before"`
|
||||
After FlagMigrationState `json:"after"`
|
||||
}
|
||||
|
||||
type CommandMigrationSchema struct {
|
||||
ProductID string `json:"product_id"`
|
||||
SourceToolID string `json:"source_tool_id"`
|
||||
ReplacementToolID string `json:"replacement_tool_id"`
|
||||
Parameters []CommandParameterMigration `json:"parameters"`
|
||||
}
|
||||
|
||||
type CommandParameterMigration struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to,omitempty"`
|
||||
}
|
||||
|
||||
func ReadCommandMigrationManifest(r io.Reader) (CommandMigrationManifest, error) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return CommandMigrationManifest{}, fmt.Errorf("read command migration manifest: %w", err)
|
||||
}
|
||||
var manifest CommandMigrationManifest
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&manifest); err != nil {
|
||||
return CommandMigrationManifest{}, err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return CommandMigrationManifest{}, fmt.Errorf("command migration manifest contains trailing multiple JSON values")
|
||||
}
|
||||
return CommandMigrationManifest{}, fmt.Errorf("read trailing command migration manifest data: %w", err)
|
||||
}
|
||||
strict := json.NewDecoder(bytes.NewReader(data))
|
||||
strict.UseNumber()
|
||||
if err := validateLabeledMigrationJSONValue(strict, "$", reflect.TypeOf(CommandMigrationManifest{}), "command"); err != nil {
|
||||
return CommandMigrationManifest{}, err
|
||||
}
|
||||
if err := manifest.Validate(); err != nil {
|
||||
return CommandMigrationManifest{}, err
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func (m CommandMigrationManifest) Validate() error {
|
||||
if m.Version != CommandMigrationManifestVersion {
|
||||
return fmt.Errorf("unsupported command migration manifest version %d (want %d)", m.Version, CommandMigrationManifestVersion)
|
||||
}
|
||||
if m.Migrations == nil {
|
||||
return fmt.Errorf("command migration manifest migrations must be an array")
|
||||
}
|
||||
seen := make(map[string]bool, len(m.Migrations))
|
||||
for index, migration := range m.Migrations {
|
||||
if err := migration.validate(); err != nil {
|
||||
return fmt.Errorf("command migration %d: %w", index, err)
|
||||
}
|
||||
if seen[migration.key()] {
|
||||
return fmt.Errorf("command migration %d duplicates %s", index, migration.displayKey())
|
||||
}
|
||||
seen[migration.key()] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m CommandMigration) validate() error {
|
||||
if m.Kind != CommandMigrationMove && m.Kind != CommandMigrationFlagExtraction {
|
||||
return fmt.Errorf("invalid kind %q", m.Kind)
|
||||
}
|
||||
if !isExactCommandPath(m.Legacy.Command) || !isExactCommandPath(m.Replacement.Command) {
|
||||
return fmt.Errorf("legacy and replacement must be exact command paths rooted at dws")
|
||||
}
|
||||
if m.Legacy.Command == m.Replacement.Command {
|
||||
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)
|
||||
}
|
||||
for label, state := range map[string]CommandMigrationState{
|
||||
"legacy before": m.Legacy.Before,
|
||||
"legacy after": m.Legacy.After,
|
||||
"replacement before": m.Replacement.Before,
|
||||
"replacement after": m.Replacement.After,
|
||||
} {
|
||||
if err := state.validate(label); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !m.Legacy.Before.Present || !m.Legacy.Before.Runnable || m.Legacy.Before.Hidden ||
|
||||
!m.Legacy.After.Present || !m.Legacy.After.Runnable {
|
||||
return fmt.Errorf("legacy command must remain runnable and start visible")
|
||||
}
|
||||
if m.Replacement.Before.Present || !m.Replacement.After.Present || !m.Replacement.After.Runnable || m.Replacement.After.Hidden {
|
||||
return fmt.Errorf("replacement command must migrate exactly from absent to visible runnable")
|
||||
}
|
||||
if err := m.Schema.validate(m.Kind); err != nil {
|
||||
return err
|
||||
}
|
||||
switch m.Kind {
|
||||
case CommandMigrationMove:
|
||||
if !m.Legacy.After.Hidden {
|
||||
return fmt.Errorf("command_move legacy command must migrate exactly from visible to hidden")
|
||||
}
|
||||
if m.LegacyFlag != (CommandMigrationFlag{}) {
|
||||
return fmt.Errorf("command_move must not declare legacy_flag")
|
||||
}
|
||||
if m.Schema.SourceToolID != m.Schema.ReplacementToolID {
|
||||
return fmt.Errorf("command_move must retain one stable Schema tool identity")
|
||||
}
|
||||
case CommandMigrationFlagExtraction:
|
||||
if m.Legacy.After.Hidden || m.Legacy.Before != m.Legacy.After {
|
||||
return fmt.Errorf("flag_extraction legacy command must remain visible and unchanged")
|
||||
}
|
||||
if err := m.LegacyFlag.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Schema.SourceToolID == m.Schema.ReplacementToolID {
|
||||
return fmt.Errorf("flag_extraction requires a distinct replacement Schema tool")
|
||||
}
|
||||
if len(m.Schema.Parameters) != 1 || m.Schema.Parameters[0].From != m.LegacyFlag.Name || m.Schema.Parameters[0].To != "" {
|
||||
return fmt.Errorf("flag_extraction must declare the exact extracted flag as one removed Schema parameter")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s CommandMigrationState) validate(label string) error {
|
||||
if !s.Present {
|
||||
if s.Runnable || s.Hidden {
|
||||
return fmt.Errorf("%s absent state must not declare command attributes", label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !s.Runnable {
|
||||
return fmt.Errorf("%s present state must be runnable", label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f CommandMigrationFlag) validate() error {
|
||||
if !isExactFlagName(f.Name) {
|
||||
return fmt.Errorf("legacy_flag name must be an exact flag")
|
||||
}
|
||||
if err := f.Before.validate("legacy_flag before"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.After.validate("legacy_flag after"); err != nil {
|
||||
return err
|
||||
}
|
||||
if !f.Before.Present || !f.After.Present || f.Before.Hidden || !f.After.Hidden {
|
||||
return fmt.Errorf("legacy_flag must migrate exactly from visible to hidden while remaining present")
|
||||
}
|
||||
before := f.Before
|
||||
after := f.After
|
||||
before.Hidden = false
|
||||
after.Hidden = false
|
||||
if before != after {
|
||||
return fmt.Errorf("legacy_flag may change only hidden visibility")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s CommandMigrationSchema) validate(kind string) error {
|
||||
for label, value := range map[string]string{
|
||||
"product_id": s.ProductID,
|
||||
"source_tool_id": s.SourceToolID,
|
||||
"replacement_tool_id": s.ReplacementToolID,
|
||||
} {
|
||||
if !isExactSchemaIdentifier(value) {
|
||||
return fmt.Errorf("schema %s must be an exact identifier", label)
|
||||
}
|
||||
}
|
||||
if s.Parameters == nil {
|
||||
return fmt.Errorf("schema parameters must be an array")
|
||||
}
|
||||
seenFrom := map[string]bool{}
|
||||
seenTo := map[string]bool{}
|
||||
for index, parameter := range s.Parameters {
|
||||
if !isExactFlagName(parameter.From) {
|
||||
return fmt.Errorf("schema parameter %d from must be an exact parameter name", index)
|
||||
}
|
||||
if seenFrom[parameter.From] {
|
||||
return fmt.Errorf("schema parameter %d duplicates from %q", index, parameter.From)
|
||||
}
|
||||
seenFrom[parameter.From] = true
|
||||
if kind == CommandMigrationMove {
|
||||
if !isExactFlagName(parameter.To) || parameter.To == parameter.From {
|
||||
return fmt.Errorf("command_move schema parameter %d requires a distinct exact to name", index)
|
||||
}
|
||||
if seenTo[parameter.To] {
|
||||
return fmt.Errorf("schema parameter %d duplicates to %q", index, parameter.To)
|
||||
}
|
||||
seenTo[parameter.To] = true
|
||||
} else if parameter.To != "" {
|
||||
return fmt.Errorf("flag_extraction schema parameters must not declare to")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isExactSchemaIdentifier(value string) bool {
|
||||
return value != "" && value == strings.TrimSpace(value) &&
|
||||
!strings.ContainsAny(value, "*?[]{} /\\\t\r\n")
|
||||
}
|
||||
|
||||
func (m CommandMigration) key() string {
|
||||
return strings.Join([]string{m.Kind, m.Legacy.Command, m.Replacement.Command, m.Schema.ProductID, m.Schema.SourceToolID, m.Schema.ReplacementToolID}, "\x00")
|
||||
}
|
||||
|
||||
func (m CommandMigration) displayKey() string {
|
||||
return fmt.Sprintf("%s %q -> %q", m.Kind, m.Legacy.Command, m.Replacement.Command)
|
||||
}
|
||||
|
||||
type commandMigrationPhase string
|
||||
|
||||
const (
|
||||
commandMigrationBefore commandMigrationPhase = "before"
|
||||
commandMigrationAfter commandMigrationPhase = "after"
|
||||
commandMigrationPartial commandMigrationPhase = "partial"
|
||||
)
|
||||
|
||||
func AuthorizeCommandMigrations(
|
||||
current Snapshot,
|
||||
references map[string]Snapshot,
|
||||
authority CommandMigrationManifest,
|
||||
candidate CommandMigrationManifest,
|
||||
) ([]CommandMigration, error) {
|
||||
if err := current.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validate current interface snapshot: %w", err)
|
||||
}
|
||||
for label, snapshot := range references {
|
||||
if err := snapshot.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validate %s interface snapshot: %w", label, err)
|
||||
}
|
||||
}
|
||||
if err := authority.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validate approved command migrations: %w", err)
|
||||
}
|
||||
if err := candidate.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validate candidate command migrations: %w", err)
|
||||
}
|
||||
return evaluateCommandMigrationLifecycle(current, references, authority, candidate)
|
||||
}
|
||||
|
||||
func evaluateCommandMigrationLifecycle(
|
||||
current Snapshot,
|
||||
references map[string]Snapshot,
|
||||
authority CommandMigrationManifest,
|
||||
candidate CommandMigrationManifest,
|
||||
) ([]CommandMigration, error) {
|
||||
if len(authority.Migrations) == 0 && len(candidate.Migrations) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if _, ok := references["stable"]; !ok {
|
||||
return nil, fmt.Errorf("command migration lifecycle requires a stable reference")
|
||||
}
|
||||
mergeBase, label, ok := flagMigrationAuthoritySnapshot(references)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("command migration lifecycle requires a main or merge-base reference")
|
||||
}
|
||||
authorityByKey := commandMigrationIndex(authority)
|
||||
candidateByKey := commandMigrationIndex(candidate)
|
||||
authorizations := make([]CommandMigration, 0, len(authority.Migrations))
|
||||
for _, approved := range authority.Migrations {
|
||||
basePhase := matchCommandMigrationPhase(mergeBase, approved)
|
||||
wantBase := commandMigrationBefore
|
||||
if approved.State == CommandMigrationConsumed {
|
||||
wantBase = commandMigrationAfter
|
||||
}
|
||||
if basePhase != wantBase {
|
||||
return nil, fmt.Errorf("approved command migration %s is %s in %s, want exact %s state for %s", approved.displayKey(), basePhase, label, wantBase, approved.State)
|
||||
}
|
||||
proposed, exists := candidateByKey[approved.key()]
|
||||
if exists && !sameCommandMigrationApproval(approved, proposed) {
|
||||
return nil, fmt.Errorf("candidate modified base-owned command migration %s", approved.displayKey())
|
||||
}
|
||||
currentPhase := matchCommandMigrationPhase(current, approved)
|
||||
switch approved.State {
|
||||
case CommandMigrationPending:
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("candidate removed pending command migration %s", approved.displayKey())
|
||||
}
|
||||
switch currentPhase {
|
||||
case commandMigrationBefore:
|
||||
if proposed.State != CommandMigrationPending {
|
||||
return nil, fmt.Errorf("candidate falsely consumed unchanged command migration %s", approved.displayKey())
|
||||
}
|
||||
case commandMigrationAfter:
|
||||
if proposed.State != CommandMigrationConsumed {
|
||||
return nil, fmt.Errorf("candidate completed command migration %s without marking it consumed", approved.displayKey())
|
||||
}
|
||||
authorizations = append(authorizations, approved)
|
||||
default:
|
||||
return nil, fmt.Errorf("candidate partially applied command migration %s", approved.displayKey())
|
||||
}
|
||||
case CommandMigrationConsumed:
|
||||
if currentPhase != commandMigrationAfter {
|
||||
return nil, fmt.Errorf("candidate drifted from consumed command migration %s", approved.displayKey())
|
||||
}
|
||||
allAfter := true
|
||||
for _, reference := range references {
|
||||
if matchCommandMigrationPhase(reference, approved) != commandMigrationAfter {
|
||||
allAfter = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allAfter {
|
||||
if exists {
|
||||
return nil, fmt.Errorf("consumed command migration %s is stale after all references reached the after state", approved.displayKey())
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !exists || proposed.State != CommandMigrationConsumed {
|
||||
return nil, fmt.Errorf("candidate must retain consumed command migration %s until every reference reaches the after state", approved.displayKey())
|
||||
}
|
||||
authorizations = append(authorizations, approved)
|
||||
}
|
||||
}
|
||||
for _, proposed := range candidate.Migrations {
|
||||
if _, exists := authorityByKey[proposed.key()]; exists {
|
||||
continue
|
||||
}
|
||||
if proposed.State != CommandMigrationPending {
|
||||
return nil, fmt.Errorf("candidate-added command migration %s must start pending", proposed.displayKey())
|
||||
}
|
||||
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())
|
||||
}
|
||||
}
|
||||
return authorizations, nil
|
||||
}
|
||||
|
||||
func commandMigrationIndex(manifest CommandMigrationManifest) map[string]CommandMigration {
|
||||
index := make(map[string]CommandMigration, len(manifest.Migrations))
|
||||
for _, migration := range manifest.Migrations {
|
||||
index[migration.key()] = migration
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func sameCommandMigrationApproval(left, right CommandMigration) bool {
|
||||
left.State = ""
|
||||
right.State = ""
|
||||
return reflect.DeepEqual(left, right)
|
||||
}
|
||||
|
||||
func matchCommandMigrationPhase(snapshot Snapshot, migration CommandMigration) commandMigrationPhase {
|
||||
commands := commandIndex(snapshot)
|
||||
legacy := commandMigrationStateForCommand(commands, migration.Legacy.Command)
|
||||
replacement := commandMigrationStateForCommand(commands, migration.Replacement.Command)
|
||||
before := legacy == migration.Legacy.Before && replacement == migration.Replacement.Before
|
||||
after := legacy == migration.Legacy.After && replacement == migration.Replacement.After
|
||||
if migration.Kind == CommandMigrationFlagExtraction {
|
||||
command, exists := commands[migration.Legacy.Command]
|
||||
flagState := FlagMigrationState{}
|
||||
if exists {
|
||||
flagState = flagMigrationStateForCommand(command, migration.LegacyFlag.Name)
|
||||
}
|
||||
before = before && flagState == migration.LegacyFlag.Before
|
||||
after = after && flagState == migration.LegacyFlag.After
|
||||
}
|
||||
if before {
|
||||
return commandMigrationBefore
|
||||
}
|
||||
if after {
|
||||
return commandMigrationAfter
|
||||
}
|
||||
return commandMigrationPartial
|
||||
}
|
||||
|
||||
func commandMigrationStateForCommand(commands map[string]Command, path string) CommandMigrationState {
|
||||
command, exists := commands[path]
|
||||
if !exists {
|
||||
return CommandMigrationState{}
|
||||
}
|
||||
return CommandMigrationState{Present: true, Runnable: command.Runnable, Hidden: command.Hidden}
|
||||
}
|
||||
|
||||
// CompareAllWithInterfaceMigrations applies both migration families to one
|
||||
// ordinary report, so the two ledgers cannot mask each other's unrelated
|
||||
// findings.
|
||||
func CompareAllWithInterfaceMigrations(
|
||||
current Snapshot,
|
||||
references map[string]Snapshot,
|
||||
flagAuthority FlagMigrationManifest,
|
||||
flagCandidate FlagMigrationManifest,
|
||||
commandAuthority CommandMigrationManifest,
|
||||
commandCandidate CommandMigrationManifest,
|
||||
) (Report, error) {
|
||||
flagAuthorizations, err := AuthorizeFlagMigrations(current, references, flagAuthority, flagCandidate)
|
||||
if err != nil {
|
||||
return Report{}, err
|
||||
}
|
||||
commandAuthorizations, err := AuthorizeCommandMigrations(current, references, commandAuthority, commandCandidate)
|
||||
if err != nil {
|
||||
return Report{}, err
|
||||
}
|
||||
report := CompareAll(current, references)
|
||||
for index := range report.Comparisons {
|
||||
comparison := &report.Comparisons[index]
|
||||
reference := references[comparison.Reference]
|
||||
filtered := comparison.Blocking[:0]
|
||||
for _, change := range comparison.Blocking {
|
||||
if flagMigrationAuthorizesChange(current, reference, change, flagAuthorizations) ||
|
||||
commandMigrationAuthorizesChange(current, reference, change, commandAuthorizations) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, change)
|
||||
}
|
||||
comparison.Blocking = filtered
|
||||
comparison.Compatible = len(filtered) == 0
|
||||
}
|
||||
report.Compatible = true
|
||||
for _, comparison := range report.Comparisons {
|
||||
if !comparison.Compatible {
|
||||
report.Compatible = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func commandMigrationAuthorizesChange(current, reference Snapshot, change Change, authorizations []CommandMigration) bool {
|
||||
canonicalPath := acceptedPathIndex(reference)[change.Path]
|
||||
if canonicalPath == "" {
|
||||
canonicalPath = change.Path
|
||||
}
|
||||
for _, migration := range authorizations {
|
||||
if canonicalPath != migration.Legacy.Command ||
|
||||
matchCommandMigrationPhase(reference, migration) != commandMigrationBefore ||
|
||||
matchCommandMigrationPhase(current, migration) != commandMigrationAfter {
|
||||
continue
|
||||
}
|
||||
switch migration.Kind {
|
||||
case CommandMigrationMove:
|
||||
if change.Kind == "command_became_hidden" && change.Flag == "" {
|
||||
return true
|
||||
}
|
||||
case CommandMigrationFlagExtraction:
|
||||
if change.Kind == "flag_became_hidden" && change.Flag == migration.LegacyFlag.Name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package interfacesnapshot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type commandMigrationErrorReader struct{}
|
||||
|
||||
func (commandMigrationErrorReader) Read([]byte) (int, error) {
|
||||
return 0, errors.New("command migration read failure")
|
||||
}
|
||||
|
||||
func TestApprovedCommandMigrationManifestRemainsValid(t *testing.T) {
|
||||
manifest, err := os.Open("../../scripts/policy/interface-migrations/approved-command-migrations-v1.json")
|
||||
if err != nil {
|
||||
t.Fatalf("open approved command migration manifest: %v", err)
|
||||
}
|
||||
defer manifest.Close()
|
||||
if _, err := ReadCommandMigrationManifest(manifest); err != nil {
|
||||
t.Fatalf("approved command migration manifest is invalid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadCommandMigrationManifestFailsClosed(t *testing.T) {
|
||||
valid := commandMigrationManifestJSON()
|
||||
if _, err := ReadCommandMigrationManifest(strings.NewReader(valid)); err != nil {
|
||||
t.Fatalf("valid command migration manifest: %v", err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{strings.Replace(valid, `"state": "pending"`, `"state": "pending", "allow": true`, 1), "unknown field"},
|
||||
{strings.Replace(valid, `"state": "pending"`, `"state": "pending", "state": "consumed"`, 1), "duplicate field"},
|
||||
{strings.Replace(valid, `"version": 1`, `"version": null`, 1), "must be"},
|
||||
{strings.Replace(valid, "dws chat message old", "dws chat *", 1), "exact command paths"},
|
||||
{strings.Replace(valid, `"kind": "command_move"`, `"kind": "anything"`, 1), "invalid kind"},
|
||||
{`{"version":1,"migrations":null}`, "must be an array"},
|
||||
} {
|
||||
if _, err := ReadCommandMigrationManifest(strings.NewReader(test.input)); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("ReadCommandMigrationManifest() error=%v, want %q", err, test.want)
|
||||
}
|
||||
}
|
||||
if _, err := ReadCommandMigrationManifest(commandMigrationErrorReader{}); err == nil || !strings.Contains(err.Error(), "read failure") {
|
||||
t.Fatalf("reader error=%v", err)
|
||||
}
|
||||
for _, input := range []string{valid + ` {}`, valid + ` {`} {
|
||||
if _, err := ReadCommandMigrationManifest(strings.NewReader(input)); err == nil || !strings.Contains(err.Error(), "trailing") {
|
||||
t.Fatalf("trailing input error=%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandMigrationValidationEdges(t *testing.T) {
|
||||
validMove := commandMigrationManifest(CommandMigrationPending).Migrations[0]
|
||||
validExtraction := commandMigrationManifest(CommandMigrationPending).Migrations[1]
|
||||
|
||||
duplicate := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{validMove, validMove}}
|
||||
if err := duplicate.Validate(); err == nil || !strings.Contains(err.Error(), "duplicates") {
|
||||
t.Fatalf("duplicate error=%v", err)
|
||||
}
|
||||
if err := (CommandMigrationManifest{Version: CommandMigrationManifestVersion}).Validate(); err == nil || !strings.Contains(err.Error(), "must be an array") {
|
||||
t.Fatalf("nil migrations error=%v", err)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*CommandMigration)
|
||||
want string
|
||||
}{
|
||||
{"same command", func(m *CommandMigration) { m.Replacement.Command = m.Legacy.Command }, "must differ"},
|
||||
{"empty reason", func(m *CommandMigration) { m.Reason = " " }, "non-empty trimmed"},
|
||||
{"invalid state", func(m *CommandMigration) { m.State = "approved" }, "invalid state"},
|
||||
{"invalid command state", func(m *CommandMigration) { m.Replacement.Before.Runnable = true }, "absent state"},
|
||||
{"legacy contract", func(m *CommandMigration) { m.Legacy.Before.Hidden = true }, "remain runnable"},
|
||||
{"replacement contract", func(m *CommandMigration) { m.Replacement.After.Hidden = true }, "absent to visible"},
|
||||
{"schema contract", func(m *CommandMigration) { m.Schema.ProductID = "bad/id" }, "exact identifier"},
|
||||
{"move not hidden", func(m *CommandMigration) { m.Legacy.After.Hidden = false }, "must migrate exactly"},
|
||||
{"move flag", func(m *CommandMigration) { m.LegacyFlag.Name = "thread" }, "must not declare legacy_flag"},
|
||||
{"move tool identity", func(m *CommandMigration) { m.Schema.ReplacementToolID = "chat.new" }, "stable Schema tool"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
migration := validMove
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*CommandMigration)
|
||||
want string
|
||||
}{
|
||||
{"legacy changed", func(m *CommandMigration) { m.Legacy.After.Hidden = true }, "remain visible"},
|
||||
{"invalid legacy flag", func(m *CommandMigration) { m.LegacyFlag.Name = "bad name" }, "exact flag"},
|
||||
{"same tool", func(m *CommandMigration) { m.Schema.ReplacementToolID = m.Schema.SourceToolID }, "distinct replacement"},
|
||||
{"wrong parameter", func(m *CommandMigration) { m.Schema.Parameters[0].From = "other" }, "exact extracted flag"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
migration := validExtraction
|
||||
migration.Schema.Parameters = append([]CommandParameterMigration(nil), validExtraction.Schema.Parameters...)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, state := range []CommandMigrationState{
|
||||
{Runnable: true},
|
||||
{Present: true},
|
||||
} {
|
||||
if err := state.validate("state"); err == nil {
|
||||
t.Fatalf("invalid command state accepted: %#v", state)
|
||||
}
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*CommandMigrationFlag)
|
||||
want string
|
||||
}{
|
||||
{"name", func(f *CommandMigrationFlag) { f.Name = "bad name" }, "exact flag"},
|
||||
{"before state", func(f *CommandMigrationFlag) { f.Before = FlagMigrationState{Present: true} }, "requires type"},
|
||||
{"after state", func(f *CommandMigrationFlag) { f.After = FlagMigrationState{Present: true} }, "requires type"},
|
||||
{"visibility", func(f *CommandMigrationFlag) { f.After.Hidden = false }, "visible to hidden"},
|
||||
{"attribute", func(f *CommandMigrationFlag) { f.After.Type = "string" }, "only hidden visibility"},
|
||||
} {
|
||||
t.Run("flag "+test.name, func(t *testing.T) {
|
||||
flag := validExtraction.LegacyFlag
|
||||
test.mutate(&flag)
|
||||
if err := flag.validate(); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("flag validate error=%v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
validSchema := validMove.Schema
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
kind string
|
||||
mutate func(*CommandMigrationSchema)
|
||||
want string
|
||||
}{
|
||||
{"identifier", CommandMigrationMove, func(s *CommandMigrationSchema) { s.ProductID = "bad/id" }, "exact identifier"},
|
||||
{"nil parameters", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = nil }, "must be an array"},
|
||||
{"bad from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters[0].From = "bad name" }, "exact parameter"},
|
||||
{"duplicate from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = append(s.Parameters, s.Parameters[0]) }, "duplicates from"},
|
||||
{"bad to", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters[0].To = s.Parameters[0].From }, "distinct exact"},
|
||||
{"duplicate to", CommandMigrationMove, func(s *CommandMigrationSchema) {
|
||||
s.Parameters = append(s.Parameters, CommandParameterMigration{From: "other", To: s.Parameters[0].To})
|
||||
}, "duplicates to"},
|
||||
{"extraction to", CommandMigrationFlagExtraction, func(s *CommandMigrationSchema) { s.Parameters[0].To = "new-id" }, "must not declare to"},
|
||||
} {
|
||||
t.Run("schema "+test.name, func(t *testing.T) {
|
||||
schema := validSchema
|
||||
schema.Parameters = append([]CommandParameterMigration(nil), validSchema.Parameters...)
|
||||
test.mutate(&schema)
|
||||
if err := schema.validate(test.kind); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("schema validate error=%v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandMigrationLifecycleEdges(t *testing.T) {
|
||||
before := commandMigrationSnapshot(false, false)
|
||||
after := commandMigrationSnapshot(true, false)
|
||||
partial := commandMigrationSnapshot(false, false)
|
||||
partial.Commands = append(partial.Commands, testCommand("dws chat topic new"))
|
||||
pending := singleCommandMigrationManifest(CommandMigrationPending)
|
||||
consumed := singleCommandMigrationManifest(CommandMigrationConsumed)
|
||||
empty := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}}
|
||||
|
||||
if got, err := AuthorizeCommandMigrations(before, map[string]Snapshot{}, empty, empty); err != nil || len(got) != 0 {
|
||||
t.Fatalf("empty lifecycle=%#v, %v", got, err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
current Snapshot
|
||||
references map[string]Snapshot
|
||||
authority CommandMigrationManifest
|
||||
candidate CommandMigrationManifest
|
||||
want string
|
||||
}{
|
||||
{"missing stable", before, map[string]Snapshot{"main": before}, pending, pending, "stable reference"},
|
||||
{"missing base", before, map[string]Snapshot{"stable": before}, pending, pending, "main or merge-base"},
|
||||
{"pending base after", after, map[string]Snapshot{"main": after, "stable": before}, pending, pending, "want exact before"},
|
||||
{"modified approval", before, map[string]Snapshot{"main": before, "stable": before}, pending, modifiedCommandManifest(pending), "modified base-owned"},
|
||||
{"pending removed", before, map[string]Snapshot{"main": before, "stable": before}, pending, empty, "removed pending"},
|
||||
{"false consumed", before, map[string]Snapshot{"main": before, "stable": before}, pending, consumed, "falsely consumed"},
|
||||
{"after pending receipt", after, map[string]Snapshot{"main": before, "stable": before}, pending, pending, "without marking it consumed"},
|
||||
{"partial", partial, map[string]Snapshot{"main": before, "stable": before}, pending, consumed, "partially applied"},
|
||||
{"consumed drift", before, map[string]Snapshot{"main": after, "stable": before}, consumed, consumed, "drifted from consumed"},
|
||||
{"stale receipt", after, map[string]Snapshot{"main": after, "stable": after}, consumed, consumed, "stale after all references"},
|
||||
{"early cleanup", after, map[string]Snapshot{"main": after, "stable": before}, consumed, empty, "must retain consumed"},
|
||||
{"consumed back to pending", after, map[string]Snapshot{"main": after, "stable": before}, consumed, pending, "must retain consumed"},
|
||||
{"candidate added consumed", after, map[string]Snapshot{"main": before, "stable": before}, empty, consumed, "must start pending"},
|
||||
{"candidate base mismatch", before, map[string]Snapshot{"main": after, "stable": before}, empty, pending, "does not match"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := AuthorizeCommandMigrations(test.current, test.references, test.authority, test.candidate)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("lifecycle error=%v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": before}, consumed, consumed); err != nil || len(got) != 1 {
|
||||
t.Fatalf("retained consumed receipt=%#v, %v", got, err)
|
||||
}
|
||||
if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": after}, consumed, empty); err != nil || len(got) != 0 {
|
||||
t.Fatalf("cleaned stale receipt=%#v, %v", got, err)
|
||||
}
|
||||
if got, err := AuthorizeCommandMigrations(before, map[string]Snapshot{"main": before, "stable": before}, empty, pending); err != nil || len(got) != 0 {
|
||||
t.Fatalf("candidate pending plan=%#v, %v", got, err)
|
||||
}
|
||||
|
||||
invalidSnapshot := before
|
||||
invalidSnapshot.SchemaVersion = 0
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
current Snapshot
|
||||
references map[string]Snapshot
|
||||
authority CommandMigrationManifest
|
||||
candidate CommandMigrationManifest
|
||||
}{
|
||||
{"current", invalidSnapshot, map[string]Snapshot{"main": before}, empty, empty},
|
||||
{"reference", before, map[string]Snapshot{"main": invalidSnapshot}, empty, empty},
|
||||
{"authority", before, map[string]Snapshot{"main": before}, CommandMigrationManifest{}, empty},
|
||||
{"candidate", before, map[string]Snapshot{"main": before}, empty, CommandMigrationManifest{}},
|
||||
} {
|
||||
t.Run("invalid "+test.name, func(t *testing.T) {
|
||||
if _, err := AuthorizeCommandMigrations(test.current, test.references, test.authority, test.candidate); err == nil {
|
||||
t.Fatal("invalid authorization input accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
invalidFlags := FlagMigrationManifest{}
|
||||
validFlags := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}
|
||||
validCommands := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}}
|
||||
if _, err := CompareAllWithInterfaceMigrations(before, map[string]Snapshot{"main": before}, invalidFlags, validFlags, validCommands, validCommands); err == nil {
|
||||
t.Fatal("combined compare accepted invalid flag lifecycle")
|
||||
}
|
||||
if _, err := CompareAllWithInterfaceMigrations(before, map[string]Snapshot{"main": before}, validFlags, validFlags, CommandMigrationManifest{}, validCommands); err == nil {
|
||||
t.Fatal("combined compare accepted invalid command lifecycle")
|
||||
}
|
||||
if commandMigrationAuthorizesChange(before, before, Change{Kind: "command_removed", Path: "dws unrelated"}, pending.Migrations) {
|
||||
t.Fatal("unrelated command change was authorized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandMigrationLifecycleAndExactFiltering(t *testing.T) {
|
||||
before := commandMigrationSnapshot(false, false)
|
||||
after := commandMigrationSnapshot(true, false)
|
||||
pending := commandMigrationManifest(CommandMigrationPending)
|
||||
consumed := commandMigrationManifest(CommandMigrationConsumed)
|
||||
emptyFlags := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}
|
||||
|
||||
report, err := CompareAllWithInterfaceMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
emptyFlags,
|
||||
emptyFlags,
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("governed command migration: %v", err)
|
||||
}
|
||||
if !report.Compatible {
|
||||
t.Fatalf("exact command migration remained blocking: %#v", report.Comparisons)
|
||||
}
|
||||
|
||||
unrelated := commandMigrationSnapshot(true, true)
|
||||
report, err = CompareAllWithInterfaceMigrations(
|
||||
unrelated,
|
||||
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
emptyFlags,
|
||||
emptyFlags,
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Compatible || !hasChangeKind(report.Comparisons[0].Blocking, "flag_removed") {
|
||||
t.Fatalf("unrelated flag removal was hidden: %#v", report.Comparisons)
|
||||
}
|
||||
|
||||
emptyCommands := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}}
|
||||
if _, err := AuthorizeCommandMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
emptyCommands,
|
||||
pending,
|
||||
); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") {
|
||||
t.Fatalf("candidate self-authorization error=%v", err)
|
||||
}
|
||||
|
||||
partial := commandMigrationSnapshot(true, false)
|
||||
partial.Commands = partial.Commands[:len(partial.Commands)-1]
|
||||
if _, err := AuthorizeCommandMigrations(
|
||||
partial,
|
||||
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
pending,
|
||||
consumed,
|
||||
); err == nil || !strings.Contains(err.Error(), "partially applied") {
|
||||
t.Fatalf("partial command migration error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func commandMigrationManifestJSON() string {
|
||||
return `{
|
||||
"version": 1,
|
||||
"migrations": [{
|
||||
"kind": "command_move",
|
||||
"legacy": {
|
||||
"command": "dws chat message old",
|
||||
"before": {"present": true, "runnable": true},
|
||||
"after": {"present": true, "runnable": true, "hidden": true}
|
||||
},
|
||||
"replacement": {
|
||||
"command": "dws chat topic new",
|
||||
"before": {"present": false},
|
||||
"after": {"present": true, "runnable": true}
|
||||
},
|
||||
"schema": {
|
||||
"product_id": "chat",
|
||||
"source_tool_id": "chat.old",
|
||||
"replacement_tool_id": "chat.old",
|
||||
"parameters": [{"from": "old-id", "to": "new-id"}]
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Reviewed command move."
|
||||
}]
|
||||
}`
|
||||
}
|
||||
|
||||
func commandMigrationManifest(state string) CommandMigrationManifest {
|
||||
move := CommandMigration{
|
||||
Kind: CommandMigrationMove,
|
||||
Legacy: CommandMigrationSide{
|
||||
Command: "dws chat message old",
|
||||
Before: CommandMigrationState{Present: true, Runnable: true},
|
||||
After: CommandMigrationState{Present: true, Runnable: true, Hidden: true},
|
||||
},
|
||||
Replacement: CommandMigrationSide{
|
||||
Command: "dws chat topic new",
|
||||
Before: CommandMigrationState{},
|
||||
After: CommandMigrationState{Present: true, Runnable: true},
|
||||
},
|
||||
Schema: CommandMigrationSchema{
|
||||
ProductID: "chat",
|
||||
SourceToolID: "chat.old",
|
||||
ReplacementToolID: "chat.old",
|
||||
Parameters: []CommandParameterMigration{{From: "old-id", To: "new-id"}},
|
||||
},
|
||||
State: state,
|
||||
Reason: "Reviewed command move.",
|
||||
}
|
||||
extraction := CommandMigration{
|
||||
Kind: CommandMigrationFlagExtraction,
|
||||
Legacy: CommandMigrationSide{
|
||||
Command: "dws chat group create",
|
||||
Before: CommandMigrationState{Present: true, Runnable: true},
|
||||
After: CommandMigrationState{Present: true, Runnable: true},
|
||||
},
|
||||
Replacement: CommandMigrationSide{
|
||||
Command: "dws chat topic create",
|
||||
Before: CommandMigrationState{},
|
||||
After: CommandMigrationState{Present: true, Runnable: true},
|
||||
},
|
||||
LegacyFlag: CommandMigrationFlag{
|
||||
Name: "thread",
|
||||
Before: FlagMigrationState{Present: true, Type: "bool", NoOpt: "true", Scope: "local"},
|
||||
After: FlagMigrationState{Present: true, Type: "bool", Hidden: true, NoOpt: "true", Scope: "local"},
|
||||
},
|
||||
Schema: CommandMigrationSchema{
|
||||
ProductID: "chat",
|
||||
SourceToolID: "chat.create_group",
|
||||
ReplacementToolID: "chat.create_topic",
|
||||
Parameters: []CommandParameterMigration{{From: "thread"}},
|
||||
},
|
||||
State: state,
|
||||
Reason: "Reviewed flag extraction.",
|
||||
}
|
||||
return CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{move, extraction}}
|
||||
}
|
||||
|
||||
func singleCommandMigrationManifest(state string) CommandMigrationManifest {
|
||||
manifest := commandMigrationManifest(state)
|
||||
manifest.Migrations = manifest.Migrations[:1]
|
||||
return manifest
|
||||
}
|
||||
|
||||
func modifiedCommandManifest(source CommandMigrationManifest) CommandMigrationManifest {
|
||||
modified := source
|
||||
modified.Migrations = append([]CommandMigration(nil), source.Migrations...)
|
||||
modified.Migrations[0].Reason = "Modified reason."
|
||||
return modified
|
||||
}
|
||||
|
||||
func commandMigrationSnapshot(after, removeUnrelated bool) Snapshot {
|
||||
oldFlags := []Flag{{Name: "old-id", Type: "string", Required: true}, {Name: "keep", Type: "string"}}
|
||||
groupFlags := []Flag{{Name: "thread", Type: "bool", NoOpt: "true"}}
|
||||
commands := []Command{
|
||||
testCommand("dws"),
|
||||
testCommand("dws chat group create", groupFlags...),
|
||||
testCommand("dws chat message old", oldFlags...),
|
||||
}
|
||||
if after {
|
||||
commands[1].LocalFlags[0].Hidden = true
|
||||
commands[2].Hidden = true
|
||||
if removeUnrelated {
|
||||
commands[2].LocalFlags = commands[2].LocalFlags[:1]
|
||||
}
|
||||
commands = append(commands,
|
||||
testCommand("dws chat topic create"),
|
||||
testCommand("dws chat topic new", Flag{Name: "new-id", Type: "string", Required: true}),
|
||||
)
|
||||
}
|
||||
return testSnapshot(commands...)
|
||||
}
|
||||
@@ -91,22 +91,26 @@ func validateFlagMigrationJSONSchema(data []byte) error {
|
||||
}
|
||||
|
||||
func validateMigrationJSONValue(decoder *json.Decoder, path string, schema reflect.Type) error {
|
||||
return validateLabeledMigrationJSONValue(decoder, path, schema, "flag")
|
||||
}
|
||||
|
||||
func validateLabeledMigrationJSONValue(decoder *json.Decoder, path string, schema reflect.Type, label string) error {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read flag migration manifest value at %s: %w", path, err)
|
||||
return fmt.Errorf("read %s migration manifest value at %s: %w", label, path, err)
|
||||
}
|
||||
|
||||
switch schema.Kind() {
|
||||
case reflect.Struct:
|
||||
if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' {
|
||||
return migrationJSONTypeError(path, schema, token)
|
||||
return migrationJSONTypeError(path, schema, token, label)
|
||||
}
|
||||
fields := migrationJSONFields(schema)
|
||||
seen := make(map[string]bool, len(fields))
|
||||
for decoder.More() {
|
||||
keyToken, keyErr := decoder.Token()
|
||||
if keyErr != nil {
|
||||
return fmt.Errorf("read flag migration manifest field at %s: %w", path, keyErr)
|
||||
return fmt.Errorf("read %s migration manifest field at %s: %w", label, path, keyErr)
|
||||
}
|
||||
// encoding/json guarantees object member names are string tokens.
|
||||
key := keyToken.(string)
|
||||
@@ -115,57 +119,58 @@ func validateMigrationJSONValue(decoder *json.Decoder, path string, schema refle
|
||||
for canonical := range fields {
|
||||
if strings.EqualFold(key, canonical) {
|
||||
return fmt.Errorf(
|
||||
"flag migration manifest contains non-canonical field %q at %s (want %q)",
|
||||
"%s migration manifest contains non-canonical field %q at %s (want %q)",
|
||||
label,
|
||||
key,
|
||||
path,
|
||||
canonical,
|
||||
)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("flag migration manifest contains unknown field %q at %s", key, path)
|
||||
return fmt.Errorf("%s migration manifest contains unknown field %q at %s", label, key, path)
|
||||
}
|
||||
if seen[key] {
|
||||
return fmt.Errorf("flag migration manifest contains duplicate field %q at %s", key, path)
|
||||
return fmt.Errorf("%s migration manifest contains duplicate field %q at %s", label, key, path)
|
||||
}
|
||||
seen[key] = true
|
||||
if err := validateMigrationJSONValue(decoder, path+"."+key, fieldSchema); err != nil {
|
||||
if err := validateLabeledMigrationJSONValue(decoder, path+"."+key, fieldSchema, label); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, closeErr := decoder.Token(); closeErr != nil {
|
||||
return fmt.Errorf("close flag migration manifest object at %s: %w", path, closeErr)
|
||||
return fmt.Errorf("close %s migration manifest object at %s: %w", label, path, closeErr)
|
||||
}
|
||||
return nil
|
||||
case reflect.Slice:
|
||||
if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' {
|
||||
return migrationJSONTypeError(path, schema, token)
|
||||
return migrationJSONTypeError(path, schema, token, label)
|
||||
}
|
||||
for index := 0; decoder.More(); index++ {
|
||||
if err := validateMigrationJSONValue(decoder, fmt.Sprintf("%s[%d]", path, index), schema.Elem()); err != nil {
|
||||
if err := validateLabeledMigrationJSONValue(decoder, fmt.Sprintf("%s[%d]", path, index), schema.Elem(), label); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, closeErr := decoder.Token(); closeErr != nil {
|
||||
return fmt.Errorf("close flag migration manifest array at %s: %w", path, closeErr)
|
||||
return fmt.Errorf("close %s migration manifest array at %s: %w", label, path, closeErr)
|
||||
}
|
||||
return nil
|
||||
case reflect.String:
|
||||
if _, ok := token.(string); !ok {
|
||||
return migrationJSONTypeError(path, schema, token)
|
||||
return migrationJSONTypeError(path, schema, token, label)
|
||||
}
|
||||
return nil
|
||||
case reflect.Int:
|
||||
if _, ok := token.(json.Number); !ok {
|
||||
return migrationJSONTypeError(path, schema, token)
|
||||
return migrationJSONTypeError(path, schema, token, label)
|
||||
}
|
||||
return nil
|
||||
case reflect.Bool:
|
||||
if _, ok := token.(bool); !ok {
|
||||
return migrationJSONTypeError(path, schema, token)
|
||||
return migrationJSONTypeError(path, schema, token, label)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("flag migration manifest value at %s has unsupported Go schema type %s", path, schema)
|
||||
return fmt.Errorf("%s migration manifest value at %s has unsupported Go schema type %s", label, path, schema)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,9 +193,10 @@ func migrationJSONFields(schema reflect.Type) map[string]reflect.Type {
|
||||
return fields
|
||||
}
|
||||
|
||||
func migrationJSONTypeError(path string, want reflect.Type, token json.Token) error {
|
||||
func migrationJSONTypeError(path string, want reflect.Type, token json.Token, label string) error {
|
||||
return fmt.Errorf(
|
||||
"flag migration manifest value at %s must be %s, got %s",
|
||||
"%s migration manifest value at %s must be %s, got %s",
|
||||
label,
|
||||
path,
|
||||
migrationJSONKindDescription(want),
|
||||
migrationJSONTokenDescription(token),
|
||||
|
||||
@@ -13,7 +13,9 @@ STABLE_REF=""
|
||||
CANDIDATE_REF="HEAD"
|
||||
SCHEMA_CHECKER_REL="scripts/policy/schema-compat/main.go"
|
||||
MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-flag-migrations-v1.json"
|
||||
COMMAND_MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-command-migrations-v1.json"
|
||||
MIGRATIONS_REL="internal/interfacesnapshot/migrations.go"
|
||||
COMMAND_MIGRATIONS_REL="internal/interfacesnapshot/command_migrations.go"
|
||||
ALIAS_CONTRACT_REL="internal/corecmd/runtimeannotate/interface_alias.go"
|
||||
|
||||
usage() {
|
||||
@@ -173,6 +175,31 @@ has_any_schema_migration_governance_artifact() {
|
||||
return 1
|
||||
}
|
||||
|
||||
has_complete_schema_command_migration_governance() {
|
||||
governance_commit="$1"
|
||||
has_complete_schema_migration_governance "$governance_commit" &&
|
||||
commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATIONS_REL" &&
|
||||
commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATION_MANIFEST_REL"
|
||||
}
|
||||
|
||||
has_any_schema_command_migration_governance_artifact() {
|
||||
governance_commit="$1"
|
||||
for relative_path in "$COMMAND_MIGRATIONS_REL" "$COMMAND_MIGRATION_MANIFEST_REL"; do
|
||||
if commit_path_exists "$governance_commit" "$relative_path"; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
require_complete_candidate_schema_command_governance() {
|
||||
if ! has_complete_schema_command_migration_governance "$CANDIDATE_COMMIT"; then
|
||||
printf 'error: candidate must preserve the complete Schema command migration governance artifact set at %s\n' \
|
||||
"$CANDIDATE_COMMIT" >&2
|
||||
exit 2
|
||||
fi
|
||||
}
|
||||
|
||||
check_candidate_alias_source_policy() {
|
||||
source_policy_failed=false
|
||||
for token in \
|
||||
@@ -229,6 +256,7 @@ check_candidate_alias_source_policy
|
||||
EMPTY_MANIFEST="$TMP_ROOT/empty-migrations.json"
|
||||
printf '%s\n' '{"version":1,"migrations":[]}' >"$EMPTY_MANIFEST"
|
||||
USE_MIGRATION_GOVERNANCE=false
|
||||
USE_COMMAND_MIGRATION_GOVERNANCE=false
|
||||
|
||||
if has_complete_schema_migration_governance "$BASE_COMMIT"; then
|
||||
USE_MIGRATION_GOVERNANCE=true
|
||||
@@ -252,6 +280,21 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
if has_complete_schema_command_migration_governance "$BASE_COMMIT"; then
|
||||
USE_COMMAND_MIGRATION_GOVERNANCE=true
|
||||
APPROVED_COMMAND_MANIFEST="$BASE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL"
|
||||
CANDIDATE_COMMAND_MANIFEST="$CANDIDATE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL"
|
||||
require_complete_candidate_schema_command_governance
|
||||
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' \
|
||||
"$BASE_REF" >&2
|
||||
exit 2
|
||||
elif has_any_schema_command_migration_governance_artifact "$CANDIDATE_COMMIT"; then
|
||||
# Bootstrap keeps the old base-owned checker in control and therefore grants
|
||||
# no command migration authorization in the governance PR itself.
|
||||
require_complete_candidate_schema_command_governance
|
||||
fi
|
||||
|
||||
BASE_BIN="$TMP_ROOT/base-dws"
|
||||
STABLE_BIN="$TMP_ROOT/stable-dws"
|
||||
CANDIDATE_BIN="$TMP_ROOT/candidate-dws"
|
||||
@@ -337,16 +380,28 @@ generate_interface_snapshot "$CANDIDATE_WORKTREE" "$CURRENT_INTERFACE_SNAPSHOT"
|
||||
generate_interface_snapshot "$BASE_WORKTREE" "$BASE_INTERFACE_SNAPSHOT"
|
||||
generate_interface_snapshot "$STABLE_WORKTREE" "$STABLE_INTERFACE_SNAPSHOT"
|
||||
|
||||
check_schema_contract "PR merge-base" "$BASE_REF" "$BASELINE" \
|
||||
--approved-flag-migrations "$APPROVED_MANIFEST" \
|
||||
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
|
||||
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
|
||||
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
|
||||
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
|
||||
check_with_migrations() {
|
||||
historical_kind="$1"
|
||||
historical_ref="$2"
|
||||
historical_baseline="$3"
|
||||
if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then
|
||||
check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" \
|
||||
--approved-flag-migrations "$APPROVED_MANIFEST" \
|
||||
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
|
||||
--approved-command-migrations "$APPROVED_COMMAND_MANIFEST" \
|
||||
--candidate-command-migrations "$CANDIDATE_COMMAND_MANIFEST" \
|
||||
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
|
||||
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
|
||||
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
|
||||
else
|
||||
check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" \
|
||||
--approved-flag-migrations "$APPROVED_MANIFEST" \
|
||||
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
|
||||
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
|
||||
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
|
||||
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
|
||||
fi
|
||||
}
|
||||
|
||||
check_schema_contract "stable" "$STABLE_REF" "$STABLE_BASELINE" \
|
||||
--approved-flag-migrations "$APPROVED_MANIFEST" \
|
||||
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
|
||||
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
|
||||
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
|
||||
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
|
||||
check_with_migrations "PR merge-base" "$BASE_REF" "$BASELINE"
|
||||
check_with_migrations "stable" "$STABLE_REF" "$STABLE_BASELINE"
|
||||
|
||||
@@ -12,6 +12,8 @@ BASE_REF=""
|
||||
STABLE_REF=""
|
||||
CANDIDATE_REF="HEAD"
|
||||
MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-flag-migrations-v1.json"
|
||||
COMMAND_MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-command-migrations-v1.json"
|
||||
COMMAND_MIGRATIONS_REL="internal/interfacesnapshot/command_migrations.go"
|
||||
ALIAS_CONTRACT_REL="internal/corecmd/runtimeannotate/interface_alias.go"
|
||||
|
||||
usage() {
|
||||
@@ -166,6 +168,31 @@ require_complete_candidate_governance() {
|
||||
fi
|
||||
}
|
||||
|
||||
has_complete_command_migration_governance() {
|
||||
governance_commit="$1"
|
||||
has_complete_migration_governance "$governance_commit" &&
|
||||
commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATIONS_REL" &&
|
||||
commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATION_MANIFEST_REL"
|
||||
}
|
||||
|
||||
has_any_command_migration_governance_artifact() {
|
||||
governance_commit="$1"
|
||||
for relative_path in "$COMMAND_MIGRATIONS_REL" "$COMMAND_MIGRATION_MANIFEST_REL"; do
|
||||
if commit_path_exists "$governance_commit" "$relative_path"; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
require_complete_candidate_command_governance() {
|
||||
if ! has_complete_command_migration_governance "$CANDIDATE_COMMIT"; then
|
||||
printf 'candidate must preserve the complete command migration governance artifact set at %s\n' \
|
||||
"$CANDIDATE_COMMIT" >&2
|
||||
exit 2
|
||||
fi
|
||||
}
|
||||
|
||||
check_candidate_alias_source_policy() {
|
||||
source_policy_failed=false
|
||||
for token in \
|
||||
@@ -225,6 +252,7 @@ install_authority_helper() {
|
||||
EMPTY_MANIFEST="$TMP_ROOT/empty-migrations.json"
|
||||
printf '%s\n' '{"version":1,"migrations":[]}' >"$EMPTY_MANIFEST"
|
||||
USE_MIGRATION_GOVERNANCE=false
|
||||
USE_COMMAND_MIGRATION_GOVERNANCE=false
|
||||
|
||||
if has_complete_migration_governance "$BASE_COMMIT"; then
|
||||
USE_MIGRATION_GOVERNANCE=true
|
||||
@@ -260,6 +288,21 @@ else
|
||||
install_authority_helper "$AUTHORITY_ROOT" "$CANDIDATE_WORKTREE" false
|
||||
fi
|
||||
|
||||
if has_complete_command_migration_governance "$BASE_COMMIT"; then
|
||||
USE_COMMAND_MIGRATION_GOVERNANCE=true
|
||||
APPROVED_COMMAND_MANIFEST="$BASE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL"
|
||||
CANDIDATE_COMMAND_MANIFEST="$CANDIDATE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL"
|
||||
require_complete_candidate_command_governance
|
||||
elif has_any_command_migration_governance_artifact "$BASE_COMMIT"; then
|
||||
printf 'merge-base contains an incomplete command migration governance artifact set: %s\n' \
|
||||
"$BASE_REF" >&2
|
||||
exit 2
|
||||
elif has_any_command_migration_governance_artifact "$CANDIDATE_COMMIT"; then
|
||||
# Bootstrap is safe because the base-owned comparator receives no new
|
||||
# authorization input and still performs the ordinary compatibility check.
|
||||
require_complete_candidate_command_governance
|
||||
fi
|
||||
|
||||
install_authority_helper "$AUTHORITY_ROOT" "$BASE_WORKTREE" "$USE_MIGRATION_GOVERNANCE"
|
||||
install_authority_helper "$AUTHORITY_ROOT" "$STABLE_WORKTREE" "$USE_MIGRATION_GOVERNANCE"
|
||||
|
||||
@@ -280,7 +323,19 @@ generate_snapshot "$CANDIDATE_WORKTREE" "$CANDIDATE"
|
||||
generate_snapshot "$BASE_WORKTREE" "$BASELINE"
|
||||
generate_snapshot "$STABLE_WORKTREE" "$STABLE"
|
||||
|
||||
if [ "$USE_MIGRATION_GOVERNANCE" = true ]; then
|
||||
if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then
|
||||
(
|
||||
cd "$AUTHORITY_ROOT"
|
||||
go run ./cmd/interface-snapshot compare \
|
||||
--current "$CANDIDATE" \
|
||||
--base "$BASELINE" \
|
||||
--stable "$STABLE" \
|
||||
--approved-flag-migrations "$APPROVED_MANIFEST" \
|
||||
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
|
||||
--approved-command-migrations "$APPROVED_COMMAND_MANIFEST" \
|
||||
--candidate-command-migrations "$CANDIDATE_COMMAND_MANIFEST"
|
||||
)
|
||||
elif [ "$USE_MIGRATION_GOVERNANCE" = true ]; then
|
||||
(
|
||||
cd "$AUTHORITY_ROOT"
|
||||
go run ./cmd/interface-snapshot compare \
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"version": 1,
|
||||
"migrations": [
|
||||
{
|
||||
"kind": "flag_extraction",
|
||||
"legacy": {
|
||||
"command": "dws chat group create",
|
||||
"before": {"present": true, "runnable": true},
|
||||
"after": {"present": true, "runnable": true}
|
||||
},
|
||||
"replacement": {
|
||||
"command": "dws chat topic create",
|
||||
"before": {"present": false},
|
||||
"after": {"present": true, "runnable": true}
|
||||
},
|
||||
"legacy_flag": {
|
||||
"name": "thread",
|
||||
"before": {"present": true, "type": "bool", "no_opt": "true", "scope": "local"},
|
||||
"after": {"present": true, "type": "bool", "hidden": true, "no_opt": "true", "scope": "local"}
|
||||
},
|
||||
"schema": {
|
||||
"product_id": "chat",
|
||||
"source_tool_id": "chat.create_group_conversation",
|
||||
"replacement_tool_id": "chat.create_topic_conversation",
|
||||
"parameters": [{"from": "thread"}]
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Move topic-conversation creation to the dedicated chat topic command while retaining the legacy --thread execution path."
|
||||
},
|
||||
{
|
||||
"kind": "command_move",
|
||||
"legacy": {
|
||||
"command": "dws chat message list-topic-replies",
|
||||
"before": {"present": true, "runnable": true},
|
||||
"after": {"present": true, "runnable": true, "hidden": true}
|
||||
},
|
||||
"replacement": {
|
||||
"command": "dws chat topic list-replies",
|
||||
"before": {"present": false},
|
||||
"after": {"present": true, "runnable": true}
|
||||
},
|
||||
"schema": {
|
||||
"product_id": "chat",
|
||||
"source_tool_id": "chat.list_topic_replies",
|
||||
"replacement_tool_id": "chat.list_topic_replies",
|
||||
"parameters": [
|
||||
{"from": "conversation-id", "to": "open-topic-id"},
|
||||
{"from": "topic-id", "to": "open-conv-thread-id"}
|
||||
]
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Move topic reply listing from chat message to the dedicated chat topic command while preserving the legacy executable command."
|
||||
},
|
||||
{
|
||||
"kind": "command_move",
|
||||
"legacy": {
|
||||
"command": "dws chat message forward-topic",
|
||||
"before": {"present": true, "runnable": true},
|
||||
"after": {"present": true, "runnable": true, "hidden": true}
|
||||
},
|
||||
"replacement": {
|
||||
"command": "dws chat topic forward",
|
||||
"before": {"present": false},
|
||||
"after": {"present": true, "runnable": true}
|
||||
},
|
||||
"schema": {
|
||||
"product_id": "chat",
|
||||
"source_tool_id": "chat.forward_topic",
|
||||
"replacement_tool_id": "chat.forward_topic",
|
||||
"parameters": [
|
||||
{"from": "src-conversation-id", "to": "src-open-topic-id"},
|
||||
{"from": "src-thread-id", "to": "src-open-conv-thread-id"},
|
||||
{"from": "dest-conversation-id", "to": "dest-open-conversation-id"}
|
||||
]
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Move topic forwarding from chat message to the dedicated chat topic command while preserving the legacy executable command."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSchemaCommandMigrationsAuthorizeOnlyExactProjection(t *testing.T) {
|
||||
baseline := schemaCommandMigrationContract(false)
|
||||
current := schemaCommandMigrationContract(true)
|
||||
migrations := schemaCommandMigrationAuthorizations()
|
||||
normalized, err := normalizeSchemaCommandMigrations(baseline, current, migrations)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize exact command migrations: %v", err)
|
||||
}
|
||||
if failures := checkCompatibility(normalized, current); len(failures) != 0 {
|
||||
t.Fatalf("exact command migrations remained incompatible: %v", failures)
|
||||
}
|
||||
|
||||
unrelated := cloneContract(current)
|
||||
product := unrelated.Products["chat"]
|
||||
tool := product.Tools["chat.move"]
|
||||
delete(tool.Parameters, "keep")
|
||||
product.Tools["chat.move"] = tool
|
||||
unrelated.Products["chat"] = product
|
||||
normalized, err = normalizeSchemaCommandMigrations(baseline, unrelated, migrations)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failures := strings.Join(checkCompatibility(normalized, unrelated), "\n"); !strings.Contains(failures, `lost parameter "keep"`) {
|
||||
t.Fatalf("unrelated parameter removal was hidden: %s", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaCommandMigrationsFailClosedOnDrift(t *testing.T) {
|
||||
baseline := schemaCommandMigrationContract(false)
|
||||
migrations := schemaCommandMigrationAuthorizations()
|
||||
|
||||
parameterDrift := schemaCommandMigrationContract(true)
|
||||
product := parameterDrift.Products["chat"]
|
||||
tool := product.Tools["chat.move"]
|
||||
parameter := tool.Parameters["new-id"]
|
||||
parameter.Property = "differentProperty"
|
||||
tool.Parameters["new-id"] = parameter
|
||||
product.Tools["chat.move"] = tool
|
||||
parameterDrift.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, parameterDrift, migrations); err == nil || !strings.Contains(err.Error(), "changed a non-name field") {
|
||||
t.Fatalf("parameter drift error=%v", err)
|
||||
}
|
||||
|
||||
safetyDrift := schemaCommandMigrationContract(true)
|
||||
product = safetyDrift.Products["chat"]
|
||||
replacement := product.Tools["chat.create_topic"]
|
||||
replacement.Risk = "high"
|
||||
product.Tools["chat.create_topic"] = replacement
|
||||
safetyDrift.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, safetyDrift, migrations); err == nil || !strings.Contains(err.Error(), "changed interface or safety identity") {
|
||||
t.Fatalf("extraction safety drift error=%v", err)
|
||||
}
|
||||
|
||||
missingReplacement := schemaCommandMigrationContract(true)
|
||||
product = missingReplacement.Products["chat"]
|
||||
delete(product.Tools, "chat.create_topic")
|
||||
missingReplacement.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, missingReplacement, migrations); err == nil || !strings.Contains(err.Error(), "lacks replacement Schema tool") {
|
||||
t.Fatalf("missing replacement error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaCommandMigrationNormalizationEdges(t *testing.T) {
|
||||
baseline := schemaCommandMigrationContract(false)
|
||||
current := schemaCommandMigrationContract(true)
|
||||
migrations := schemaCommandMigrationAuthorizations()
|
||||
|
||||
missingHistorical := cloneContract(baseline)
|
||||
product := missingHistorical.Products["chat"]
|
||||
delete(product.Tools, "chat.move")
|
||||
missingHistorical.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(missingHistorical, current, migrations); err != nil {
|
||||
t.Fatalf("missing historical tool should be a no-op: %v", err)
|
||||
}
|
||||
|
||||
missingCurrent := cloneContract(current)
|
||||
product = missingCurrent.Products["chat"]
|
||||
delete(product.Tools, "chat.move")
|
||||
missingCurrent.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, missingCurrent, migrations); err != nil {
|
||||
t.Fatalf("missing current source should remain for ordinary checker: %v", err)
|
||||
}
|
||||
|
||||
alreadyAfter := schemaCommandMigrationContract(true)
|
||||
if _, err := normalizeSchemaCommandMigrations(alreadyAfter, current, migrations[:1]); err != nil {
|
||||
t.Fatalf("already-after baseline should be a no-op: %v", err)
|
||||
}
|
||||
|
||||
wrongHistoricalPath := cloneContract(baseline)
|
||||
product = wrongHistoricalPath.Products["chat"]
|
||||
tool := product.Tools["chat.move"]
|
||||
tool.PrimaryCLIPath = "chat unrelated"
|
||||
product.Tools["chat.move"] = tool
|
||||
wrongHistoricalPath.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(wrongHistoricalPath, current, migrations); err == nil || !strings.Contains(err.Error(), "historical Schema tool") {
|
||||
t.Fatalf("wrong historical path error=%v", err)
|
||||
}
|
||||
|
||||
wrongCurrentPath := cloneContract(current)
|
||||
product = wrongCurrentPath.Products["chat"]
|
||||
tool = product.Tools["chat.move"]
|
||||
tool.PrimaryCLIPath = "chat unrelated"
|
||||
product.Tools["chat.move"] = tool
|
||||
wrongCurrentPath.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, wrongCurrentPath, migrations); err != nil {
|
||||
t.Fatalf("wrong current path should remain for ordinary checker: %v", err)
|
||||
}
|
||||
|
||||
missingHistoricalParameter := cloneContract(baseline)
|
||||
product = missingHistoricalParameter.Products["chat"]
|
||||
tool = product.Tools["chat.move"]
|
||||
delete(tool.Parameters, "old-id")
|
||||
product.Tools["chat.move"] = tool
|
||||
missingHistoricalParameter.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(missingHistoricalParameter, current, migrations); err == nil || !strings.Contains(err.Error(), "lacks parameter") {
|
||||
t.Fatalf("missing historical parameter error=%v", err)
|
||||
}
|
||||
|
||||
legacyPublished := cloneContract(current)
|
||||
product = legacyPublished.Products["chat"]
|
||||
tool = product.Tools["chat.move"]
|
||||
tool.Parameters["old-id"] = baseline.Products["chat"].Tools["chat.move"].Parameters["old-id"]
|
||||
product.Tools["chat.move"] = tool
|
||||
legacyPublished.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, legacyPublished, migrations); err == nil || !strings.Contains(err.Error(), "still publishes legacy") {
|
||||
t.Fatalf("legacy parameter error=%v", err)
|
||||
}
|
||||
|
||||
missingReplacementParameter := cloneContract(current)
|
||||
product = missingReplacementParameter.Products["chat"]
|
||||
tool = product.Tools["chat.move"]
|
||||
delete(tool.Parameters, "new-id")
|
||||
product.Tools["chat.move"] = tool
|
||||
missingReplacementParameter.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, missingReplacementParameter, migrations); err == nil || !strings.Contains(err.Error(), "does not publish replacement") {
|
||||
t.Fatalf("missing replacement parameter error=%v", err)
|
||||
}
|
||||
|
||||
constraintDrift := cloneContract(current)
|
||||
product = constraintDrift.Products["chat"]
|
||||
tool = product.Tools["chat.move"]
|
||||
tool.Constraints = `{"require_one_of":[["new-id"]]}`
|
||||
product.Tools["chat.move"] = tool
|
||||
constraintDrift.Products["chat"] = product
|
||||
normalized, err := normalizeSchemaCommandMigrations(baseline, constraintDrift, migrations)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failures := strings.Join(checkCompatibility(normalized, constraintDrift), "\n"); !strings.Contains(failures, "changed constraints") {
|
||||
t.Fatalf("constraint drift was hidden: %s", failures)
|
||||
}
|
||||
|
||||
extractionWrongSource := cloneContract(current)
|
||||
product = extractionWrongSource.Products["chat"]
|
||||
tool = product.Tools["chat.create_group"]
|
||||
tool.PrimaryCLIPath = "chat unrelated"
|
||||
product.Tools["chat.create_group"] = tool
|
||||
extractionWrongSource.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, extractionWrongSource, migrations[1:]); err != nil {
|
||||
t.Fatalf("wrong extraction source path should remain for ordinary checker: %v", err)
|
||||
}
|
||||
|
||||
extractionHistoricalMissing := cloneContract(baseline)
|
||||
product = extractionHistoricalMissing.Products["chat"]
|
||||
tool = product.Tools["chat.create_group"]
|
||||
delete(tool.Parameters, "thread")
|
||||
product.Tools["chat.create_group"] = tool
|
||||
extractionHistoricalMissing.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(extractionHistoricalMissing, current, migrations[1:]); err == nil || !strings.Contains(err.Error(), "historical Schema tool lacks") {
|
||||
t.Fatalf("missing extracted historical parameter error=%v", err)
|
||||
}
|
||||
|
||||
extractionStillPublished := cloneContract(current)
|
||||
product = extractionStillPublished.Products["chat"]
|
||||
tool = product.Tools["chat.create_group"]
|
||||
tool.Parameters["thread"] = baseline.Products["chat"].Tools["chat.create_group"].Parameters["thread"]
|
||||
product.Tools["chat.create_group"] = tool
|
||||
extractionStillPublished.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, extractionStillPublished, migrations[1:]); err == nil || !strings.Contains(err.Error(), "still publishes extracted") {
|
||||
t.Fatalf("still-published extraction error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaCommandMigrationLifecycleAndRun(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
baselinePath := filepath.Join(directory, "baseline.json")
|
||||
currentPath := filepath.Join(directory, "current.json")
|
||||
approvedPath := filepath.Join(directory, "approved.json")
|
||||
candidatePath := filepath.Join(directory, "candidate.json")
|
||||
currentSnapshotPath := filepath.Join(directory, "current-snapshot.json")
|
||||
baseSnapshotPath := filepath.Join(directory, "base-snapshot.json")
|
||||
stableSnapshotPath := filepath.Join(directory, "stable-snapshot.json")
|
||||
|
||||
writeSchemaContractFile(t, baselinePath, schemaCommandMigrationContract(false))
|
||||
writeRawSchemaContractFile(t, currentPath, schemaCommandMigrationContract(true))
|
||||
writeCommandMigrationManifestFile(t, approvedPath, schemaCommandMigrationManifest(interfacesnapshot.CommandMigrationPending))
|
||||
writeCommandMigrationManifestFile(t, candidatePath, schemaCommandMigrationManifest(interfacesnapshot.CommandMigrationConsumed))
|
||||
writeInterfaceSnapshotFile(t, currentSnapshotPath, schemaCommandMigrationSnapshot(true))
|
||||
writeInterfaceSnapshotFile(t, baseSnapshotPath, schemaCommandMigrationSnapshot(false))
|
||||
writeInterfaceSnapshotFile(t, stableSnapshotPath, schemaCommandMigrationSnapshot(false))
|
||||
|
||||
args := []string{
|
||||
"--check", baselinePath,
|
||||
"--current", currentPath,
|
||||
"--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("command migration run code=%d stderr=%s", code, stderr.String())
|
||||
}
|
||||
|
||||
paths := []string{approvedPath, candidatePath, 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 {
|
||||
t.Fatalf("missing command migration input %d was accepted", index)
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
stderr.Reset()
|
||||
if code := run([]string{
|
||||
"--check", baselinePath,
|
||||
"--current", currentPath,
|
||||
"--approved-command-migrations", approvedPath,
|
||||
"--candidate-command-migrations", candidatePath,
|
||||
}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "all three interface snapshots") {
|
||||
t.Fatalf("missing command snapshots code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
stderr.Reset()
|
||||
if code := run([]string{"--check", baselinePath, "--current", currentPath, "--migration-current-snapshot", currentSnapshotPath}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "require a flag or command") {
|
||||
t.Fatalf("orphan snapshot code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
|
||||
badArgs := append([]string(nil), args...)
|
||||
badArgs[5] = filepath.Join(directory, "missing-approved.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())
|
||||
}
|
||||
|
||||
legacyCurrent := schemaCommandMigrationContract(true)
|
||||
product := legacyCurrent.Products["chat"]
|
||||
tool := product.Tools["chat.move"]
|
||||
tool.Parameters["old-id"] = schemaCommandMigrationContract(false).Products["chat"].Tools["chat.move"].Parameters["old-id"]
|
||||
product.Tools["chat.move"] = tool
|
||||
legacyCurrent.Products["chat"] = product
|
||||
writeRawSchemaContractFile(t, currentPath, legacyCurrent)
|
||||
stderr.Reset()
|
||||
if code := run(args, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "normalize approved Schema command migrations") {
|
||||
t.Fatalf("command normalization error code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func schemaCommandMigrationContract(after bool) schemaContract {
|
||||
id := parameterSchema{Type: `"string"`, Property: "resourceId", Required: true, CLIRequired: true}
|
||||
keep := parameterSchema{Type: `"string"`, Property: "keep"}
|
||||
thread := parameterSchema{Type: `"boolean"`, Property: "threadEnabled"}
|
||||
group := toolSchema{
|
||||
PrimaryCLIPath: "chat group create",
|
||||
InterfaceMode: "mcp",
|
||||
InterfaceRef: `{"product_id":"im","rpc_name":"create_group"}`,
|
||||
Availability: "available",
|
||||
Parameters: map[string]parameterSchema{"name": keep, "thread": thread},
|
||||
Effect: "write",
|
||||
Risk: "medium",
|
||||
Confirmation: "not_required",
|
||||
Idempotency: "unknown",
|
||||
}
|
||||
move := toolSchema{
|
||||
PrimaryCLIPath: "chat message old",
|
||||
InterfaceMode: "mcp",
|
||||
InterfaceRef: `{"product_id":"chat","rpc_name":"move"}`,
|
||||
Availability: "available",
|
||||
Parameters: map[string]parameterSchema{"old-id": id, "keep": keep},
|
||||
Constraints: `{"require_together":[["keep","old-id"]]}`,
|
||||
Effect: "read",
|
||||
Risk: "low",
|
||||
Confirmation: "not_required",
|
||||
Idempotency: "idempotent",
|
||||
}
|
||||
tools := map[string]toolSchema{"chat.create_group": group, "chat.move": move}
|
||||
if after {
|
||||
delete(group.Parameters, "thread")
|
||||
tools["chat.create_group"] = group
|
||||
replacement := group
|
||||
replacement.PrimaryCLIPath = "chat topic create"
|
||||
tools["chat.create_topic"] = replacement
|
||||
delete(move.Parameters, "old-id")
|
||||
move.Parameters["new-id"] = id
|
||||
move.PrimaryCLIPath = "chat topic new"
|
||||
move.Constraints = `{"require_together":[["keep","new-id"]]}`
|
||||
tools["chat.move"] = move
|
||||
}
|
||||
return schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{"chat": {Tools: tools}}}
|
||||
}
|
||||
|
||||
func schemaCommandMigrationAuthorizations() []interfacesnapshot.CommandMigration {
|
||||
return []interfacesnapshot.CommandMigration{
|
||||
{
|
||||
Kind: interfacesnapshot.CommandMigrationMove,
|
||||
Legacy: interfacesnapshot.CommandMigrationSide{
|
||||
Command: "dws chat message old",
|
||||
Before: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
|
||||
After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true, Hidden: true},
|
||||
},
|
||||
Replacement: interfacesnapshot.CommandMigrationSide{
|
||||
Command: "dws chat topic new",
|
||||
After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
|
||||
},
|
||||
Schema: interfacesnapshot.CommandMigrationSchema{
|
||||
ProductID: "chat",
|
||||
SourceToolID: "chat.move",
|
||||
ReplacementToolID: "chat.move",
|
||||
Parameters: []interfacesnapshot.CommandParameterMigration{{From: "old-id", To: "new-id"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Kind: interfacesnapshot.CommandMigrationFlagExtraction,
|
||||
Legacy: interfacesnapshot.CommandMigrationSide{
|
||||
Command: "dws chat group create",
|
||||
Before: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
|
||||
After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
|
||||
},
|
||||
Replacement: interfacesnapshot.CommandMigrationSide{
|
||||
Command: "dws chat topic create",
|
||||
After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
|
||||
},
|
||||
LegacyFlag: interfacesnapshot.CommandMigrationFlag{
|
||||
Name: "thread",
|
||||
Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "bool", Scope: "local"},
|
||||
After: interfacesnapshot.FlagMigrationState{Present: true, Type: "bool", Hidden: true, Scope: "local"},
|
||||
},
|
||||
Schema: interfacesnapshot.CommandMigrationSchema{
|
||||
ProductID: "chat",
|
||||
SourceToolID: "chat.create_group",
|
||||
ReplacementToolID: "chat.create_topic",
|
||||
Parameters: []interfacesnapshot.CommandParameterMigration{{From: "thread"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func schemaCommandMigrationManifest(state string) interfacesnapshot.CommandMigrationManifest {
|
||||
migrations := schemaCommandMigrationAuthorizations()
|
||||
for index := range migrations {
|
||||
migrations[index].State = state
|
||||
migrations[index].Reason = "Reviewed Schema command migration."
|
||||
}
|
||||
return interfacesnapshot.CommandMigrationManifest{
|
||||
Version: interfacesnapshot.CommandMigrationManifestVersion,
|
||||
Migrations: migrations,
|
||||
}
|
||||
}
|
||||
|
||||
func schemaCommandMigrationSnapshot(after bool) interfacesnapshot.Snapshot {
|
||||
commands := []interfacesnapshot.Command{
|
||||
{Path: "dws", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
|
||||
{
|
||||
Path: "dws chat group create",
|
||||
Runnable: true,
|
||||
Aliases: []string{},
|
||||
LocalFlags: []interfacesnapshot.Flag{{
|
||||
Name: "thread", Type: "bool",
|
||||
}},
|
||||
InheritedFlags: []interfacesnapshot.Flag{},
|
||||
},
|
||||
{Path: "dws chat message old", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
|
||||
}
|
||||
if after {
|
||||
commands[1].LocalFlags[0].Hidden = true
|
||||
commands[2].Hidden = true
|
||||
commands = append(commands,
|
||||
interfacesnapshot.Command{Path: "dws chat topic create", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
|
||||
interfacesnapshot.Command{Path: "dws chat topic new", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
|
||||
)
|
||||
}
|
||||
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 writeCommandMigrationManifestFile(t *testing.T, path string, manifest interfacesnapshot.CommandMigrationManifest) {
|
||||
t.Helper()
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.NewEncoder(file).Encode(manifest); err != nil {
|
||||
_ = file.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,7 @@ func main() {
|
||||
func run(args []string, stdout, stderr io.Writer) int {
|
||||
var normalizePath, checkPath, mergePath, currentPath string
|
||||
var approvedFlagMigrationsPath, candidateFlagMigrationsPath string
|
||||
var approvedCommandMigrationsPath, candidateCommandMigrationsPath string
|
||||
var migrationCurrentSnapshotPath, migrationBaseSnapshotPath, migrationStableSnapshotPath string
|
||||
flags := flag.NewFlagSet("schema-compat", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
@@ -95,6 +96,8 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
flags.StringVar(¤tPath, "current", "", "raw current complete Schema response")
|
||||
flags.StringVar(&approvedFlagMigrationsPath, "approved-flag-migrations", "", "base-owned approved flag migration manifest")
|
||||
flags.StringVar(&candidateFlagMigrationsPath, "candidate-flag-migrations", "", "detached candidate flag migration manifest")
|
||||
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(&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")
|
||||
@@ -112,25 +115,38 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
fmt.Fprintln(stderr, "exactly one of --normalize, --check, or --merge is required")
|
||||
return 2
|
||||
}
|
||||
migrationInputs := []string{
|
||||
approvedFlagMigrationsPath,
|
||||
candidateFlagMigrationsPath,
|
||||
flagMigrationPair := approvedFlagMigrationsPath != "" || candidateFlagMigrationsPath != ""
|
||||
commandMigrationPair := approvedCommandMigrationsPath != "" || candidateCommandMigrationsPath != ""
|
||||
if flagMigrationPair && (approvedFlagMigrationsPath == "" || candidateFlagMigrationsPath == "") {
|
||||
fmt.Fprintln(stderr, "Schema flag migration authorization requires both flag manifests")
|
||||
return 2
|
||||
}
|
||||
if commandMigrationPair && (approvedCommandMigrationsPath == "" || candidateCommandMigrationsPath == "") {
|
||||
fmt.Fprintln(stderr, "Schema command migration authorization requires both command manifests")
|
||||
return 2
|
||||
}
|
||||
migrationSnapshots := []string{
|
||||
migrationCurrentSnapshotPath,
|
||||
migrationBaseSnapshotPath,
|
||||
migrationStableSnapshotPath,
|
||||
}
|
||||
migrationInputCount := 0
|
||||
for _, path := range migrationInputs {
|
||||
migrationSnapshotCount := 0
|
||||
for _, path := range migrationSnapshots {
|
||||
if path != "" {
|
||||
migrationInputCount++
|
||||
migrationSnapshotCount++
|
||||
}
|
||||
}
|
||||
if migrationInputCount != 0 && migrationInputCount != len(migrationInputs) {
|
||||
fmt.Fprintln(stderr, "Schema flag migration authorization requires all five migration inputs")
|
||||
migrationsEnabled := flagMigrationPair || commandMigrationPair
|
||||
if migrationsEnabled && migrationSnapshotCount != len(migrationSnapshots) {
|
||||
fmt.Fprintln(stderr, "Schema migration authorization requires all three interface snapshots")
|
||||
return 2
|
||||
}
|
||||
if migrationInputCount != 0 && checkPath == "" {
|
||||
fmt.Fprintln(stderr, "Schema flag migration authorization is only valid with --check")
|
||||
if !migrationsEnabled && migrationSnapshotCount != 0 {
|
||||
fmt.Fprintln(stderr, "Schema migration snapshots require a flag or command migration manifest pair")
|
||||
return 2
|
||||
}
|
||||
if migrationsEnabled && checkPath == "" {
|
||||
fmt.Fprintln(stderr, "Schema migration authorization is only valid with --check")
|
||||
return 2
|
||||
}
|
||||
|
||||
@@ -159,7 +175,7 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
fmt.Fprintf(stderr, "read schema baseline: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
if migrationInputCount != 0 {
|
||||
if flagMigrationPair {
|
||||
migrations, err := authorizeSchemaFlagMigrations(
|
||||
approvedFlagMigrationsPath,
|
||||
candidateFlagMigrationsPath,
|
||||
@@ -177,6 +193,24 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
return 2
|
||||
}
|
||||
}
|
||||
if commandMigrationPair {
|
||||
migrations, err := authorizeSchemaCommandMigrations(
|
||||
approvedCommandMigrationsPath,
|
||||
candidateCommandMigrationsPath,
|
||||
migrationCurrentSnapshotPath,
|
||||
migrationBaseSnapshotPath,
|
||||
migrationStableSnapshotPath,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "authorize Schema command migrations: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
baseline, err = normalizeSchemaCommandMigrations(baseline, current, migrations)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "normalize approved Schema command migrations: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
failures := checkCompatibility(baseline, current)
|
||||
if len(failures) > 0 {
|
||||
fmt.Fprintln(stderr, "Schema backwards-compatibility check failed:")
|
||||
@@ -251,6 +285,49 @@ func readFlagMigrationManifestFile(path string) (interfacesnapshot.FlagMigration
|
||||
return interfacesnapshot.ReadFlagMigrationManifest(bytes.NewReader(data))
|
||||
}
|
||||
|
||||
func authorizeSchemaCommandMigrations(
|
||||
approvedManifestPath string,
|
||||
candidateManifestPath string,
|
||||
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)
|
||||
}
|
||||
base, err := readInterfaceSnapshotFile(baseSnapshotPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migration base snapshot: %w", err)
|
||||
}
|
||||
stable, err := readInterfaceSnapshotFile(stableSnapshotPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migration stable snapshot: %w", err)
|
||||
}
|
||||
return interfacesnapshot.AuthorizeCommandMigrations(
|
||||
current,
|
||||
map[string]interfacesnapshot.Snapshot{"merge-base": base, "stable": stable},
|
||||
approved,
|
||||
candidate,
|
||||
)
|
||||
}
|
||||
|
||||
func readCommandMigrationManifestFile(path string) (interfacesnapshot.CommandMigrationManifest, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return interfacesnapshot.CommandMigrationManifest{}, err
|
||||
}
|
||||
return interfacesnapshot.ReadCommandMigrationManifest(bytes.NewReader(data))
|
||||
}
|
||||
|
||||
func readInterfaceSnapshotFile(path string) (interfacesnapshot.Snapshot, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -1290,6 +1367,169 @@ func normalizeSchemaFlagMigrations(
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// normalizeSchemaCommandMigrations projects only the Schema consequences that
|
||||
// are coupled to an already-authorized CLI command migration. It rewrites a
|
||||
// cloned historical contract; the ordinary checker still rejects every field
|
||||
// not proven equivalent here.
|
||||
func normalizeSchemaCommandMigrations(
|
||||
baseline schemaContract,
|
||||
current schemaContract,
|
||||
migrations []interfacesnapshot.CommandMigration,
|
||||
) (schemaContract, error) {
|
||||
normalized := cloneContract(baseline)
|
||||
for _, migration := range migrations {
|
||||
oldProduct, productExists := baseline.Products[migration.Schema.ProductID]
|
||||
oldTool, toolExists := oldProduct.Tools[migration.Schema.SourceToolID]
|
||||
if !productExists || !toolExists {
|
||||
// The historical baseline predates this Schema tool, so it has no
|
||||
// compatibility surface for this migration.
|
||||
continue
|
||||
}
|
||||
newProduct, productExists := current.Products[migration.Schema.ProductID]
|
||||
newSource, sourceExists := newProduct.Tools[migration.Schema.SourceToolID]
|
||||
if !productExists || !sourceExists {
|
||||
// Preserve the baseline so the ordinary checker reports the removal.
|
||||
continue
|
||||
}
|
||||
legacyPath := strings.TrimPrefix(migration.Legacy.Command, "dws ")
|
||||
replacementPath := strings.TrimPrefix(migration.Replacement.Command, "dws ")
|
||||
if oldTool.PrimaryCLIPath == replacementPath {
|
||||
// A consumed receipt can still be needed for the stable baseline after
|
||||
// main has already reached the after state.
|
||||
continue
|
||||
}
|
||||
if oldTool.PrimaryCLIPath != legacyPath {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %s historical Schema tool %q has primary_cli_path %q",
|
||||
migration.Kind,
|
||||
migration.Schema.SourceToolID,
|
||||
oldTool.PrimaryCLIPath,
|
||||
)
|
||||
}
|
||||
|
||||
normalizedProduct := normalized.Products[migration.Schema.ProductID]
|
||||
normalizedTool := normalizedProduct.Tools[migration.Schema.SourceToolID]
|
||||
switch migration.Kind {
|
||||
case interfacesnapshot.CommandMigrationMove:
|
||||
if newSource.PrimaryCLIPath != replacementPath {
|
||||
continue
|
||||
}
|
||||
renames := make(map[string]string, len(migration.Schema.Parameters))
|
||||
for _, parameter := range migration.Schema.Parameters {
|
||||
oldParameter, existed := oldTool.Parameters[parameter.From]
|
||||
if !existed {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %q historical Schema tool lacks parameter %q",
|
||||
migration.Legacy.Command,
|
||||
parameter.From,
|
||||
)
|
||||
}
|
||||
if _, exists := newSource.Parameters[parameter.From]; exists {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %q still publishes legacy Schema parameter %q",
|
||||
migration.Legacy.Command,
|
||||
parameter.From,
|
||||
)
|
||||
}
|
||||
newParameter, exists := newSource.Parameters[parameter.To]
|
||||
if !exists {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %q does not publish replacement Schema parameter %q",
|
||||
migration.Replacement.Command,
|
||||
parameter.To,
|
||||
)
|
||||
}
|
||||
if err := validateEquivalentCommandSchemaParameter(migration, parameter, oldParameter, newParameter); err != nil {
|
||||
return schemaContract{}, err
|
||||
}
|
||||
delete(normalizedTool.Parameters, parameter.From)
|
||||
normalizedTool.Parameters[parameter.To] = newParameter
|
||||
renames[parameter.From] = parameter.To
|
||||
}
|
||||
if oldTool.Constraints != newSource.Constraints {
|
||||
oldConstraints, oldOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames)
|
||||
newConstraints, newOK := canonicalizeMigratedConstraints(newSource.Constraints, nil)
|
||||
if oldOK && newOK && oldConstraints == newConstraints {
|
||||
normalizedTool.Constraints = newSource.Constraints
|
||||
}
|
||||
}
|
||||
normalizedTool.PrimaryCLIPath = replacementPath
|
||||
|
||||
case interfacesnapshot.CommandMigrationFlagExtraction:
|
||||
if newSource.PrimaryCLIPath != legacyPath {
|
||||
continue
|
||||
}
|
||||
replacement, exists := newProduct.Tools[migration.Schema.ReplacementToolID]
|
||||
if !exists || replacement.PrimaryCLIPath != replacementPath {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved flag extraction %q lacks replacement Schema tool %q at %q",
|
||||
migration.Legacy.Command,
|
||||
migration.Schema.ReplacementToolID,
|
||||
replacementPath,
|
||||
)
|
||||
}
|
||||
if oldTool.InterfaceMode != replacement.InterfaceMode ||
|
||||
oldTool.InterfaceRef != replacement.InterfaceRef ||
|
||||
oldTool.Availability != replacement.Availability ||
|
||||
oldTool.Effect != replacement.Effect ||
|
||||
oldTool.Risk != replacement.Risk ||
|
||||
oldTool.Confirmation != replacement.Confirmation ||
|
||||
oldTool.Idempotency != replacement.Idempotency {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved flag extraction %q replacement Schema tool changed interface or safety identity",
|
||||
migration.Legacy.Command,
|
||||
)
|
||||
}
|
||||
for _, parameter := range migration.Schema.Parameters {
|
||||
if _, existed := oldTool.Parameters[parameter.From]; !existed {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved flag extraction %q historical Schema tool lacks parameter %q",
|
||||
migration.Legacy.Command,
|
||||
parameter.From,
|
||||
)
|
||||
}
|
||||
if _, exists := newSource.Parameters[parameter.From]; exists {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved flag extraction %q still publishes extracted Schema parameter %q",
|
||||
migration.Legacy.Command,
|
||||
parameter.From,
|
||||
)
|
||||
}
|
||||
delete(normalizedTool.Parameters, parameter.From)
|
||||
}
|
||||
}
|
||||
normalizedProduct.Tools[migration.Schema.SourceToolID] = normalizedTool
|
||||
normalized.Products[migration.Schema.ProductID] = normalizedProduct
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func validateEquivalentCommandSchemaParameter(
|
||||
migration interfacesnapshot.CommandMigration,
|
||||
parameter interfacesnapshot.CommandParameterMigration,
|
||||
oldParameter parameterSchema,
|
||||
newParameter parameterSchema,
|
||||
) error {
|
||||
if oldParameter.Type != newParameter.Type ||
|
||||
oldParameter.Property != newParameter.Property ||
|
||||
oldParameter.InterfaceType != newParameter.InterfaceType ||
|
||||
oldParameter.Required != newParameter.Required ||
|
||||
oldParameter.CLIRequired != newParameter.CLIRequired ||
|
||||
oldParameter.RequiredWhen != newParameter.RequiredWhen ||
|
||||
oldParameter.Default != newParameter.Default ||
|
||||
oldParameter.InterfaceDefault != newParameter.InterfaceDefault ||
|
||||
oldParameter.Format != newParameter.Format ||
|
||||
!equalStringSlices(oldParameter.Enum, newParameter.Enum) {
|
||||
return fmt.Errorf(
|
||||
"approved command migration %q Schema parameter %q -> %q changed a non-name field",
|
||||
migration.Legacy.Command,
|
||||
parameter.From,
|
||||
parameter.To,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func schemaToolsByPrimaryPath(contract schemaContract, primaryPath string) []schemaToolRef {
|
||||
var matches []schemaToolRef
|
||||
for productID, product := range contract.Products {
|
||||
|
||||
@@ -1589,7 +1589,7 @@ func TestCrossPlatformCoverageSchemaMigrationCLIRequiresCompleteCheckInputs(t *t
|
||||
"--migration-stable-snapshot", "stable.json",
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := run([]string{"--check", "baseline.json", "--current", "schema.json", "--approved-flag-migrations", "approved.json"}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "all five") {
|
||||
if code := run([]string{"--check", "baseline.json", "--current", "schema.json", "--approved-flag-migrations", "approved.json"}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "both flag manifests") {
|
||||
t.Fatalf("partial migration inputs code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user