* 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>
303 lines
7.5 KiB
Go
303 lines
7.5 KiB
Go
// Copyright 2026 Alibaba Group
|
||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||
|
||
// Command skill-command-check verifies that executable `dws ...` references
|
||
// in published skill Markdown resolve against the current Cobra command tree.
|
||
package main
|
||
|
||
import (
|
||
"bufio"
|
||
"fmt"
|
||
"io"
|
||
"io/fs"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||
"github.com/spf13/cobra"
|
||
)
|
||
|
||
var (
|
||
inlineCommand = regexp.MustCompile("`(dws\\s+[^`]+)`")
|
||
lineCommand = regexp.MustCompile(`^\s*(?:[>$]\s*)?(dws\s+.+?)\s*$`)
|
||
antiMarkers = []string{
|
||
"禁止", "不存在", "不要使用", "不要用", "错误写法", "错误命令",
|
||
"反模式", "反例", "错例", "臆造", "虚构", "不支持", "unknown ",
|
||
"❌", "×", "已下线", "废弃",
|
||
}
|
||
antiCommands = map[string]bool{
|
||
"dws calendar list": true,
|
||
"dws minutes detail": true,
|
||
"dws minutes info": true,
|
||
"dws minutes summary": true,
|
||
"dws minutes transcribe": true,
|
||
"dws minutes transcription": true,
|
||
"dws report inbox": true,
|
||
}
|
||
)
|
||
|
||
type commandRef struct {
|
||
File string
|
||
Line int
|
||
Text string
|
||
}
|
||
|
||
type commandResolution uint8
|
||
|
||
const (
|
||
resolutionValid commandResolution = iota
|
||
resolutionInvalid
|
||
resolutionSkip
|
||
)
|
||
|
||
func main() {
|
||
rootPath, err := os.Getwd()
|
||
if err != nil {
|
||
fmt.Fprintln(os.Stderr, err)
|
||
os.Exit(2)
|
||
}
|
||
os.Exit(run(rootPath, app.NewRootCommand(), os.Stdout, os.Stderr))
|
||
}
|
||
|
||
func run(rootPath string, root *cobra.Command, stdout, stderr io.Writer) int {
|
||
refs, err := extractReferences(filepath.Join(rootPath, "skills"))
|
||
if err != nil {
|
||
fmt.Fprintln(stderr, err)
|
||
return 2
|
||
}
|
||
|
||
root.InitDefaultHelpCmd()
|
||
var failures []string
|
||
checked := map[string]bool{}
|
||
for _, ref := range refs {
|
||
path, _, skip := parseReference(ref.Text)
|
||
if skip || path == "" || antiCommands[path] {
|
||
continue
|
||
}
|
||
if checked[path] {
|
||
continue
|
||
}
|
||
|
||
switch resolveCommandReference(root, path) {
|
||
case resolutionSkip:
|
||
continue
|
||
case resolutionInvalid:
|
||
failures = append(failures, formatFailure(rootPath, ref, "command path does not exist"))
|
||
continue
|
||
case resolutionValid:
|
||
checked[path] = true
|
||
}
|
||
}
|
||
|
||
sort.Strings(failures)
|
||
if len(failures) > 0 {
|
||
fmt.Fprintf(stderr, "skill command integrity check failed (%d references):\n", len(failures))
|
||
for _, failure := range failures {
|
||
fmt.Fprintf(stderr, " - %s\n", failure)
|
||
}
|
||
return 1
|
||
}
|
||
fmt.Fprintf(stdout, "skill command integrity check: ok (%d executable command paths)\n", len(checked))
|
||
return 0
|
||
}
|
||
|
||
func extractReferences(root string) ([]commandRef, error) {
|
||
var refs []commandRef
|
||
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if entry.IsDir() || filepath.Ext(path) != ".md" {
|
||
return nil
|
||
}
|
||
file, err := os.Open(path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer file.Close()
|
||
scanner := bufio.NewScanner(file)
|
||
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||
lineNumber := 0
|
||
inFence := false
|
||
for scanner.Scan() {
|
||
lineNumber++
|
||
line := scanner.Text()
|
||
if strings.HasPrefix(strings.TrimSpace(line), "```") {
|
||
inFence = !inFence
|
||
continue
|
||
}
|
||
if isAntiPatternLine(line) {
|
||
continue
|
||
}
|
||
seen := map[string]bool{}
|
||
for _, match := range inlineCommand.FindAllStringSubmatch(line, -1) {
|
||
command := strings.TrimSpace(match[1])
|
||
seen[command] = true
|
||
refs = append(refs, commandRef{File: path, Line: lineNumber, Text: command})
|
||
}
|
||
if inFence {
|
||
match := lineCommand.FindStringSubmatch(line)
|
||
if len(match) != 2 {
|
||
continue
|
||
}
|
||
command := strings.TrimSpace(match[1])
|
||
if !seen[command] {
|
||
refs = append(refs, commandRef{File: path, Line: lineNumber, Text: command})
|
||
}
|
||
}
|
||
}
|
||
return scanner.Err()
|
||
})
|
||
return refs, err
|
||
}
|
||
|
||
func isAntiPatternLine(line string) bool {
|
||
for _, marker := range antiMarkers {
|
||
if strings.Contains(line, marker) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func parseReference(raw string) (string, []string, bool) {
|
||
if strings.Contains(raw, "|") || strings.Contains(raw, "$(") || strings.Contains(raw, "&&") || strings.Contains(raw, " & ") {
|
||
return "", nil, true
|
||
}
|
||
if comment := strings.Index(raw, " #"); comment >= 0 {
|
||
raw = raw[:comment]
|
||
}
|
||
raw = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(raw), "\\"))
|
||
if strings.Contains(raw, "[flags]") || strings.Contains(raw, "[command]") || strings.Contains(raw, "...") {
|
||
return "", nil, true
|
||
}
|
||
tokens := shellFields(raw)
|
||
if len(tokens) < 2 || tokens[0] != "dws" {
|
||
return "", nil, true
|
||
}
|
||
var pathTokens []string
|
||
var flags []string
|
||
for i := 1; i < len(tokens); i++ {
|
||
token := tokens[i]
|
||
if strings.HasPrefix(token, "--") {
|
||
name := strings.TrimPrefix(strings.SplitN(token, "=", 2)[0], "--")
|
||
if name != "" {
|
||
flags = append(flags, name)
|
||
}
|
||
if !strings.Contains(token, "=") && i+1 < len(tokens) && !strings.HasPrefix(tokens[i+1], "-") {
|
||
i++
|
||
}
|
||
continue
|
||
}
|
||
if strings.HasPrefix(token, "-") && len(token) == 2 {
|
||
// Shorthand validity is already covered by the help compatibility
|
||
// baseline; command references are keyed by long flags where present.
|
||
if i+1 < len(tokens) && !strings.HasPrefix(tokens[i+1], "-") {
|
||
i++
|
||
}
|
||
continue
|
||
}
|
||
pathTokens = append(pathTokens, token)
|
||
}
|
||
for _, token := range pathTokens {
|
||
if strings.Contains(token, "/") || strings.Contains(token, "*") {
|
||
return "", nil, true
|
||
}
|
||
}
|
||
return "dws " + strings.Join(pathTokens, " "), uniqueSorted(flags), false
|
||
}
|
||
|
||
func resolveCommandReference(root *cobra.Command, path string) commandResolution {
|
||
cmd, remaining, err := root.Find(strings.Fields(strings.TrimPrefix(path, "dws ")))
|
||
if err != nil || cmd == nil {
|
||
return resolutionInvalid
|
||
}
|
||
if len(remaining) == 0 {
|
||
return resolutionValid
|
||
}
|
||
if !cmd.HasSubCommands() {
|
||
// Once a leaf command has been found, remaining tokens are positional
|
||
// arguments. This check intentionally validates command paths only.
|
||
return resolutionValid
|
||
}
|
||
if isPlaceholder(remaining[0]) {
|
||
// Documentation such as `dws <cmd> --help` or
|
||
// `dws sheet <command> --help` describes a command shape rather than an
|
||
// executable command reference.
|
||
return resolutionSkip
|
||
}
|
||
return resolutionInvalid
|
||
}
|
||
|
||
func isPlaceholder(token string) bool {
|
||
if len(token) < 2 {
|
||
return false
|
||
}
|
||
first, last := token[0], token[len(token)-1]
|
||
return (first == '<' && last == '>') || (first == '[' && last == ']')
|
||
}
|
||
|
||
func shellFields(input string) []string {
|
||
var fields []string
|
||
var current strings.Builder
|
||
var quote rune
|
||
escaped := false
|
||
flush := func() {
|
||
if current.Len() > 0 {
|
||
fields = append(fields, current.String())
|
||
current.Reset()
|
||
}
|
||
}
|
||
for _, char := range input {
|
||
if escaped {
|
||
current.WriteRune(char)
|
||
escaped = false
|
||
continue
|
||
}
|
||
if char == '\\' && quote != '\'' {
|
||
escaped = true
|
||
continue
|
||
}
|
||
if quote != 0 {
|
||
if char == quote {
|
||
quote = 0
|
||
} else {
|
||
current.WriteRune(char)
|
||
}
|
||
continue
|
||
}
|
||
if char == '\'' || char == '"' {
|
||
quote = char
|
||
continue
|
||
}
|
||
if char == ' ' || char == '\t' {
|
||
flush()
|
||
continue
|
||
}
|
||
current.WriteRune(char)
|
||
}
|
||
flush()
|
||
return fields
|
||
}
|
||
|
||
func uniqueSorted(values []string) []string {
|
||
set := map[string]bool{}
|
||
for _, value := range values {
|
||
set[value] = true
|
||
}
|
||
result := make([]string, 0, len(set))
|
||
for value := range set {
|
||
result = append(result, value)
|
||
}
|
||
sort.Strings(result)
|
||
return result
|
||
}
|
||
|
||
func formatFailure(root string, ref commandRef, reason string) string {
|
||
relative, _ := filepath.Rel(root, ref.File)
|
||
return fmt.Sprintf("%s:%d: `%s`: %s", relative, ref.Line, ref.Text, reason)
|
||
}
|