diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f83fce6b..9a982cac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,38 @@ jobs: # version: v1.64.8 # args: ./... + code-check: + name: Code Check + needs: [lint, test, policy, edition-tests] + if: ${{ always() }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Verify code checks + env: + LINT_RESULT: ${{ needs.lint.result }} + TEST_RESULT: ${{ needs.test.result }} + POLICY_RESULT: ${{ needs.policy.result }} + EDITION_TESTS_RESULT: ${{ needs.edition-tests.result }} + run: | + set -eu + failed=0 + for check in \ + "Lint:$LINT_RESULT" \ + "Test:$TEST_RESULT" \ + "Policy Check:$POLICY_RESULT" \ + "Edition Contract Tests:$EDITION_TESTS_RESULT" + do + name="${check%%:*}" + result="${check#*:}" + printf '%s: %s\n' "$name" "$result" + if [ "$result" != "success" ]; then + failed=1 + fi + done + test "$failed" -eq 0 + test: name: Test runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7251571a..de7eff27 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,7 @@ git diff --check 1. Keep implementation and tests in sync. 2. Run `./scripts/dev/ci-local.sh`. -3. Run `./scripts/policy/check-command-surface.sh --strict` when command paths/flags change. +3. Run `./scripts/policy/check-command-surface.sh --strict` when command paths/flags change. CI also runs `./scripts/policy/check-command-compatibility.sh --base-ref --stable-ref ` against both the target branch and latest stable release. 4. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may change. 5. Run `./scripts/release/verify-package-managers.sh` when packaging or installer surfaces change (run `make package` first). 6. Update docs and `CHANGELOG.md` for behavior/interface changes. diff --git a/cmd/interface-snapshot/main.go b/cmd/interface-snapshot/main.go new file mode 100644 index 00000000..d921bff9 --- /dev/null +++ b/cmd/interface-snapshot/main.go @@ -0,0 +1,195 @@ +// 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. + +// interface-snapshot is an internal CI helper. It is intentionally a separate +// binary so it can be copied into a temporary worktree and compiled against an +// older revision's real Cobra root. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot" +) + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +func run(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + printUsage(stderr) + return 2 + } + + switch args[0] { + case "generate": + if err := runGenerate(args[1:], stdout, stderr); err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + return 0 + case "compare": + compatible, err := runCompare(args[1:], stdout, stderr) + if err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + if !compatible { + return 1 + } + return 0 + default: + fmt.Fprintf(stderr, "unknown command %q\n", args[0]) + printUsage(stderr) + return 2 + } +} + +func runGenerate(args []string, stdout, stderr io.Writer) error { + flags := flag.NewFlagSet("generate", flag.ContinueOnError) + flags.SetOutput(stderr) + output := flags.String("output", "-", "snapshot output path, or - for stdout") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("generate accepts no positional arguments") + } + + home, err := os.MkdirTemp("", "dws-interface-snapshot-*") + if err != nil { + return fmt.Errorf("create isolated home: %w", err) + } + defer os.RemoveAll(home) + + environment := map[string]string{ + "DWS_CONFIG_DIR": home, + "DWS_LANG": "en", + "HOME": home, + "NO_COLOR": "1", + "USERPROFILE": home, + } + type previousEnv struct { + value string + set bool + } + previous := make(map[string]previousEnv, len(environment)) + for key, value := range environment { + oldValue, wasSet := os.LookupEnv(key) + previous[key] = previousEnv{value: oldValue, set: wasSet} + if err := os.Setenv(key, value); err != nil { + return fmt.Errorf("set %s: %w", key, err) + } + } + defer func() { + for key, old := range previous { + if old.set { + _ = os.Setenv(key, old.value) + } else { + _ = os.Unsetenv(key) + } + } + }() + previousLang := i18n.Lang() + defer i18n.SetLang(previousLang) + i18n.SetLang("en") + + snapshot := interfacesnapshot.Capture(app.NewRootCommand()) + if *output == "-" { + return interfacesnapshot.Write(stdout, snapshot) + } + + file, err := os.Create(filepath.Clean(*output)) + if err != nil { + return fmt.Errorf("create snapshot %q: %w", *output, err) + } + writeErr := interfacesnapshot.Write(file, snapshot) + closeErr := file.Close() + if writeErr != nil { + return fmt.Errorf("write snapshot %q: %w", *output, writeErr) + } + if closeErr != nil { + return fmt.Errorf("close snapshot %q: %w", *output, closeErr) + } + return nil +} + +func runCompare(args []string, stdout, stderr io.Writer) (bool, error) { + flags := flag.NewFlagSet("compare", flag.ContinueOnError) + flags.SetOutput(stderr) + currentPath := flags.String("current", "", "candidate snapshot path") + basePath := flags.String("base", "", "target main/development baseline snapshot path") + stablePath := flags.String("stable", "", "latest stable GA snapshot path") + if err := flags.Parse(args); err != nil { + return false, err + } + if flags.NArg() != 0 { + return false, fmt.Errorf("compare accepts no positional arguments") + } + if *currentPath == "" { + return false, fmt.Errorf("compare requires --current") + } + if *basePath == "" && *stablePath == "" { + return false, fmt.Errorf("compare requires --base, --stable, or both") + } + + current, err := readSnapshot(*currentPath) + if err != nil { + return false, fmt.Errorf("read current snapshot: %w", err) + } + references := make(map[string]interfacesnapshot.Snapshot, 2) + if *basePath != "" { + references["main"], err = readSnapshot(*basePath) + if err != nil { + return false, fmt.Errorf("read main/development baseline snapshot: %w", err) + } + } + if *stablePath != "" { + references["stable"], err = readSnapshot(*stablePath) + if err != nil { + return false, fmt.Errorf("read stable snapshot: %w", err) + } + } + + report := interfacesnapshot.CompareAll(current, references) + encoder := json.NewEncoder(stdout) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", " ") + if err := encoder.Encode(report); err != nil { + return false, fmt.Errorf("write comparison report: %w", err) + } + return report.Compatible, nil +} + +func readSnapshot(path string) (interfacesnapshot.Snapshot, error) { + file, err := os.Open(filepath.Clean(path)) + if err != nil { + return interfacesnapshot.Snapshot{}, err + } + defer file.Close() + return interfacesnapshot.Read(file) +} + +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]") +} diff --git a/cmd/interface-snapshot/main_test.go b/cmd/interface-snapshot/main_test.go new file mode 100644 index 00000000..8b791770 --- /dev/null +++ b/cmd/interface-snapshot/main_test.go @@ -0,0 +1,130 @@ +// 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 main + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot" +) + +func TestRunGenerateCapturesActualRootOffline(t *testing.T) { + var stdout, stderr bytes.Buffer + if exitCode := run([]string{"generate"}, &stdout, &stderr); exitCode != 0 { + t.Fatalf("run(generate) exit=%d stderr=%s", exitCode, stderr.String()) + } + snapshot, err := interfacesnapshot.Read(bytes.NewReader(stdout.Bytes())) + if err != nil { + t.Fatalf("decode generated snapshot: %v", err) + } + + commands := make(map[string]interfacesnapshot.Command, len(snapshot.Commands)) + for _, command := range snapshot.Commands { + commands[command.Path] = command + } + for _, path := range []string{"dws", "dws chat", "dws dev app create"} { + if _, ok := commands[path]; !ok { + t.Errorf("actual root snapshot is missing %q", path) + } + } + for _, path := range []string{"dws completion", "dws help"} { + if _, ok := commands[path]; ok { + t.Errorf("framework-noise path %q leaked into snapshot", path) + } + } + + create := commands["dws dev app create"] + if !hasFlag(create.LocalFlags, "name", "string") { + t.Errorf("dev app create local flags do not contain --name string: %#v", create.LocalFlags) + } + if !hasFlag(create.InheritedFlags, "profile", "string") { + t.Errorf("dev app create inherited flags do not contain --profile string: %#v", create.InheritedFlags) + } +} + +func TestRunCompareUsesBothSnapshotInputsAndExitCode(t *testing.T) { + current := commandSnapshot("dws") + mergeBase := commandSnapshot("dws") + stable := commandSnapshot("dws", "dws legacy") + + dir := t.TempDir() + currentPath := writeSnapshot(t, dir, "current.json", current) + mergeBasePath := writeSnapshot(t, dir, "base.json", mergeBase) + stablePath := writeSnapshot(t, dir, "stable.json", stable) + + var stdout, stderr bytes.Buffer + exitCode := run([]string{ + "compare", + "--current", currentPath, + "--base", mergeBasePath, + "--stable", stablePath, + }, &stdout, &stderr) + if exitCode != 1 { + t.Fatalf("run(compare) exit=%d, want 1; stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"reference": "main"`)) || + !bytes.Contains(stdout.Bytes(), []byte(`"reference": "stable"`)) || + !bytes.Contains(stdout.Bytes(), []byte(`"kind": "command_removed"`)) { + t.Fatalf("comparison report does not contain both references and the blocking change:\n%s", stdout.String()) + } +} + +func commandSnapshot(paths ...string) interfacesnapshot.Snapshot { + commands := make([]interfacesnapshot.Command, 0, len(paths)) + for _, path := range paths { + commands = append(commands, interfacesnapshot.Command{ + Path: path, + Aliases: []string{}, + LocalFlags: []interfacesnapshot.Flag{}, + InheritedFlags: []interfacesnapshot.Flag{}, + }) + } + return interfacesnapshot.Snapshot{ + SchemaVersion: interfacesnapshot.SchemaVersion, + Rules: interfacesnapshot.Rules{ + ExcludedCommandSubtrees: []string{}, + ExcludedFlags: []string{}, + }, + Commands: commands, + } +} + +func writeSnapshot(t *testing.T, dir, name string, snapshot interfacesnapshot.Snapshot) string { + t.Helper() + path := filepath.Join(dir, name) + file, err := os.Create(path) + if err != nil { + t.Fatalf("create %s: %v", path, err) + } + if err := interfacesnapshot.Write(file, snapshot); err != nil { + file.Close() + t.Fatalf("write %s: %v", path, err) + } + if err := file.Close(); err != nil { + t.Fatalf("close %s: %v", path, err) + } + return path +} + +func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool { + for _, flag := range flags { + if flag.Name == name && flag.Type == flagType { + return true + } + } + return false +} diff --git a/internal/interfacesnapshot/compare.go b/internal/interfacesnapshot/compare.go new file mode 100644 index 00000000..79fcd2d3 --- /dev/null +++ b/internal/interfacesnapshot/compare.go @@ -0,0 +1,441 @@ +// 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, +// - new commands and flags are allowed. +// +// 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 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 { + 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.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 +} diff --git a/internal/interfacesnapshot/compare_test.go b/internal/interfacesnapshot/compare_test.go new file mode 100644 index 00000000..f3ef67c9 --- /dev/null +++ b/internal/interfacesnapshot/compare_test.go @@ -0,0 +1,367 @@ +// 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 "testing" + +func TestCompareAdmissionPolicy(t *testing.T) { + base := testSnapshot( + testCommand("dws"), + testCommand("dws search", testFlag("query", "string", false)), + ) + + tests := []struct { + name string + current Snapshot + compatible bool + kind string + }{ + { + name: "command addition allowed", + current: testSnapshot( + testCommand("dws"), + testCommand("dws search", testFlag("query", "string", false)), + testCommand("dws status"), + ), + compatible: true, + }, + { + name: "flag addition allowed", + current: testSnapshot( + testCommand("dws"), + testCommand("dws search", + testFlag("limit", "int", false), + testFlag("query", "string", false), + ), + ), + compatible: true, + }, + { + name: "required flag addition blocked on existing command", + current: testSnapshot( + testCommand("dws"), + testCommand("dws search", + testFlag("query", "string", false), + testFlag("tenant", "string", true), + ), + ), + kind: "required_flag_added", + }, + { + name: "command deletion blocked", + current: testSnapshot( + testCommand("dws"), + ), + kind: "command_removed", + }, + { + name: "rename without alias blocked", + current: testSnapshot( + testCommand("dws"), + testCommand("dws find", testFlag("query", "string", false)), + ), + kind: "command_removed", + }, + { + name: "flag deletion blocked", + current: testSnapshot( + testCommand("dws"), + testCommand("dws search"), + ), + kind: "flag_removed", + }, + { + name: "optional becoming required blocked", + current: testSnapshot( + testCommand("dws"), + testCommand("dws search", testFlag("query", "string", true)), + ), + kind: "flag_became_required", + }, + { + name: "flag type change blocked", + current: testSnapshot( + testCommand("dws"), + testCommand("dws search", testFlag("query", "stringSlice", false)), + ), + kind: "flag_type_changed", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + comparison := Compare(test.current, base, "base") + if comparison.Compatible != test.compatible { + t.Fatalf("Compatible = %v, want %v; blocking=%#v", comparison.Compatible, test.compatible, comparison.Blocking) + } + if test.kind == "" { + return + } + if !hasChangeKind(comparison.Blocking, test.kind) { + t.Fatalf("blocking=%#v, want kind %q", comparison.Blocking, test.kind) + } + }) + } +} + +func TestCompareAllowsRenameWhenOldPathIsAlias(t *testing.T) { + base := testSnapshot( + testCommand("dws"), + testCommand("dws search", testFlag("query", "string", false)), + ) + current := testSnapshot( + testCommand("dws"), + testCommandWithAliases("dws find", []string{"search"}, testFlag("query", "string", false)), + ) + + comparison := Compare(current, base, "base") + if !comparison.Compatible { + t.Fatalf("rename with compatibility alias was blocked: %#v", comparison.Blocking) + } +} + +func TestCompareBlocksRemovedAlias(t *testing.T) { + base := testSnapshot( + testCommand("dws"), + testCommandWithAliases("dws search", []string{"find"}), + ) + current := testSnapshot( + testCommand("dws"), + testCommand("dws search"), + ) + + comparison := Compare(current, base, "base") + if comparison.Compatible || !hasChangeKind(comparison.Blocking, "command_alias_removed") { + t.Fatalf("removed alias was not blocked: %#v", comparison) + } +} + +func TestCompareBlocksAliasRetargetedToIncompatibleCommand(t *testing.T) { + base := testSnapshot( + testCommand("dws"), + testCommandWithAliases("dws search", []string{"find"}, testFlag("query", "string", false)), + ) + current := testSnapshot( + testCommand("dws"), + testCommand("dws search", testFlag("query", "string", false)), + testCommand("dws find"), + ) + + comparison := Compare(current, base, "base") + if comparison.Compatible || !hasFlagChange(comparison.Blocking, "flag_removed", "dws find", "query") { + t.Fatalf("alias retarget was not checked against its old contract: %#v", comparison) + } +} + +func TestCompareBlocksSnapshotRuleChanges(t *testing.T) { + base := testSnapshot(testCommand("dws")) + current := testSnapshot(testCommand("dws")) + current.Rules.ExcludedFlags = append(current.Rules.ExcludedFlags, "legacy") + + comparison := Compare(current, base, "base") + if comparison.Compatible || !hasChangeKind(comparison.Blocking, "snapshot_rules_changed") { + t.Fatalf("snapshot rule change was not blocked: %#v", comparison) + } +} + +func TestCompareBlocksCallableMetadataRegressions(t *testing.T) { + baseFlag := testFlag("format", "string", false) + baseFlag.Shorthand = "f" + baseFlag.NoOpt = "json" + base := testSnapshot( + testCommand("dws"), + testCommand("dws export", baseFlag), + ) + + tests := []struct { + name string + command Command + kind string + }{ + { + name: "command became non-runnable", + command: func() Command { + command := testCommand("dws export", baseFlag) + command.Runnable = false + return command + }(), + kind: "command_became_non_runnable", + }, + { + name: "command became hidden", + command: func() Command { + command := testCommand("dws export", baseFlag) + command.Hidden = true + return command + }(), + kind: "command_became_hidden", + }, + { + name: "flag shorthand removed", + command: func() Command { + flag := baseFlag + flag.Shorthand = "" + return testCommand("dws export", flag) + }(), + kind: "flag_shorthand_changed", + }, + { + name: "flag no-opt behavior removed", + command: func() Command { + flag := baseFlag + flag.NoOpt = "" + return testCommand("dws export", flag) + }(), + kind: "flag_no_opt_changed", + }, + { + name: "flag became hidden", + command: func() Command { + flag := baseFlag + flag.Hidden = true + return testCommand("dws export", flag) + }(), + kind: "flag_became_hidden", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + current := testSnapshot(testCommand("dws"), test.command) + comparison := Compare(current, base, "base") + if comparison.Compatible || !hasChangeKind(comparison.Blocking, test.kind) { + t.Fatalf("metadata regression %q was not blocked: %#v", test.kind, comparison) + } + }) + } +} + +func TestCompareUsesEffectiveFlagsAtEveryCommandPath(t *testing.T) { + base := testSnapshot( + testCommand("dws", testFlag("profile", "string", false)), + testCommandWithFlagScopes( + "dws search", + []Flag{testFlag("query", "string", false)}, + []Flag{testFlag("profile", "string", false)}, + ), + ) + + t.Run("persistent flag scope narrowing is blocked", func(t *testing.T) { + current := testSnapshot( + testCommand("dws", testFlag("profile", "string", false)), + testCommand("dws search", testFlag("query", "string", false)), + ) + comparison := Compare(current, base, "base") + if comparison.Compatible || !hasFlagChange(comparison.Blocking, "flag_removed", "dws search", "profile") { + t.Fatalf("lost inherited flag was not blocked: %#v", comparison) + } + }) + + t.Run("local flag moved to inherited remains compatible", func(t *testing.T) { + current := testSnapshot( + testCommand("dws", + testFlag("profile", "string", false), + testFlag("query", "string", false), + ), + testCommandWithFlagScopes( + "dws search", + nil, + []Flag{ + testFlag("profile", "string", false), + testFlag("query", "string", false), + }, + ), + ) + comparison := Compare(current, base, "base") + if !comparison.Compatible { + t.Fatalf("flag moved to an inherited scope was blocked: %#v", comparison.Blocking) + } + }) +} + +func TestCompareAllRequiresBothReferencesToPass(t *testing.T) { + current := testSnapshot(testCommand("dws"), testCommand("dws status")) + mergeBase := testSnapshot(testCommand("dws")) + stable := testSnapshot(testCommand("dws"), testCommand("dws legacy")) + + report := CompareAll(current, map[string]Snapshot{ + "stable": stable, + "merge-base": mergeBase, + }) + if report.Compatible { + t.Fatal("aggregate report passed even though stable comparison removed a command") + } + if len(report.Comparisons) != 2 || report.Comparisons[0].Reference != "merge-base" || report.Comparisons[1].Reference != "stable" { + t.Fatalf("comparisons are not deterministic: %#v", report.Comparisons) + } + if !report.Comparisons[0].Compatible || report.Comparisons[1].Compatible { + t.Fatalf("unexpected per-reference results: %#v", report.Comparisons) + } +} + +func testSnapshot(commands ...Command) Snapshot { + return Snapshot{ + SchemaVersion: SchemaVersion, + Rules: Rules{ + ExcludedCommandSubtrees: append([]string(nil), excludedCommandSubtrees...), + ExcludedFlags: []string{"help"}, + }, + Commands: commands, + } +} + +func testCommand(path string, flags ...Flag) Command { + return testCommandWithAliases(path, nil, flags...) +} + +func testCommandWithAliases(path string, aliases []string, flags ...Flag) Command { + return Command{ + Path: path, + Runnable: true, + Aliases: aliases, + LocalFlags: flags, + InheritedFlags: []Flag{}, + } +} + +func testCommandWithFlagScopes(path string, local, inherited []Flag) Command { + return Command{ + Path: path, + Aliases: []string{}, + LocalFlags: local, + InheritedFlags: inherited, + } +} + +func testFlag(name, flagType string, required bool) Flag { + return Flag{Name: name, Type: flagType, Required: required} +} + +func hasChangeKind(changes []Change, kind string) bool { + for _, change := range changes { + if change.Kind == kind { + return true + } + } + return false +} + +func hasFlagChange(changes []Change, kind, path, flag string) bool { + for _, change := range changes { + if change.Kind == kind && change.Path == path && change.Flag == flag { + return true + } + } + return false +} diff --git a/internal/interfacesnapshot/snapshot.go b/internal/interfacesnapshot/snapshot.go new file mode 100644 index 00000000..58b26c54 --- /dev/null +++ b/internal/interfacesnapshot/snapshot.go @@ -0,0 +1,270 @@ +// 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/spf13/cobra" + "github.com/spf13/pflag" +) + +const SchemaVersion = 1 + +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"` +} + +// 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"` +} + +// 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) + + 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()), + }) + + 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 err := validateFlags(command.Path, "local", command.LocalFlags); err != nil { + return err + } + if err := validateFlags(command.Path, "inherited", command.InheritedFlags); 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 + } + 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), + }) + }) + sort.Slice(flags, func(i, j int) bool { return flags[i].Name < flags[j].Name }) + return flags +} + +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 +} diff --git a/internal/interfacesnapshot/snapshot_test.go b/internal/interfacesnapshot/snapshot_test.go new file mode 100644 index 00000000..fdcfa411 --- /dev/null +++ b/internal/interfacesnapshot/snapshot_test.go @@ -0,0 +1,119 @@ +// 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 ( + "bytes" + "reflect" + "testing" + + "github.com/spf13/cobra" +) + +func TestCaptureUsesStableNoiseRulesAndFlagScopes(t *testing.T) { + root := &cobra.Command{Use: "dws", Version: "test"} + root.PersistentFlags().String("profile", "", "profile") + + service := &cobra.Command{ + Use: "service", + Aliases: []string{"svc", "api", "svc"}, + Hidden: true, + Deprecated: "use replacement", + } + leaf := &cobra.Command{Use: "run", Run: func(*cobra.Command, []string) {}} + leaf.Flags().String("name", "", "name") + leaf.Flags().Bool("force", false, "force") + if err := leaf.MarkFlagRequired("name"); err != nil { + t.Fatalf("MarkFlagRequired: %v", err) + } + leaf.InitDefaultHelpFlag() + service.AddCommand(leaf) + + help := &cobra.Command{Use: "help"} + completion := &cobra.Command{Use: "completion"} + completion.AddCommand(&cobra.Command{Use: "zsh"}) + root.AddCommand(service, help, completion) + + snapshot := Capture(root) + wantPaths := []string{"dws", "dws service", "dws service run"} + if got := sortedCommandPaths(snapshot.Commands); !reflect.DeepEqual(got, wantPaths) { + t.Fatalf("paths = %v, want %v", got, wantPaths) + } + + serviceSnapshot := commandIndex(snapshot)["dws service"] + if !serviceSnapshot.Hidden || serviceSnapshot.Deprecated != "use replacement" { + t.Fatalf("service metadata = %#v", serviceSnapshot) + } + if want := []string{"api", "svc"}; !reflect.DeepEqual(serviceSnapshot.Aliases, want) { + t.Fatalf("aliases = %v, want %v", serviceSnapshot.Aliases, want) + } + + leafSnapshot := commandIndex(snapshot)["dws service run"] + if !leafSnapshot.Runnable { + t.Fatal("runnable leaf was not recorded as runnable") + } + local := flagIndex(leafSnapshot.LocalFlags) + if len(local) != 2 { + t.Fatalf("local flags = %#v, want two business flags and no auto help flag", leafSnapshot.LocalFlags) + } + if local["name"].Type != "string" || !local["name"].Required { + t.Fatalf("name flag = %#v, want required string", local["name"]) + } + if local["force"].Type != "bool" || local["force"].Required { + t.Fatalf("force flag = %#v, want optional bool", local["force"]) + } + inherited := flagIndex(leafSnapshot.InheritedFlags) + if inherited["profile"].Type != "string" { + t.Fatalf("inherited flags = %#v, want root --profile", leafSnapshot.InheritedFlags) + } + if _, exists := inherited["help"]; exists { + t.Fatal("auto --help leaked into inherited flags") + } + + var first, second bytes.Buffer + if err := Write(&first, snapshot); err != nil { + t.Fatalf("first Write: %v", err) + } + if err := Write(&second, Capture(root)); err != nil { + t.Fatalf("second Write: %v", err) + } + if !bytes.Equal(first.Bytes(), second.Bytes()) { + t.Fatal("capturing the same Cobra root twice produced different JSON") + } +} + +func TestReadRejectsUnknownSnapshotFields(t *testing.T) { + input := bytes.NewBufferString(`{ + "schema_version": 1, + "rules": {"excluded_command_subtrees": [], "excluded_flags": []}, + "commands": [], + "future_field": true +}`) + if _, err := Read(input); err == nil { + t.Fatal("Read accepted an unknown field") + } +} + +func TestCompareBlocksCandidateSiblingAliasCollision(t *testing.T) { + base := testSnapshot(testCommand("dws")) + current := testSnapshot( + testCommand("dws"), + testCommandWithAliases("dws search", []string{"find"}), + testCommand("dws find"), + ) + comparison := Compare(current, base, "base") + if comparison.Compatible || !hasChangeKind(comparison.Blocking, "command_alias_collision") { + t.Fatalf("candidate sibling alias collision was not blocked: %#v", comparison) + } +} diff --git a/scripts/policy/check-command-compatibility.sh b/scripts/policy/check-command-compatibility.sh new file mode 100755 index 00000000..a15a69bb --- /dev/null +++ b/scripts/policy/check-command-compatibility.sh @@ -0,0 +1,122 @@ +#!/bin/sh +set -eu + +# Compare the current Cobra command surface with two local Git revisions: +# the PR merge-base/main reference and the latest stable GA tag. The caller must +# pass the highest non-prerelease SemVer tag (excluding beta/rc tags). Tag +# governance treats that tag as proof of a successfully published GA release; +# release automation must not leave a GA tag behind when publication fails. +# +# The script never fetches refs or snapshots from the network. With checkout +# fetch-depth=0, both references are materialized in temporary worktrees. A +# reference uses its own snapshot helper when available; older revisions are +# bootstrapped with the candidate helper. + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +BASE_REF="" +STABLE_REF="" + +usage() { + printf '%s\n' "usage: $0 --base-ref --stable-ref " >&2 +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --base-ref) + [ "$#" -ge 2 ] || { usage; exit 2; } + BASE_REF="$2" + shift 2 + ;; + --stable-ref) + [ "$#" -ge 2 ] || { usage; exit 2; } + STABLE_REF="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + printf 'unknown argument: %s\n' "$1" >&2 + usage + exit 2 + ;; + esac +done + +[ -n "$BASE_REF" ] && [ -n "$STABLE_REF" ] || { usage; exit 2; } + +cd "$ROOT" +git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null || { + printf 'base ref is not available locally: %s\n' "$BASE_REF" >&2 + exit 2 +} +git rev-parse --verify --quiet "${STABLE_REF}^{commit}" >/dev/null || { + printf 'stable ref is not available locally: %s\n' "$STABLE_REF" >&2 + exit 2 +} + +TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-command-compat.XXXXXX")" +BASE_WORKTREE="$TMP_ROOT/base-worktree" +STABLE_WORKTREE="$TMP_ROOT/stable-worktree" + +cleanup() { + git -C "$ROOT" worktree remove --force "$BASE_WORKTREE" >/dev/null 2>&1 || true + git -C "$ROOT" worktree remove --force "$STABLE_WORKTREE" >/dev/null 2>&1 || true + rm -rf "$TMP_ROOT" +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +ensure_snapshot_helper() { + worktree="$1" + + # Once a baseline contains the helper, use that revision's own capture + # rules. This makes a rule/filter change in the candidate visible as a + # snapshot_rules_changed failure instead of applying the new exclusion to + # both sides and accidentally hiding a removed command. Older revisions + # created before this helper existed are bootstrapped with the candidate + # implementation. + if [ -f "$worktree/cmd/interface-snapshot/main.go" ] && \ + [ -f "$worktree/internal/interfacesnapshot/snapshot.go" ] && \ + [ -f "$worktree/internal/interfacesnapshot/compare.go" ]; then + return + fi + if [ -e "$worktree/cmd/interface-snapshot" ] || \ + [ -e "$worktree/internal/interfacesnapshot" ]; then + printf 'baseline contains an incomplete interface snapshot helper: %s\n' "$worktree" >&2 + exit 2 + fi + + mkdir -p "$worktree/cmd/interface-snapshot" "$worktree/internal/interfacesnapshot" + cp -R "$ROOT/cmd/interface-snapshot/." "$worktree/cmd/interface-snapshot/" + cp -R "$ROOT/internal/interfacesnapshot/." "$worktree/internal/interfacesnapshot/" +} + +generate_ref_snapshot() { + ref="$1" + worktree="$2" + output="$3" + + git -C "$ROOT" worktree add --detach "$worktree" "$ref" >/dev/null + ensure_snapshot_helper "$worktree" + ( + cd "$worktree" + go run ./cmd/interface-snapshot generate --output "$output" + ) +} + +CANDIDATE="$TMP_ROOT/candidate.json" +BASELINE="$TMP_ROOT/merge-base.json" +STABLE="$TMP_ROOT/stable.json" + +go run ./cmd/interface-snapshot generate --output "$CANDIDATE" +generate_ref_snapshot "$BASE_REF" "$BASE_WORKTREE" "$BASELINE" +generate_ref_snapshot "$STABLE_REF" "$STABLE_WORKTREE" "$STABLE" + +go run ./cmd/interface-snapshot compare \ + --current "$CANDIDATE" \ + --base "$BASELINE" \ + --stable "$STABLE" diff --git a/test/mock_mcp/mock_mcp_smoke_test.go b/test/mock_mcp/mock_mcp_smoke_test.go new file mode 100644 index 00000000..a97cb649 --- /dev/null +++ b/test/mock_mcp/mock_mcp_smoke_test.go @@ -0,0 +1,219 @@ +package mock_mcp_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "reflect" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app" +) + +const mockMCPSmokeHelperEnv = "DWS_MOCK_MCP_SMOKE_HELPER" + +type recordedToolCall struct { + path string + method string + authorization string + jsonrpc string + tool string + arguments map[string]any + err error +} + +// TestCLIHelperProcess runs the production CLI entrypoint with real os.Args. +// The parent test supplies only isolated temp directories, a loopback endpoint, +// and a synthetic token accepted by the local fake server. +func TestCLIHelperProcess(t *testing.T) { + if os.Getenv(mockMCPSmokeHelperEnv) != "1" { + return + } + + marker := -1 + for i, arg := range os.Args { + if arg == "--" { + marker = i + break + } + } + if marker < 0 { + fmt.Fprintln(os.Stderr, "Mock MCP smoke helper: missing -- argument marker") + os.Exit(2) + } + os.Args = append([]string{"dws"}, os.Args[marker+1:]...) + os.Exit(app.Execute()) +} + +func TestMockMCPSmoke_CLIRoutesSerializedArgumentsAndPrintsJSON(t *testing.T) { + var requestsMu sync.Mutex + var requests []recordedToolCall + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + call := recordedToolCall{ + path: r.URL.Path, + method: r.Method, + authorization: r.Header.Get("Authorization"), + } + + var envelope struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + } `json:"params"` + } + if err := json.NewDecoder(r.Body).Decode(&envelope); err != nil { + call.err = err + } else { + call.jsonrpc = envelope.JSONRPC + call.tool = envelope.Params.Name + call.arguments = envelope.Params.Arguments + if envelope.Method != "tools/call" { + call.err = fmt.Errorf("JSON-RPC method = %q, want tools/call", envelope.Method) + } + } + requestsMu.Lock() + requests = append(requests, call) + requestsMu.Unlock() + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": envelope.ID, + "result": map[string]any{ + "content": []map[string]any{{ + "type": "text", + "text": `{"success":true,"result":[{"userId":"mock-user-1","name":"Local Mock"}]}`, + }}, + }, + }) + })) + defer server.Close() + + env := isolatedCLIEnv(t, map[string]string{ + "DINGTALK_CONTACT_MCP_URL": server.URL + "/mcp/contact", + }) + args := []string{ + "--token", "ci-smoke-token", + "--format", "json", + "contact", "user", "get", + "--ids", "user-001,user-002", + } + stdout, stderr, err := runCLI(t, env, args...) + if err != nil { + t.Fatalf("dws %s failed: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(args, " "), err, stdout, stderr) + } + + requestsMu.Lock() + recorded := append([]recordedToolCall(nil), requests...) + requestsMu.Unlock() + if len(recorded) != 1 { + t.Fatalf("local fake MCP server received %d requests, want exactly one tools/call: %#v", len(recorded), recorded) + } + call := recorded[0] + if call.err != nil { + t.Fatal(call.err) + } + if call.path != "/mcp/contact" { + t.Fatalf("request path = %q, want /mcp/contact", call.path) + } + if call.method != http.MethodPost { + t.Fatalf("HTTP method = %q, want POST", call.method) + } + if call.authorization != "Bearer ci-smoke-token" { + t.Fatalf("Authorization = %q, want synthetic smoke token", call.authorization) + } + if call.jsonrpc != "2.0" { + t.Fatalf("jsonrpc = %q, want 2.0", call.jsonrpc) + } + if call.tool != "get_user_info_by_user_ids" { + t.Fatalf("tool = %q, want get_user_info_by_user_ids", call.tool) + } + wantArgs := map[string]any{"user_id_list": []any{"user-001", "user-002"}} + if !reflect.DeepEqual(call.arguments, wantArgs) { + t.Fatalf("arguments = %#v, want %#v", call.arguments, wantArgs) + } + + var payload map[string]any + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatalf("CLI returned non-JSON stdout: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr) + } + if payload["success"] != true { + t.Fatalf("CLI success = %#v, want true; payload=%#v", payload["success"], payload) + } + result, ok := payload["result"].([]any) + if !ok || len(result) != 1 { + t.Fatalf("CLI result = %#v, want one mock user", payload["result"]) + } + user, _ := result[0].(map[string]any) + if user["userId"] != "mock-user-1" { + t.Fatalf("CLI userId = %#v, want mock-user-1; payload=%#v", user["userId"], payload) + } +} + +func isolatedCLIEnv(t *testing.T, extra map[string]string) []string { + t.Helper() + + root := t.TempDir() + controlled := map[string]string{ + "HOME": root, + "USERPROFILE": root, + "DWS_CONFIG_DIR": filepath.Join(root, "config"), + "DWS_KEYCHAIN_DIR": filepath.Join(root, "keychain"), + "DWS_DISABLE_KEYCHAIN": "1", + "HTTP_PROXY": "http://127.0.0.1:1", + "HTTPS_PROXY": "http://127.0.0.1:1", + "http_proxy": "http://127.0.0.1:1", + "https_proxy": "http://127.0.0.1:1", + "NO_PROXY": "127.0.0.1,localhost,::1", + "no_proxy": "127.0.0.1,localhost,::1", + mockMCPSmokeHelperEnv: "1", + "DWS_ALLOW_HTTP_ENDPOINTS": "1", + "DWS_TRUSTED_DOMAINS": "127.0.0.1,localhost,::1", + } + for key, value := range extra { + controlled[key] = value + } + + env := make([]string, 0, len(controlled)+8) + for _, key := range []string{"PATH", "TMPDIR", "TEMP", "TMP", "LANG", "LC_ALL", "TZ", "SYSTEMROOT"} { + if value := os.Getenv(key); value != "" { + env = append(env, key+"="+value) + } + } + for key, value := range controlled { + env = append(env, key+"="+value) + } + sort.Strings(env) + return env +} + +func runCLI(t *testing.T, env []string, args ...string) (string, string, error) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + processArgs := append([]string{"-test.run=^TestCLIHelperProcess$", "--"}, args...) + cmd := exec.CommandContext(ctx, os.Args[0], processArgs...) + cmd.Env = env + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if ctx.Err() != nil { + t.Fatalf("dws %s timed out: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(args, " "), ctx.Err(), stdout.String(), stderr.String()) + } + return stdout.String(), stderr.String(), err +} diff --git a/test/smoke/cli_smoke_test.go b/test/smoke/cli_smoke_test.go new file mode 100644 index 00000000..27f86e5c --- /dev/null +++ b/test/smoke/cli_smoke_test.go @@ -0,0 +1,212 @@ +package smoke_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app" + "github.com/spf13/cobra" +) + +const cliSmokeHelperEnv = "DWS_CLI_SMOKE_HELPER" + +// TestCLIHelperProcess executes the real app entrypoint in a subprocess. Product +// routing inspects os.Args, so using Cobra.SetArgs would not exercise the same +// path as the dws binary. +func TestCLIHelperProcess(t *testing.T) { + if os.Getenv(cliSmokeHelperEnv) != "1" { + return + } + + marker := -1 + for i, arg := range os.Args { + if arg == "--" { + marker = i + break + } + } + if marker < 0 { + fmt.Fprintln(os.Stderr, "CLI smoke helper: missing -- argument marker") + os.Exit(2) + } + os.Args = append([]string{"dws"}, os.Args[marker+1:]...) + os.Exit(app.Execute()) +} + +func TestCLISmoke_AllPublicCommandsSupportHelp(t *testing.T) { + _ = isolatedCLIEnv(t) + + root := app.NewRootCommand() + root.InitDefaultHelpCmd() + paths := publicCommandPaths(root) + if len(paths) == 0 { + t.Fatal("public command traversal returned no commands") + } + + for _, path := range paths { + path := path + name := "root" + if len(path) > 0 { + name = strings.Join(path, "/") + } + t.Run(name, func(t *testing.T) { + args := append(append([]string(nil), path...), "--help") + // Help never executes a product handler, so SetArgs is sufficient here. + // Real product dispatch is covered below through app.Execute in a + // subprocess because that path intentionally inspects os.Args. + cmd := app.NewRootCommand() + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetErr(&output) + cmd.SetArgs(args) + err := cmd.Execute() + if err != nil { + t.Fatalf("dws %s failed: %v\noutput:\n%s", strings.Join(args, " "), err, output.String()) + } + if strings.TrimSpace(output.String()) == "" { + t.Fatalf("dws %s returned empty help", strings.Join(args, " ")) + } + }) + } + + t.Logf("validated --help for %d public Cobra command paths", len(paths)) +} + +func TestCLISmoke_RepresentativeStaticCommandsReturnMockJSON(t *testing.T) { + env := isolatedCLIEnv(t) + + tests := []struct { + name string + args []string + wantTool string + }{ + { + name: "contact search", + args: []string{"--mock", "--format", "json", "contact", "user", "search", "--query", "Ada"}, + wantTool: "search_contact_by_key_word", + }, + { + name: "calendar list", + args: []string{ + "--mock", "--format", "json", "calendar", "event", "list", + "--start", "2026-07-10T09:00:00+08:00", + "--end", "2026-07-10T10:00:00+08:00", + }, + wantTool: "list_calendar_events", + }, + { + name: "ding list", + args: []string{"--mock", "--format", "json", "ding", "message", "list", "--type", "UNREAD"}, + wantTool: "list_ding_messages", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + stdout, stderr, err := runCLI(t, env, tc.args...) + if err != nil { + t.Fatalf("dws %s failed: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(tc.args, " "), err, stdout, stderr) + } + + var payload map[string]any + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatalf("dws %s returned non-JSON stdout: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(tc.args, " "), err, stdout, stderr) + } + if payload["_mock"] != true { + t.Fatalf("dws %s _mock = %#v, want true; payload=%#v", strings.Join(tc.args, " "), payload["_mock"], payload) + } + if payload["_tool"] != tc.wantTool { + t.Fatalf("dws %s _tool = %#v, want %q; payload=%#v", strings.Join(tc.args, " "), payload["_tool"], tc.wantTool, payload) + } + }) + } +} + +// publicCommandPaths follows only canonical, user-visible Cobra commands. +// Hidden commands and commands marked Deprecated by Cobra are intentionally +// excluded; aliases are not separate command nodes. Parents and leaves are both +// checked, while positional placeholders in Use are never synthesized. +func publicCommandPaths(root *cobra.Command) [][]string { + var paths [][]string + var walk func(*cobra.Command, []string) + walk = func(cmd *cobra.Command, path []string) { + if cmd != root && (cmd.Hidden || cmd.Deprecated != "") { + return + } + + paths = append(paths, append([]string(nil), path...)) + for _, child := range cmd.Commands() { + walk(child, append(path, child.Name())) + } + } + walk(root, nil) + + sort.Slice(paths, func(i, j int) bool { + return strings.Join(paths[i], " ") < strings.Join(paths[j], " ") + }) + return paths +} + +func isolatedCLIEnv(t *testing.T) []string { + t.Helper() + + root := t.TempDir() + controlled := map[string]string{ + "HOME": root, + "USERPROFILE": root, + "DWS_CONFIG_DIR": filepath.Join(root, "config"), + "DWS_KEYCHAIN_DIR": filepath.Join(root, "keychain"), + "DWS_DISABLE_KEYCHAIN": "1", + "HTTP_PROXY": "http://127.0.0.1:1", + "HTTPS_PROXY": "http://127.0.0.1:1", + "http_proxy": "http://127.0.0.1:1", + "https_proxy": "http://127.0.0.1:1", + "NO_PROXY": "127.0.0.1,localhost,::1", + "no_proxy": "127.0.0.1,localhost,::1", + cliSmokeHelperEnv: "1", + "DWS_ALLOW_HTTP_ENDPOINTS": "1", + "DWS_TRUSTED_DOMAINS": "127.0.0.1,localhost,::1", + } + for key, value := range controlled { + t.Setenv(key, value) + } + + env := make([]string, 0, len(controlled)+8) + for _, key := range []string{"PATH", "TMPDIR", "TEMP", "TMP", "LANG", "LC_ALL", "TZ", "SYSTEMROOT"} { + if value := os.Getenv(key); value != "" { + env = append(env, key+"="+value) + } + } + for key, value := range controlled { + env = append(env, key+"="+value) + } + sort.Strings(env) + return env +} + +func runCLI(t *testing.T, env []string, args ...string) (string, string, error) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + processArgs := append([]string{"-test.run=^TestCLIHelperProcess$", "--"}, args...) + cmd := exec.CommandContext(ctx, os.Args[0], processArgs...) + cmd.Env = env + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if ctx.Err() != nil { + t.Fatalf("dws %s timed out: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(args, " "), ctx.Err(), stdout.String(), stderr.String()) + } + return stdout.String(), stderr.String(), err +}