387 lines
12 KiB
Go
387 lines
12 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 captures and compares the public Cobra command
|
|
// surface without executing commands or contacting runtime services.
|
|
package interfacesnapshot
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/pflag"
|
|
)
|
|
|
|
const SchemaVersion = 3
|
|
|
|
// FlagAliasOfAnnotation records a framework-originated reviewed relationship
|
|
// between a retained compatibility flag and an exact canonical Cobra flag.
|
|
const FlagAliasOfAnnotation = runtimeannotate.AnnotationFlagAliasOf
|
|
|
|
var (
|
|
// Framework-owned command subtrees are deliberately excluded. They are
|
|
// generated by Cobra or shell-completion plumbing rather than DWS product
|
|
// code, so changes to them are not command-surface compatibility changes.
|
|
excludedCommandSubtrees = []string{
|
|
"dws __complete",
|
|
"dws __completeNoDesc",
|
|
"dws completion",
|
|
"dws help",
|
|
}
|
|
|
|
// Cobra installs --help lazily on every command. Keeping it would make the
|
|
// snapshot depend on whether help happened to be rendered before capture.
|
|
excludedFlags = map[string]bool{"help": true}
|
|
)
|
|
|
|
// Rules records the noise filtering contract in every snapshot. A future rule
|
|
// change is therefore visible instead of silently changing comparison scope.
|
|
type Rules struct {
|
|
ExcludedCommandSubtrees []string `json:"excluded_command_subtrees"`
|
|
ExcludedFlags []string `json:"excluded_flags"`
|
|
}
|
|
|
|
// Snapshot is a deterministic representation of a Cobra command tree.
|
|
type Snapshot struct {
|
|
SchemaVersion int `json:"schema_version"`
|
|
Rules Rules `json:"rules"`
|
|
Commands []Command `json:"commands"`
|
|
}
|
|
|
|
// Command contains compatibility-relevant command metadata. Path always
|
|
// includes the root command name (for example, "dws chat message send").
|
|
type Command struct {
|
|
Path string `json:"path"`
|
|
Runnable bool `json:"runnable"`
|
|
Hidden bool `json:"hidden"`
|
|
Deprecated string `json:"deprecated,omitempty"`
|
|
Aliases []string `json:"aliases"`
|
|
LocalFlags []Flag `json:"local_flags"`
|
|
InheritedFlags []Flag `json:"inherited_flags"`
|
|
BoolConstParams map[string]bool `json:"bool_const_params,omitempty"`
|
|
}
|
|
|
|
// Flag contains the stable pflag contract visible at a command node.
|
|
type Flag struct {
|
|
Name string `json:"name"`
|
|
Shorthand string `json:"shorthand,omitempty"`
|
|
Type string `json:"type"`
|
|
Default string `json:"default"`
|
|
NoOpt string `json:"no_opt,omitempty"`
|
|
Required bool `json:"required"`
|
|
Hidden bool `json:"hidden"`
|
|
Deprecated string `json:"deprecated,omitempty"`
|
|
AliasOf string `json:"alias_of,omitempty"`
|
|
|
|
// Capture cannot return an error, so it preserves malformed or non-framework
|
|
// relation evidence here for Validate/Write to reject. Valid snapshots never
|
|
// serialize this implementation detail.
|
|
malformedAliasEvidence bool
|
|
}
|
|
|
|
// Capture walks root without rendering help or executing any command. Both
|
|
// hidden compatibility commands and hidden flags are retained unless they are
|
|
// covered by the explicit framework-noise rules above.
|
|
func Capture(root *cobra.Command) Snapshot {
|
|
snapshot := Snapshot{
|
|
SchemaVersion: SchemaVersion,
|
|
Rules: Rules{
|
|
ExcludedCommandSubtrees: append([]string(nil), excludedCommandSubtrees...),
|
|
ExcludedFlags: []string{"help"},
|
|
},
|
|
Commands: []Command{},
|
|
}
|
|
if root == nil {
|
|
return snapshot
|
|
}
|
|
|
|
// --version is also installed lazily by Cobra, but unlike --help it is a
|
|
// real root CLI contract and should be captured.
|
|
root.InitDefaultVersionFlag()
|
|
|
|
var walk func(*cobra.Command)
|
|
walk = func(cmd *cobra.Command) {
|
|
path := normalizePath(cmd.CommandPath())
|
|
if commandExcluded(path) {
|
|
return
|
|
}
|
|
|
|
aliases := append([]string{}, cmd.Aliases...)
|
|
for i := range aliases {
|
|
aliases[i] = strings.TrimSpace(aliases[i])
|
|
}
|
|
aliases = compactSorted(aliases)
|
|
|
|
boolConstParams := corecmd.InterfaceBoolConstParams(cmd)
|
|
snapshot.Commands = append(snapshot.Commands, Command{
|
|
Path: path,
|
|
Runnable: cmd.Runnable(),
|
|
Hidden: cmd.Hidden,
|
|
Deprecated: strings.TrimSpace(cmd.Deprecated),
|
|
Aliases: aliases,
|
|
LocalFlags: captureFlags(cmd.LocalFlags()),
|
|
InheritedFlags: captureFlags(cmd.InheritedFlags()),
|
|
BoolConstParams: boolConstParams,
|
|
})
|
|
|
|
children := append([]*cobra.Command(nil), cmd.Commands()...)
|
|
sort.Slice(children, func(i, j int) bool {
|
|
return children[i].Name() < children[j].Name()
|
|
})
|
|
for _, child := range children {
|
|
walk(child)
|
|
}
|
|
}
|
|
walk(root)
|
|
|
|
sort.Slice(snapshot.Commands, func(i, j int) bool {
|
|
return snapshot.Commands[i].Path < snapshot.Commands[j].Path
|
|
})
|
|
return snapshot
|
|
}
|
|
|
|
// Write emits canonical, indented JSON with a trailing newline.
|
|
func Write(w io.Writer, snapshot Snapshot) error {
|
|
if err := snapshot.Validate(); err != nil {
|
|
return err
|
|
}
|
|
encoder := json.NewEncoder(w)
|
|
encoder.SetEscapeHTML(false)
|
|
encoder.SetIndent("", " ")
|
|
return encoder.Encode(snapshot)
|
|
}
|
|
|
|
// Read decodes and validates a snapshot. Unknown fields are rejected so a
|
|
// comparison never silently ignores a newer contract it does not understand.
|
|
func Read(r io.Reader) (Snapshot, error) {
|
|
var snapshot Snapshot
|
|
decoder := json.NewDecoder(r)
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&snapshot); err != nil {
|
|
return Snapshot{}, err
|
|
}
|
|
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
|
if err == nil {
|
|
return Snapshot{}, fmt.Errorf("interface snapshot contains multiple JSON values")
|
|
}
|
|
return Snapshot{}, fmt.Errorf("read trailing interface snapshot data: %w", err)
|
|
}
|
|
if err := snapshot.Validate(); err != nil {
|
|
return Snapshot{}, err
|
|
}
|
|
return snapshot, nil
|
|
}
|
|
|
|
// Validate checks the invariants needed by the comparison algorithm.
|
|
func (s Snapshot) Validate() error {
|
|
if s.SchemaVersion != SchemaVersion {
|
|
return fmt.Errorf("unsupported interface snapshot schema_version %d (want %d)", s.SchemaVersion, SchemaVersion)
|
|
}
|
|
seenCommands := make(map[string]bool, len(s.Commands))
|
|
for _, command := range s.Commands {
|
|
if command.Path == "" {
|
|
return fmt.Errorf("interface snapshot contains an empty command path")
|
|
}
|
|
if seenCommands[command.Path] {
|
|
return fmt.Errorf("interface snapshot contains duplicate command path %q", command.Path)
|
|
}
|
|
seenCommands[command.Path] = true
|
|
if command.BoolConstParams != nil && len(command.BoolConstParams) == 0 {
|
|
return fmt.Errorf("command %q must omit empty bool_const_params", command.Path)
|
|
}
|
|
if err := validateFlags(command.Path, "local", command.LocalFlags); err != nil {
|
|
return err
|
|
}
|
|
if err := validateFlags(command.Path, "inherited", command.InheritedFlags); err != nil {
|
|
return err
|
|
}
|
|
if err := validateFlagAliases(command); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func captureFlags(set *pflag.FlagSet) []Flag {
|
|
flags := []Flag{}
|
|
if set == nil {
|
|
return flags
|
|
}
|
|
set.VisitAll(func(flag *pflag.Flag) {
|
|
if flag == nil || excludedFlags[flag.Name] {
|
|
return
|
|
}
|
|
aliasOf, malformedAliasEvidence := captureFlagAliasOf(flag)
|
|
flags = append(flags, Flag{
|
|
Name: flag.Name,
|
|
Shorthand: flag.Shorthand,
|
|
Type: flag.Value.Type(),
|
|
Default: flag.DefValue,
|
|
NoOpt: flag.NoOptDefVal,
|
|
Required: isRequired(flag),
|
|
Hidden: flag.Hidden,
|
|
Deprecated: strings.TrimSpace(flag.Deprecated),
|
|
AliasOf: aliasOf,
|
|
malformedAliasEvidence: malformedAliasEvidence,
|
|
})
|
|
})
|
|
sort.Slice(flags, func(i, j int) bool { return flags[i].Name < flags[j].Name })
|
|
return flags
|
|
}
|
|
|
|
func captureFlagAliasOf(flag *pflag.Flag) (string, bool) {
|
|
aliasOf, aliasMalformed := exactFlagAnnotation(flag, FlagAliasOfAnnotation)
|
|
origin, originMalformed := exactFlagAnnotation(flag, runtimeannotate.AnnotationFlagAliasOrigin)
|
|
if aliasOf == "" && origin == "" && !aliasMalformed && !originMalformed {
|
|
return "", false
|
|
}
|
|
malformed := aliasMalformed || originMalformed || aliasOf == "" ||
|
|
origin != runtimeannotate.FlagAliasOriginCorecmdV1
|
|
return aliasOf, malformed
|
|
}
|
|
|
|
func exactFlagAnnotation(flag *pflag.Flag, key string) (string, bool) {
|
|
values, exists := flag.Annotations[key]
|
|
if !exists {
|
|
return "", false
|
|
}
|
|
if len(values) != 1 || values[0] == "" || values[0] != strings.TrimSpace(values[0]) {
|
|
return "", true
|
|
}
|
|
return values[0], false
|
|
}
|
|
|
|
func isRequired(flag *pflag.Flag) bool {
|
|
for _, value := range flag.Annotations[cobra.BashCompOneRequiredFlag] {
|
|
if value == "true" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func commandExcluded(path string) bool {
|
|
for _, prefix := range excludedCommandSubtrees {
|
|
if path == prefix || strings.HasPrefix(path, prefix+" ") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func normalizePath(path string) string {
|
|
return strings.Join(strings.Fields(path), " ")
|
|
}
|
|
|
|
func compactSorted(values []string) []string {
|
|
sort.Strings(values)
|
|
out := values[:0]
|
|
for _, value := range values {
|
|
if value == "" || (len(out) > 0 && out[len(out)-1] == value) {
|
|
continue
|
|
}
|
|
out = append(out, value)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func validateFlags(path, scope string, flags []Flag) error {
|
|
seen := make(map[string]bool, len(flags))
|
|
for _, flag := range flags {
|
|
if flag.Name == "" {
|
|
return fmt.Errorf("command %q contains an empty %s flag name", path, scope)
|
|
}
|
|
if seen[flag.Name] {
|
|
return fmt.Errorf("command %q contains duplicate %s flag %q", path, scope, flag.Name)
|
|
}
|
|
seen[flag.Name] = true
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateFlagAliases(command Command) error {
|
|
flags := make(map[string]Flag, len(command.LocalFlags)+len(command.InheritedFlags))
|
|
ordered := make([]Flag, 0, len(command.LocalFlags)+len(command.InheritedFlags))
|
|
for _, flag := range command.LocalFlags {
|
|
flags[flag.Name] = flag
|
|
ordered = append(ordered, flag)
|
|
}
|
|
for _, flag := range command.InheritedFlags {
|
|
if _, exists := flags[flag.Name]; exists {
|
|
return fmt.Errorf("command %q contains flag %q in both local and inherited scopes", command.Path, flag.Name)
|
|
}
|
|
flags[flag.Name] = flag
|
|
ordered = append(ordered, flag)
|
|
}
|
|
|
|
for _, flag := range ordered {
|
|
if flag.malformedAliasEvidence {
|
|
return fmt.Errorf(
|
|
"command %q flag --%s must declare one exact %q value and framework origin %q",
|
|
command.Path,
|
|
flag.Name,
|
|
FlagAliasOfAnnotation,
|
|
runtimeannotate.FlagAliasOriginCorecmdV1,
|
|
)
|
|
}
|
|
if flag.AliasOf == "" {
|
|
continue
|
|
}
|
|
if !isExactFlagName(flag.AliasOf) {
|
|
return fmt.Errorf(
|
|
"command %q flag --%s has non-exact alias_of target %q",
|
|
command.Path,
|
|
flag.Name,
|
|
flag.AliasOf,
|
|
)
|
|
}
|
|
if flag.AliasOf == flag.Name {
|
|
return fmt.Errorf("command %q flag --%s cannot alias itself", command.Path, flag.Name)
|
|
}
|
|
target, exists := flags[flag.AliasOf]
|
|
if !exists {
|
|
return fmt.Errorf(
|
|
"command %q flag --%s aliases missing flag --%s",
|
|
command.Path,
|
|
flag.Name,
|
|
flag.AliasOf,
|
|
)
|
|
}
|
|
if target.AliasOf != "" {
|
|
return fmt.Errorf(
|
|
"command %q flag --%s aliases --%s, which is itself an alias",
|
|
command.Path,
|
|
flag.Name,
|
|
flag.AliasOf,
|
|
)
|
|
}
|
|
if flag.Type != target.Type {
|
|
return fmt.Errorf(
|
|
"command %q alias flags --%s and --%s have different types %q and %q",
|
|
command.Path,
|
|
flag.Name,
|
|
flag.AliasOf,
|
|
flag.Type,
|
|
target.Type,
|
|
)
|
|
}
|
|
}
|
|
return nil
|
|
}
|