Files

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
}