Files
chichuan cd02fe71e6 ci(interface): allow reviewed flag type migrations
The authoritative interface baseline and command-compatibility gates
rejected every flag type change on a historical command, with no review
channel — even when the new type only moves the same validation from RunE
to flag parsing. Both now consult a precise allowlist.

An entry must match command path, flag name and both type names exactly,
so it is direction-sensitive by construction, and it applies only when
nothing else about the flag moved (shorthand, required, hidden, no-opt,
scope). A bundled regression re-reports the type change.

The first and only entry is "dws minutes permission apply --policy" moving
from string to int (for #912): the old RunE parsed with
strconv.ParseInt(v, 10, 64) and enforced [2,4], the new one lets pflag
parse with base 0 and still enforces [2,4], so the historical set of
successful invocations is a subset of the new one. Base 0 additionally
accepts spellings like "0x3", which widens rather than narrows. Defaults
are excluded from the guard because the migration necessarily changes one.

In the snapshot gate the exemption resolves against the canonical
Command.Path, never the alias-expanded accepted path: an aliased command is
compared once per accepted spelling, so keying on that would let every
alias bypass the table.

The table is duplicated because check-authoritative-interface-baselines.sh
copies the whole scripts/policy/interface-baseline directory into a
worktree checked out at a historical revision and builds it there, so that
copy cannot import a package this branch adds. A guard test fails if the
two copies drift.
2026-08-10 14:26:32 +08:00

611 lines
18 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package main
import (
"bufio"
"bytes"
"fmt"
"io"
"os"
"sort"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
type flagContract struct {
Name string
Shorthand string
Type string
Required bool
RequiredSet bool
Hidden bool
HiddenSet bool
NoOpt string
NoOptSet bool
Scope string
ScopeSet bool
}
type commandContract struct {
Path string
Runnable bool
RunnableSet bool
Hidden bool
HiddenSet bool
Children map[string]struct{}
Aliases map[string]struct{}
Flags map[string]flagContract
}
type interfaceContract struct {
Commands map[string]*commandContract
}
func newInterfaceContract() interfaceContract {
return interfaceContract{Commands: map[string]*commandContract{}}
}
func (c interfaceContract) command(path string) *commandContract {
if existing := c.Commands[path]; existing != nil {
return existing
}
created := &commandContract{
Path: path,
Children: map[string]struct{}{},
Aliases: map[string]struct{}{},
Flags: map[string]flagContract{},
}
c.Commands[path] = created
return created
}
func snapshot(root *cobra.Command) interfaceContract {
contract := newInterfaceContract()
var walk func(*cobra.Command, []string)
walk = func(cmd *cobra.Command, parent []string) {
pathParts := parent
if cmd.HasParent() {
pathParts = append(append([]string(nil), parent...), cmd.Name())
}
path := "root"
if len(pathParts) > 0 {
path = strings.Join(pathParts, ".")
}
entry := contract.command(path)
entry.Runnable = cmd.Runnable()
entry.RunnableSet = true
entry.Hidden = cmd.Hidden
entry.HiddenSet = true
for _, alias := range cmd.Aliases {
entry.Aliases[alias] = struct{}{}
}
cmd.InitDefaultHelpFlag()
captureFlagSet(entry.Flags, cmd.InheritedFlags(), "inherited")
captureFlagSet(entry.Flags, cmd.PersistentFlags(), "persistent")
captureFlagSet(entry.Flags, cmd.LocalNonPersistentFlags(), "local")
for _, child := range publicChildren(cmd) {
entry.Children[child.Name()] = struct{}{}
walk(child, pathParts)
}
}
walk(root, nil)
return contract
}
func captureFlagSet(target map[string]flagContract, set *pflag.FlagSet, scope string) {
if set == nil {
return
}
set.VisitAll(func(f *pflag.Flag) {
target[f.Name] = flagContract{
Name: f.Name,
Shorthand: f.Shorthand,
Type: f.Value.Type(),
Required: isRequiredFlag(f),
RequiredSet: true,
Hidden: f.Hidden,
HiddenSet: true,
NoOpt: f.NoOptDefVal,
NoOptSet: true,
Scope: scope,
ScopeSet: true,
}
})
}
func isRequiredFlag(flag *pflag.Flag) bool {
for _, value := range flag.Annotations[cobra.BashCompOneRequiredFlag] {
if value == "true" {
return true
}
}
return false
}
func publicChildren(cmd *cobra.Command) []*cobra.Command {
var children []*cobra.Command
for _, child := range cmd.Commands() {
if !child.Hidden {
children = append(children, child)
}
}
sort.Slice(children, func(i, j int) bool { return children[i].Name() < children[j].Name() })
return children
}
func readContract(path string) (interfaceContract, error) {
data, err := os.ReadFile(path)
if err != nil {
return interfaceContract{}, err
}
return parseContract(data)
}
func parseContract(data []byte) (interfaceContract, error) {
contract := newInterfaceContract()
var current *commandContract
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
path := strings.TrimSuffix(strings.TrimPrefix(line, "["), "]")
if path == "" {
return interfaceContract{}, fmt.Errorf("empty command path")
}
current = contract.command(path)
continue
}
if current == nil {
return interfaceContract{}, fmt.Errorf("property before command section: %q", line)
}
key, value, ok := strings.Cut(line, ":")
if !ok {
return interfaceContract{}, fmt.Errorf("invalid contract line %q", line)
}
items := splitList(value)
switch key {
case "runnable":
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
if err != nil {
return interfaceContract{}, fmt.Errorf("%s: invalid runnable value %q", current.Path, value)
}
current.Runnable = parsed
current.RunnableSet = true
case "hidden":
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
if err != nil {
return interfaceContract{}, fmt.Errorf("%s: invalid hidden value %q", current.Path, value)
}
current.Hidden = parsed
current.HiddenSet = true
case "commands":
for _, item := range items {
current.Children[item] = struct{}{}
}
case "aliases":
for _, item := range items {
current.Aliases[item] = struct{}{}
}
case "flags":
for _, item := range items {
parsed, err := parseFlag(item)
if err != nil {
return interfaceContract{}, fmt.Errorf("%s: %w", current.Path, err)
}
if old, exists := current.Flags[parsed.Name]; exists && old != parsed {
return interfaceContract{}, fmt.Errorf("%s: conflicting duplicate flag --%s", current.Path, parsed.Name)
}
current.Flags[parsed.Name] = parsed
}
default:
return interfaceContract{}, fmt.Errorf("unknown contract property %q", key)
}
}
if err := scanner.Err(); err != nil {
return interfaceContract{}, err
}
return contract, nil
}
func splitList(value string) []string {
var result []string
for _, item := range strings.Split(strings.TrimSpace(value), ",") {
if item = strings.TrimSpace(item); item != "" {
result = append(result, item)
}
}
return result
}
func parseFlag(raw string) (flagContract, error) {
parts := strings.Split(raw, "|")
spelling, flagType, ok := strings.Cut(parts[0], ":")
if !ok || flagType == "" {
return flagContract{}, fmt.Errorf("invalid flag contract %q", raw)
}
var shorthand string
longName := spelling
if before, after, found := strings.Cut(spelling, "/"); found {
shorthand = strings.TrimPrefix(before, "-")
longName = after
}
longName = strings.TrimPrefix(longName, "--")
if longName == "" {
return flagContract{}, fmt.Errorf("invalid flag contract %q", raw)
}
contract := flagContract{Name: longName, Shorthand: shorthand, Type: flagType, Scope: "local"}
for _, attribute := range parts[1:] {
key, value, found := strings.Cut(attribute, "=")
if !found {
return flagContract{}, fmt.Errorf("invalid flag attribute %q", attribute)
}
switch key {
case "required":
contract.Required, found = parseBoolAttribute(value)
contract.RequiredSet = found
case "hidden":
contract.Hidden, found = parseBoolAttribute(value)
contract.HiddenSet = found
case "no-opt":
contract.NoOpt, _ = strconv.Unquote(value)
found = value == strconv.Quote(contract.NoOpt)
contract.NoOptSet = found
case "scope":
contract.Scope = value
found = value == "local" || value == "persistent" || value == "inherited"
contract.ScopeSet = found
default:
return flagContract{}, fmt.Errorf("unknown flag attribute %q", key)
}
if !found {
return flagContract{}, fmt.Errorf("invalid flag attribute %q", attribute)
}
}
return contract, nil
}
func parseBoolAttribute(value string) (bool, bool) {
parsed, err := strconv.ParseBool(value)
return parsed, err == nil
}
func checkCompatibility(root *cobra.Command, baseline interfaceContract) []string {
var failures []string
paths := sortedCommandPaths(baseline)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
for _, path := range paths {
expected := baseline.Commands[path]
cmd, ok := resolveCommand(root, path)
if !ok {
failures = append(failures, fmt.Sprintf("historical command %q is missing", displayPath(path)))
continue
}
if err := cmd.Help(); err != nil {
failures = append(failures, fmt.Sprintf("%q -h cannot render: %v", displayPath(path), err))
}
if expected.RunnableSet && expected.Runnable && !cmd.Runnable() {
failures = append(failures, fmt.Sprintf("historical command %q became non-runnable", displayPath(path)))
}
if expected.HiddenSet && !expected.Hidden && cmd.Hidden {
failures = append(failures, fmt.Sprintf("historical command %q became hidden", displayPath(path)))
}
for _, alias := range sortedSet(expected.Aliases) {
aliasPath := aliasCommandPath(path, alias)
resolved, found := resolveCommand(root, aliasPath)
if !found || resolved != cmd {
failures = append(failures, fmt.Sprintf("historical alias %q is missing", displayPath(aliasPath)))
}
}
for _, expectedFlag := range sortedFlags(expected.Flags) {
actual, actualScope := lookupFlag(cmd, expectedFlag.Name)
if actual == nil {
failures = append(failures, fmt.Sprintf("%q lost flag --%s", displayPath(path), expectedFlag.Name))
continue
}
if actual.Value.Type() != expectedFlag.Type {
if !reviewedFlagTypeChange(displayPath(path), expectedFlag.Name, expectedFlag.Type, actual.Value.Type()) ||
flagContractOtherwiseChanged(expectedFlag, actual, actualScope) {
failures = append(failures, fmt.Sprintf(
"%q flag --%s changed type from %s to %s",
displayPath(path), expectedFlag.Name, expectedFlag.Type, actual.Value.Type(),
))
}
}
if expectedFlag.Shorthand != "" && actual.Shorthand != expectedFlag.Shorthand {
failures = append(failures, fmt.Sprintf(
"%q flag --%s lost shorthand -%s",
displayPath(path), expectedFlag.Name, expectedFlag.Shorthand,
))
}
if expectedFlag.RequiredSet && !expectedFlag.Required && isRequiredFlag(actual) {
failures = append(failures, fmt.Sprintf(
"%q flag --%s became required", displayPath(path), expectedFlag.Name,
))
}
if expectedFlag.HiddenSet && !expectedFlag.Hidden && actual.Hidden {
failures = append(failures, fmt.Sprintf(
"%q flag --%s became hidden", displayPath(path), expectedFlag.Name,
))
}
if expectedFlag.NoOptSet && actual.NoOptDefVal != expectedFlag.NoOpt {
failures = append(failures, fmt.Sprintf(
"%q flag --%s changed no-opt value from %q to %q",
displayPath(path), expectedFlag.Name, expectedFlag.NoOpt, actual.NoOptDefVal,
))
}
if expectedFlag.ScopeSet && expectedFlag.Scope == "persistent" && actualScope == "local" {
failures = append(failures, fmt.Sprintf(
"%q flag --%s narrowed persistent scope to local", displayPath(path), expectedFlag.Name,
))
}
}
for _, actualFlag := range effectiveFlags(cmd) {
if _, existed := expected.Flags[actualFlag.Name]; existed || !actualFlag.Required {
continue
}
failures = append(failures, fmt.Sprintf(
"%q added required flag --%s", displayPath(path), actualFlag.Name,
))
}
}
return failures
}
func resolveCommand(root *cobra.Command, path string) (*cobra.Command, bool) {
if path == "root" {
return root, true
}
cmd, remaining, err := root.Find(strings.Split(path, "."))
return cmd, err == nil && len(remaining) == 0
}
func lookupFlag(cmd *cobra.Command, name string) (*pflag.Flag, string) {
cmd.InitDefaultHelpFlag()
if flag := cmd.LocalNonPersistentFlags().Lookup(name); flag != nil {
return flag, "local"
}
if flag := cmd.PersistentFlags().Lookup(name); flag != nil {
return flag, "persistent"
}
if flag := cmd.InheritedFlags().Lookup(name); flag != nil {
return flag, "inherited"
}
return nil, ""
}
func effectiveFlags(cmd *cobra.Command) []flagContract {
flags := map[string]flagContract{}
cmd.InitDefaultHelpFlag()
captureFlagSet(flags, cmd.InheritedFlags(), "inherited")
captureFlagSet(flags, cmd.PersistentFlags(), "persistent")
captureFlagSet(flags, cmd.LocalNonPersistentFlags(), "local")
return sortedFlags(flags)
}
func aliasCommandPath(path, alias string) string {
if path == "root" || !strings.Contains(path, ".") {
return alias
}
parent, _, _ := strings.Cut(path, ".")
index := strings.LastIndex(path, ".")
if index >= 0 {
parent = path[:index]
}
return parent + "." + alias
}
func mergeContracts(historical, current interfaceContract) (interfaceContract, []string) {
merged := cloneContract(historical)
var failures []string
for path, addition := range current.Commands {
target, existed := merged.Commands[path]
if !existed {
target = merged.command(path)
target.Runnable = addition.Runnable
target.RunnableSet = addition.RunnableSet
target.Hidden = addition.Hidden
target.HiddenSet = addition.HiddenSet
}
if target.RunnableSet && target.Runnable && !addition.Runnable {
failures = append(failures, fmt.Sprintf("historical command %q became non-runnable", displayPath(path)))
}
if target.HiddenSet && !target.Hidden && addition.Hidden {
failures = append(failures, fmt.Sprintf("historical command %q became hidden", displayPath(path)))
}
if !target.RunnableSet {
target.Runnable = addition.Runnable
target.RunnableSet = addition.RunnableSet
}
if !target.HiddenSet {
target.Hidden = addition.Hidden
target.HiddenSet = addition.HiddenSet
}
for child := range addition.Children {
target.Children[child] = struct{}{}
}
for alias := range addition.Aliases {
target.Aliases[alias] = struct{}{}
}
for name, newFlag := range addition.Flags {
oldFlag, exists := target.Flags[name]
if !exists {
if existed && newFlag.Required {
failures = append(failures, fmt.Sprintf("%q added required flag --%s", displayPath(path), name))
continue
}
target.Flags[name] = newFlag
continue
}
if oldFlag.Type != newFlag.Type {
if !reviewedFlagTypeChange(displayPath(path), name, oldFlag.Type, newFlag.Type) ||
mergedFlagContractOtherwiseChanged(oldFlag, newFlag) {
failures = append(failures, fmt.Sprintf(
"%q flag --%s changed type from %s to %s",
displayPath(path), name, oldFlag.Type, newFlag.Type,
))
continue
}
// A reviewed migration keeps merging, and the merged entry keeps
// the historical type below, exactly as this function keeps the
// historical shorthand, required and hidden values. The baseline
// records what was promised, so a later --check still resolves
// this flag through the reviewed table rather than silently
// adopting the new type as history.
}
if oldFlag.Shorthand != "" && oldFlag.Shorthand != newFlag.Shorthand {
failures = append(failures, fmt.Sprintf(
"%q flag --%s lost shorthand -%s",
displayPath(path), name, oldFlag.Shorthand,
))
continue
}
if oldFlag.Shorthand == "" && newFlag.Shorthand != "" {
oldFlag.Shorthand = newFlag.Shorthand
}
if oldFlag.RequiredSet && !oldFlag.Required && newFlag.Required {
failures = append(failures, fmt.Sprintf("%q flag --%s became required", displayPath(path), name))
continue
}
if oldFlag.HiddenSet && !oldFlag.Hidden && newFlag.Hidden {
failures = append(failures, fmt.Sprintf("%q flag --%s became hidden", displayPath(path), name))
continue
}
if oldFlag.NoOptSet && newFlag.NoOptSet && oldFlag.NoOpt != newFlag.NoOpt {
failures = append(failures, fmt.Sprintf("%q flag --%s changed no-opt value", displayPath(path), name))
continue
}
if oldFlag.ScopeSet && oldFlag.Scope == "persistent" && newFlag.Scope == "local" {
failures = append(failures, fmt.Sprintf("%q flag --%s narrowed persistent scope to local", displayPath(path), name))
continue
}
if !oldFlag.RequiredSet {
oldFlag.Required = newFlag.Required
oldFlag.RequiredSet = newFlag.RequiredSet
}
if !oldFlag.HiddenSet {
oldFlag.Hidden = newFlag.Hidden
oldFlag.HiddenSet = newFlag.HiddenSet
}
if !oldFlag.NoOptSet {
oldFlag.NoOpt = newFlag.NoOpt
oldFlag.NoOptSet = newFlag.NoOptSet
}
if !oldFlag.ScopeSet {
oldFlag.Scope = newFlag.Scope
oldFlag.ScopeSet = newFlag.ScopeSet
}
target.Flags[name] = oldFlag
}
}
return merged, failures
}
func cloneContract(source interfaceContract) interfaceContract {
cloned := newInterfaceContract()
for path, command := range source.Commands {
target := cloned.command(path)
target.Runnable = command.Runnable
target.RunnableSet = command.RunnableSet
target.Hidden = command.Hidden
target.HiddenSet = command.HiddenSet
for child := range command.Children {
target.Children[child] = struct{}{}
}
for alias := range command.Aliases {
target.Aliases[alias] = struct{}{}
}
for name, flag := range command.Flags {
target.Flags[name] = flag
}
}
return cloned
}
func renderContract(w io.Writer, contract interfaceContract) error {
for index, path := range sortedCommandPaths(contract) {
if index > 0 {
if _, err := fmt.Fprintln(w); err != nil {
return err
}
}
command := contract.Commands[path]
if _, err := fmt.Fprintf(w, "[%s]\n", path); err != nil {
return err
}
fmt.Fprintf(w, " runnable: %t\n", command.Runnable)
fmt.Fprintf(w, " hidden: %t\n", command.Hidden)
if values := sortedSet(command.Children); len(values) > 0 {
fmt.Fprintf(w, " commands: %s\n", strings.Join(values, ", "))
}
if values := sortedSet(command.Aliases); len(values) > 0 {
fmt.Fprintf(w, " aliases: %s\n", strings.Join(values, ", "))
}
if values := sortedFlags(command.Flags); len(values) > 0 {
rendered := make([]string, 0, len(values))
for _, flag := range values {
name := "--" + flag.Name
if flag.Shorthand != "" {
name = "-" + flag.Shorthand + "/" + name
}
rendered = append(rendered, fmt.Sprintf(
"%s:%s|required=%t|hidden=%t|no-opt=%s|scope=%s",
name, flag.Type, flag.Required, flag.Hidden, strconv.Quote(flag.NoOpt), flag.Scope,
))
}
fmt.Fprintf(w, " flags: %s\n", strings.Join(rendered, ", "))
}
}
return nil
}
func sortedCommandPaths(contract interfaceContract) []string {
paths := make([]string, 0, len(contract.Commands))
for path := range contract.Commands {
paths = append(paths, path)
}
sort.Slice(paths, func(i, j int) bool {
if paths[i] == "root" {
return true
}
if paths[j] == "root" {
return false
}
return paths[i] < paths[j]
})
return paths
}
func sortedSet(values map[string]struct{}) []string {
result := make([]string, 0, len(values))
for value := range values {
result = append(result, value)
}
sort.Strings(result)
return result
}
func sortedFlags(values map[string]flagContract) []flagContract {
result := make([]flagContract, 0, len(values))
for _, value := range values {
result = append(result, value)
}
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
return result
}
func displayPath(path string) string {
if path == "root" {
return "dws"
}
return "dws " + strings.ReplaceAll(path, ".", " ")
}