Files

473 lines
14 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package interfacesnapshot
import (
"fmt"
"reflect"
"sort"
"strings"
)
// Report combines comparisons against independently supplied compatibility
// references, normally the PR merge-base and the latest stable GA release.
type Report struct {
Compatible bool `json:"compatible"`
Comparisons []Comparison `json:"comparisons"`
}
// Comparison is the result for one reference snapshot.
type Comparison struct {
Reference string `json:"reference"`
Compatible bool `json:"compatible"`
Blocking []Change `json:"blocking"`
Additions []Change `json:"additions"`
}
// Change describes one compatibility decision. Before and After are concise,
// human-readable values intended for CI annotations.
type Change struct {
Kind string `json:"kind"`
Path string `json:"path"`
Flag string `json:"flag,omitempty"`
Before string `json:"before,omitempty"`
After string `json:"after,omitempty"`
}
// CompareAll compares current against every named reference in one pass.
// Additions are reported but remain compatible.
func CompareAll(current Snapshot, references map[string]Snapshot) Report {
labels := make([]string, 0, len(references))
for label := range references {
labels = append(labels, label)
}
sort.Strings(labels)
report := Report{Compatible: true, Comparisons: []Comparison{}}
for _, label := range labels {
comparison := Compare(current, references[label], label)
report.Comparisons = append(report.Comparisons, comparison)
if !comparison.Compatible {
report.Compatible = false
}
}
return report
}
// Compare enforces the deliberately small admission policy:
// - every previously accepted command path must still resolve to a runnable,
// visible-compatible target (a rename may preserve the old path as an
// alias),
// - flags accepted at each command path may not disappear, change type, or
// become required; an existing path may not gain a new required flag,
// - once bool ConstParams evidence exists, its exact property/value map is a
// durable contract; adding the first evidence to an older snapshot remains
// a silent bootstrap,
// - new commands and flags are allowed.
//
// The single exception to the type rule is an individually reviewed migration
// listed in reviewedFlagTypeChanges, and only when nothing else about the flag
// changed. See reviewed.go.
//
// Comparing the effective local + inherited set is intentional. It catches a
// persistent flag whose scope is accidentally narrowed to its declaring
// command, while allowing a local flag to move to an ancestor without breaking
// the old invocation path.
func Compare(current, baseline Snapshot, reference string) Comparison {
result := Comparison{
Reference: reference,
Compatible: true,
Blocking: []Change{},
Additions: []Change{},
}
if !reflect.DeepEqual(current.Rules, baseline.Rules) {
result.Blocking = append(result.Blocking, Change{
Kind: "snapshot_rules_changed",
Path: "dws",
Before: fmt.Sprintf("%v", baseline.Rules),
After: fmt.Sprintf("%v", current.Rules),
})
}
result.Blocking = append(result.Blocking, aliasCollisionChanges(current)...)
currentCommands := commandIndex(current)
baselineCommands := commandIndex(baseline)
currentAccepted := acceptedPathIndex(current)
baselineAccepted := acceptedPathIndex(baseline)
removedPaths := make([]string, 0)
for path := range baselineAccepted {
if _, ok := currentAccepted[path]; !ok {
removedPaths = append(removedPaths, path)
}
}
for _, path := range minimalPaths(removedPaths) {
kind := "command_alias_removed"
if _, canonical := baselineCommands[path]; canonical {
kind = "command_removed"
}
result.Blocking = append(result.Blocking, Change{Kind: kind, Path: path})
}
mappedCurrent := make(map[string]bool)
for _, acceptedPath := range sortedAcceptedPaths(baselineAccepted) {
oldPath := baselineAccepted[acceptedPath]
oldCommand := baselineCommands[oldPath]
newPath, ok := currentAccepted[acceptedPath]
if !ok {
continue
}
newCommand, ok := currentCommands[newPath]
if !ok {
continue
}
mappedCurrent[newPath] = true
compareCommandContract(&result, acceptedPath, oldCommand, newCommand)
compareEffectiveFlags(&result, acceptedPath, oldCommand, newCommand)
}
for _, path := range sortedCommandPaths(current.Commands) {
if mappedCurrent[path] {
continue
}
if _, existed := baselineCommands[path]; !existed {
result.Additions = append(result.Additions, Change{Kind: "command_added", Path: path})
}
}
sortChanges(result.Blocking)
sortChanges(result.Additions)
result.Blocking = dedupeChanges(result.Blocking)
result.Additions = dedupeChanges(result.Additions)
result.Compatible = len(result.Blocking) == 0
return result
}
func compareCommandContract(result *Comparison, acceptedPath string, oldCommand, newCommand Command) {
if len(oldCommand.BoolConstParams) > 0 && !reflect.DeepEqual(oldCommand.BoolConstParams, newCommand.BoolConstParams) {
result.Blocking = append(result.Blocking, Change{
Kind: "bool_const_params_changed",
Path: acceptedPath,
Before: fmt.Sprintf("%v", oldCommand.BoolConstParams),
After: fmt.Sprintf("%v", newCommand.BoolConstParams),
})
}
if oldCommand.Runnable && !newCommand.Runnable {
result.Blocking = append(result.Blocking, Change{
Kind: "command_became_non_runnable",
Path: acceptedPath,
Before: "runnable",
After: "non-runnable",
})
}
if !oldCommand.Hidden && newCommand.Hidden {
result.Blocking = append(result.Blocking, Change{
Kind: "command_became_hidden",
Path: acceptedPath,
Before: "visible",
After: "hidden",
})
}
}
func compareEffectiveFlags(result *Comparison, acceptedPath string, oldCommand, newCommand Command) {
oldFlags := effectiveFlagIndex(oldCommand)
newFlags := effectiveFlagIndex(newCommand)
for _, name := range sortedFlagNames(oldFlags) {
oldFlag := oldFlags[name]
newFlag, ok := newFlags[name]
if !ok {
result.Blocking = append(result.Blocking, Change{
Kind: "flag_removed",
Path: acceptedPath,
Flag: name,
})
continue
}
if oldFlag.Type != newFlag.Type {
// Resolve the exemption against the canonical path, not acceptedPath.
// An aliased command reaches this function once per accepted spelling,
// so keying on acceptedPath would let every alias spelling re-report a
// reviewed migration.
if !reviewedFlagTypeChange(newCommand.Path, name, oldFlag.Type, newFlag.Type) ||
flagContractOtherwiseChanged(oldFlag, newFlag) {
result.Blocking = append(result.Blocking, Change{
Kind: "flag_type_changed",
Path: acceptedPath,
Flag: name,
Before: oldFlag.Type,
After: newFlag.Type,
})
}
}
if !oldFlag.Required && newFlag.Required {
result.Blocking = append(result.Blocking, Change{
Kind: "flag_became_required",
Path: acceptedPath,
Flag: name,
Before: "optional",
After: "required",
})
}
if oldFlag.Shorthand != "" && newFlag.Shorthand != oldFlag.Shorthand {
result.Blocking = append(result.Blocking, Change{
Kind: "flag_shorthand_changed",
Path: acceptedPath,
Flag: name,
Before: oldFlag.Shorthand,
After: newFlag.Shorthand,
})
}
if oldFlag.NoOpt != "" && newFlag.NoOpt != oldFlag.NoOpt {
result.Blocking = append(result.Blocking, Change{
Kind: "flag_no_opt_changed",
Path: acceptedPath,
Flag: name,
Before: oldFlag.NoOpt,
After: newFlag.NoOpt,
})
}
if oldFlag.AliasOf != "" && newFlag.AliasOf != oldFlag.AliasOf {
result.Blocking = append(result.Blocking, Change{
Kind: "flag_alias_target_changed",
Path: acceptedPath,
Flag: name,
Before: oldFlag.AliasOf,
After: newFlag.AliasOf,
})
}
if !oldFlag.Hidden && newFlag.Hidden {
result.Blocking = append(result.Blocking, Change{
Kind: "flag_became_hidden",
Path: acceptedPath,
Flag: name,
Before: "visible",
After: "hidden",
})
}
}
for _, name := range sortedFlagNames(newFlags) {
newFlag := newFlags[name]
if _, existed := oldFlags[name]; existed {
continue
}
if newFlag.Required {
result.Blocking = append(result.Blocking, Change{
Kind: "required_flag_added",
Path: acceptedPath,
Flag: name,
Before: "absent",
After: "required",
})
}
}
// Report optional additions once where they are declared. Required
// additions were handled against the effective set above because they can
// break every descendant invocation.
newLocalFlags := flagIndex(newCommand.LocalFlags)
for _, name := range sortedFlagNames(newLocalFlags) {
newFlag := newLocalFlags[name]
if _, existed := oldFlags[name]; existed || newFlag.Required {
continue
}
result.Additions = append(result.Additions, Change{Kind: "flag_added", Path: newCommand.Path, Flag: name})
}
}
func commandIndex(snapshot Snapshot) map[string]Command {
out := make(map[string]Command, len(snapshot.Commands))
for _, command := range snapshot.Commands {
out[command.Path] = command
}
return out
}
func aliasCollisionChanges(snapshot Snapshot) []Change {
acceptedSiblings := make(map[string]string)
changes := []Change{}
for _, command := range snapshot.Commands {
parent, name := splitParent(command.Path)
for _, acceptedName := range append([]string{name}, command.Aliases...) {
acceptedName = strings.TrimSpace(acceptedName)
if acceptedName == "" {
continue
}
key := parent + "\x00" + acceptedName
if previous, exists := acceptedSiblings[key]; exists && previous != command.Path {
changes = append(changes, Change{
Kind: "command_alias_collision",
Path: strings.TrimSpace(parent + " " + acceptedName),
Before: previous,
After: command.Path,
})
continue
}
acceptedSiblings[key] = command.Path
}
}
return changes
}
// acceptedPathIndex expands aliases at every path segment. For example, if
// "dws chat" has alias "im", then "dws im message send" remains accepted for
// every descendant even though descendants only store their canonical paths.
func acceptedPathIndex(snapshot Snapshot) map[string]string {
commands := append([]Command(nil), snapshot.Commands...)
sort.Slice(commands, func(i, j int) bool {
leftDepth, rightDepth := pathDepth(commands[i].Path), pathDepth(commands[j].Path)
if leftDepth != rightDepth {
return leftDepth < rightDepth
}
return commands[i].Path < commands[j].Path
})
variants := make(map[string][]string, len(commands))
accepted := make(map[string]string)
// Canonical paths always win over an alias collision.
for _, command := range commands {
accepted[command.Path] = command.Path
}
for _, command := range commands {
parent, name := splitParent(command.Path)
names := compactSorted(append([]string{name}, command.Aliases...))
parentVariants := variants[parent]
if parent == "" {
parentVariants = []string{""}
} else if len(parentVariants) == 0 {
parentVariants = []string{parent}
}
commandVariants := make([]string, 0, len(parentVariants)*len(names))
for _, parentVariant := range parentVariants {
for _, candidateName := range names {
path := strings.TrimSpace(parentVariant + " " + candidateName)
commandVariants = append(commandVariants, path)
if _, canonical := accepted[path]; !canonical {
accepted[path] = command.Path
}
}
}
variants[command.Path] = compactSorted(commandVariants)
}
return accepted
}
func flagIndex(flags []Flag) map[string]Flag {
out := make(map[string]Flag, len(flags))
for _, flag := range flags {
out[flag.Name] = flag
}
return out
}
func effectiveFlagIndex(command Command) map[string]Flag {
out := flagIndex(command.InheritedFlags)
for _, flag := range command.LocalFlags {
// A local flag is the callable definition when it shadows an inherited
// flag with the same name.
out[flag.Name] = flag
}
return out
}
func sortedCommandPaths(commands []Command) []string {
paths := make([]string, 0, len(commands))
for _, command := range commands {
paths = append(paths, command.Path)
}
sort.Strings(paths)
return paths
}
func sortedAcceptedPaths(accepted map[string]string) []string {
paths := make([]string, 0, len(accepted))
for path := range accepted {
paths = append(paths, path)
}
sort.Strings(paths)
return paths
}
func sortedFlagNames(flags map[string]Flag) []string {
names := make([]string, 0, len(flags))
for name := range flags {
names = append(names, name)
}
sort.Strings(names)
return names
}
func minimalPaths(paths []string) []string {
sort.Slice(paths, func(i, j int) bool {
leftDepth, rightDepth := pathDepth(paths[i]), pathDepth(paths[j])
if leftDepth != rightDepth {
return leftDepth < rightDepth
}
return paths[i] < paths[j]
})
minimal := make([]string, 0, len(paths))
for _, path := range paths {
covered := false
for _, parent := range minimal {
if strings.HasPrefix(path, parent+" ") {
covered = true
break
}
}
if !covered {
minimal = append(minimal, path)
}
}
return minimal
}
func splitParent(path string) (string, string) {
index := strings.LastIndexByte(path, ' ')
if index < 0 {
return "", path
}
return path[:index], path[index+1:]
}
func pathDepth(path string) int {
return len(strings.Fields(path))
}
func sortChanges(changes []Change) {
sort.Slice(changes, func(i, j int) bool {
left := changes[i].Path + "\x00" + changes[i].Flag + "\x00" + changes[i].Kind
right := changes[j].Path + "\x00" + changes[j].Flag + "\x00" + changes[j].Kind
return left < right
})
}
func dedupeChanges(changes []Change) []Change {
if len(changes) < 2 {
return changes
}
out := changes[:1]
for _, change := range changes[1:] {
if change == out[len(out)-1] {
continue
}
out = append(out, change)
}
return out
}