Files
dingtalk-workspace-cli/scripts/policy/interface-baseline/contract.go
T
SCzhengand修雨 809d026b7e ci: add CLI compatibility and PR quality gates (#602)
* ci(interface): 添加接口完整性和CLI烟雾测试检查

- 在Makefile中新增interface-integrity、update-interface-baseline、cli-smoke和mock-mcp-smoke目标
- 实现接口基线脚本以比较CLI公共命令树与基线文件
- 新增脚本确保二进制文件可渲染所有顶层命令的帮助信息
- 添加mock-mcp-smoke测试验证HTTP和stdio MCP请求/响应传输
- 在GitHub Actions CI工作流中加入interface-integrity、cli-smoke和mock-mcp-smoke检查
- 新增AI行为检查工作流,限制AI生成PR的改动范围和禁改保护文件
- 文档中补充PR质量门禁要求及接口变更流程说明

* feat(policy): 增加多项兼容性和完整性检查

- Makefile中新增reset-interface-baseline、schema-compatibility、update-schema-baseline、skill-command-integrity等目标
- CI流程新增schema-compatibility和skill-command-integrity步骤
- 文档中详细说明接口兼容性基线更新和重置流程及schema基线注意事项
- 实现interface-baseline工具支持兼容性重置和合并,多项接口兼容性检查逻辑完善
- 新增schema-compat工具用于标准化schema列表及兼容性检查与合并
- 新增skill-command-integrity检查确保技能中引用命令存在
- 为interface-baseline和schema-compat添加单元测试覆盖基本兼容性规则

* test(skill-command-check): 增加命令解析和解析结果测试用例

- 补充对 parseReference 函数的边界情况和跳过条件的测试
- 新增 resolveCommandReference 函数测试,覆盖有效、无效及跳过场景
- 增加 isPlaceholder 函数的测试,验证占位符识别准确性

refactor(skill-command-check): 优化命令路径解析和验证逻辑

- 使用 resolveCommandReference 统一处理命令解析结果,清晰区分有效、无效和跳过情况
- 新增 commandResolution 类型及常量,提升代码可读性和扩展性
- 调整 parseReference 增加对 shell 组合符 “ & ” 的跳过处理

docs(mono): 更新最佳实践和产品文档命令示例

- 优化《best_practices/07-minutes.md》中行动项与摘要拉取示例命令,提升准确性
- 修改产品文档中 ALIDOC 表格数据和多维表格记录相关命令,命令路径更规范统一
- 同步多端技能文档,确保命令示例一致且正确

* ci: check interface against PR merge-base

* feat(policy): enforce CLI contract compatibility

* ci: enforce PR coverage thresholds

* ci: add fail-closed CI gate

* ci: adapt schema compatibility gate to runtime catalog

* fix(ci): close schema compatibility gate gaps

* test(auth): skip POSIX mode assertion on Windows

* test(auth): scope POSIX file-backend checks

* fix(ci): enforce native platform coverage

* test(ci): make skill paths portable

---------

Co-authored-by: 修雨 <huyizhou.hyz@alibaba-inc.com>
2026-07-15 14:53:34 +08:00

599 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 {
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 {
failures = append(failures, fmt.Sprintf(
"%q flag --%s changed type from %s to %s",
displayPath(path), name, oldFlag.Type, newFlag.Type,
))
continue
}
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, ".", " ")
}