* 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>
405 lines
12 KiB
Go
405 lines
12 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
// Command coverage-gate enforces non-regressing overall coverage and a strict
|
|
// threshold for executable statements changed by the current PR.
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
profileLine = regexp.MustCompile(`^(.+):(\d+)\.(\d+),(\d+)\.(\d+)\s+(\d+)\s+(\d+)$`)
|
|
hunkHeader = regexp.MustCompile(`^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,(\d+))?\s+@@`)
|
|
)
|
|
|
|
type stringList []string
|
|
|
|
func (values *stringList) String() string { return strings.Join(*values, ",") }
|
|
func (values *stringList) Set(value string) error {
|
|
*values = append(*values, value)
|
|
return nil
|
|
}
|
|
|
|
type coverageBlock struct {
|
|
File string
|
|
StartLine int
|
|
EndLine int
|
|
Statements int
|
|
Count int
|
|
}
|
|
|
|
type lineRange struct {
|
|
Start int
|
|
End int
|
|
}
|
|
|
|
type gateInput struct {
|
|
Overall []coverageBlock
|
|
Diff []coverageBlock
|
|
Changed map[string][]lineRange
|
|
BaselineOverall float64
|
|
OverallTolerance float64
|
|
Target float64
|
|
EnforceOverall bool
|
|
ChangedOnly bool
|
|
}
|
|
|
|
type gateResult struct {
|
|
Overall float64
|
|
ChangedCoverage float64
|
|
ChangedStatements int
|
|
Failures []string
|
|
}
|
|
|
|
func main() {
|
|
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr, gitChangedLines, goListBuildableFiles))
|
|
}
|
|
|
|
func run(
|
|
args []string,
|
|
stdout, stderr io.Writer,
|
|
changedLoader func(string) (map[string][]lineRange, error),
|
|
buildableLoader func() (map[string]bool, error),
|
|
) int {
|
|
var overallPath string
|
|
var diffPaths stringList
|
|
var baseRef string
|
|
var modulePath string
|
|
var baselineOverall float64
|
|
var overallTolerance float64
|
|
var target float64
|
|
var enforceOverall bool
|
|
var changedOnly bool
|
|
var scopeBuildable bool
|
|
flags := flag.NewFlagSet("coverage-gate", flag.ContinueOnError)
|
|
flags.SetOutput(stderr)
|
|
flags.StringVar(&overallPath, "overall-profile", "", "coverage profile used for overall coverage")
|
|
flags.Var(&diffPaths, "diff-profile", "coverage profile used for changed-code coverage (repeatable)")
|
|
flags.StringVar(&baseRef, "base-ref", "", "Git merge-base or previous main SHA")
|
|
flags.StringVar(&modulePath, "module", "", "Go module path used to normalize profile filenames")
|
|
flags.Float64Var(&baselineOverall, "baseline-overall", -1, "authoritative overall coverage percentage")
|
|
flags.Float64Var(&overallTolerance, "overall-tolerance", 0.1, "allowed overall coverage measurement variance in percentage points")
|
|
flags.Float64Var(&target, "target", 80, "required changed-code and eventual overall coverage percentage")
|
|
flags.BoolVar(&enforceOverall, "enforce-overall-target", false, "require overall coverage to reach target")
|
|
flags.BoolVar(&changedOnly, "changed-only", false, "enforce changed-code coverage without an overall baseline")
|
|
flags.BoolVar(&scopeBuildable, "scope-buildable", false, "only evaluate changed files buildable on the current platform")
|
|
if err := flags.Parse(args); err != nil {
|
|
return 2
|
|
}
|
|
|
|
if len(diffPaths) == 0 || baseRef == "" || modulePath == "" || (!changedOnly && (overallPath == "" || baselineOverall < 0)) {
|
|
fmt.Fprintln(stderr, "coverage-gate requires --diff-profile, --base-ref, and --module; overall mode also requires --overall-profile and --baseline-overall")
|
|
return 2
|
|
}
|
|
var overall []coverageBlock
|
|
if !changedOnly {
|
|
var err error
|
|
overall, err = readProfiles([]string{overallPath}, modulePath)
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, err)
|
|
return 2
|
|
}
|
|
}
|
|
diff, err := readProfiles(diffPaths, modulePath)
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, err)
|
|
return 2
|
|
}
|
|
changed, err := changedLoader(baseRef)
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, err)
|
|
return 2
|
|
}
|
|
if scopeBuildable {
|
|
buildable, err := buildableLoader()
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "resolve buildable Go files: %v\n", err)
|
|
return 2
|
|
}
|
|
changed = filterChangedFiles(changed, buildable)
|
|
}
|
|
result := evaluate(gateInput{
|
|
Overall: overall,
|
|
Diff: diff,
|
|
Changed: changed,
|
|
BaselineOverall: baselineOverall,
|
|
OverallTolerance: overallTolerance,
|
|
Target: target,
|
|
EnforceOverall: enforceOverall,
|
|
ChangedOnly: changedOnly,
|
|
})
|
|
|
|
if !changedOnly {
|
|
mode := "transition: non-regression"
|
|
if enforceOverall {
|
|
mode = "required"
|
|
}
|
|
fmt.Fprintf(stdout, "overall coverage: %.1f%% (merge-base %.1f%%; tolerance %.1fpp; target %.1f%%; %s)\n", result.Overall, baselineOverall, overallTolerance, target, mode)
|
|
}
|
|
if result.ChangedStatements == 0 {
|
|
fmt.Fprintf(stdout, "changed code coverage: n/a (no changed executable statements; target %.1f%%)\n", target)
|
|
} else {
|
|
fmt.Fprintf(stdout, "changed code coverage: %.1f%% (%d executable statements; target %.1f%%)\n", result.ChangedCoverage, result.ChangedStatements, target)
|
|
}
|
|
if len(result.Failures) > 0 {
|
|
fmt.Fprintln(stderr, "coverage gate failed:")
|
|
for _, failure := range result.Failures {
|
|
fmt.Fprintf(stderr, " - %s\n", failure)
|
|
}
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func evaluate(input gateInput) gateResult {
|
|
result := gateResult{Failures: []string{}}
|
|
if !input.ChangedOnly {
|
|
result.Overall = coveragePercent(input.Overall)
|
|
baselineRounded := roundOne(input.BaselineOverall)
|
|
overallRounded := roundOne(result.Overall)
|
|
if overallRounded+input.OverallTolerance+1e-9 < baselineRounded {
|
|
result.Failures = append(result.Failures, fmt.Sprintf("overall coverage regressed from %.1f%% to %.1f%%", baselineRounded, overallRounded))
|
|
}
|
|
if input.EnforceOverall && overallRounded < input.Target {
|
|
result.Failures = append(result.Failures, fmt.Sprintf("overall coverage %.1f%% is below target %.1f%%", overallRounded, input.Target))
|
|
}
|
|
}
|
|
|
|
profiledFiles := map[string]bool{}
|
|
covered, total := 0, 0
|
|
seen := map[string]bool{}
|
|
for _, block := range input.Diff {
|
|
profiledFiles[block.File] = true
|
|
if !intersectsAny(block, input.Changed[block.File]) {
|
|
continue
|
|
}
|
|
key := fmt.Sprintf("%s:%d:%d:%d", block.File, block.StartLine, block.EndLine, block.Statements)
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
total += block.Statements
|
|
if block.Count > 0 {
|
|
covered += block.Statements
|
|
}
|
|
}
|
|
var missing []string
|
|
for path := range input.Changed {
|
|
if !profiledFiles[path] {
|
|
missing = append(missing, path)
|
|
}
|
|
}
|
|
sort.Strings(missing)
|
|
if len(missing) > 0 {
|
|
result.Failures = append(result.Failures, "changed production Go files missing from coverage profiles: "+strings.Join(missing, ", "))
|
|
}
|
|
result.ChangedStatements = total
|
|
if total > 0 {
|
|
result.ChangedCoverage = float64(covered) * 100 / float64(total)
|
|
if result.ChangedCoverage+1e-9 < input.Target {
|
|
result.Failures = append(result.Failures, fmt.Sprintf("changed code coverage %.1f%% is below target %.1f%%", result.ChangedCoverage, input.Target))
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func filterChangedFiles(changed map[string][]lineRange, allowed map[string]bool) map[string][]lineRange {
|
|
filtered := map[string][]lineRange{}
|
|
for path, ranges := range changed {
|
|
if allowed[path] {
|
|
filtered[path] = ranges
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func readProfiles(paths []string, modulePath string) ([]coverageBlock, error) {
|
|
var blocks []coverageBlock
|
|
for _, path := range paths {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open coverage profile %s: %w", path, err)
|
|
}
|
|
scanner := bufio.NewScanner(file)
|
|
line := 0
|
|
for scanner.Scan() {
|
|
line++
|
|
text := strings.TrimSpace(scanner.Text())
|
|
if line == 1 && strings.HasPrefix(text, "mode:") {
|
|
continue
|
|
}
|
|
match := profileLine.FindStringSubmatch(text)
|
|
if len(match) != 8 {
|
|
file.Close()
|
|
return nil, fmt.Errorf("%s:%d: invalid coverage profile line", path, line)
|
|
}
|
|
values := make([]int, 0, 6)
|
|
for _, raw := range match[2:] {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
file.Close()
|
|
return nil, fmt.Errorf("%s:%d: parse coverage number: %w", path, line, err)
|
|
}
|
|
values = append(values, value)
|
|
}
|
|
blocks = append(blocks, coverageBlock{
|
|
File: normalizeProfilePath(match[1], modulePath),
|
|
StartLine: values[0],
|
|
EndLine: values[2],
|
|
Statements: values[4],
|
|
Count: values[5],
|
|
})
|
|
}
|
|
err = scanner.Err()
|
|
file.Close()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read coverage profile %s: %w", path, err)
|
|
}
|
|
}
|
|
return blocks, nil
|
|
}
|
|
|
|
func gitChangedLines(baseRef string) (map[string][]lineRange, error) {
|
|
command := exec.Command("git", "diff", "--unified=0", "--no-color", baseRef+"...HEAD", "--", "*.go")
|
|
output, err := command.Output()
|
|
if err != nil {
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) {
|
|
return nil, fmt.Errorf("git diff failed: %s", strings.TrimSpace(string(exitErr.Stderr)))
|
|
}
|
|
return nil, fmt.Errorf("run git diff: %w", err)
|
|
}
|
|
return parseChangedLines(output)
|
|
}
|
|
|
|
func goListBuildableFiles() (map[string]bool, error) {
|
|
rootCommand := exec.Command("git", "rev-parse", "--show-toplevel")
|
|
rootOutput, err := rootCommand.Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve repository root: %w", err)
|
|
}
|
|
root := strings.TrimSpace(string(rootOutput))
|
|
|
|
command := exec.Command("go", "list", "-json", "./...")
|
|
output, err := command.Output()
|
|
if err != nil {
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) {
|
|
return nil, fmt.Errorf("go list failed: %s", strings.TrimSpace(string(exitErr.Stderr)))
|
|
}
|
|
return nil, fmt.Errorf("run go list: %w", err)
|
|
}
|
|
|
|
buildable := map[string]bool{}
|
|
decoder := json.NewDecoder(bytes.NewReader(output))
|
|
for {
|
|
var packageInfo struct {
|
|
Dir string
|
|
GoFiles []string
|
|
CgoFiles []string
|
|
}
|
|
if err := decoder.Decode(&packageInfo); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
return nil, fmt.Errorf("decode go list output: %w", err)
|
|
}
|
|
for _, name := range append(packageInfo.GoFiles, packageInfo.CgoFiles...) {
|
|
relative, err := filepath.Rel(root, filepath.Join(packageInfo.Dir, name))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("normalize buildable file %s: %w", name, err)
|
|
}
|
|
relative = filepath.ToSlash(relative)
|
|
if relative != ".." && !strings.HasPrefix(relative, "../") && isProductionGo(relative) {
|
|
buildable[relative] = true
|
|
}
|
|
}
|
|
}
|
|
return buildable, nil
|
|
}
|
|
|
|
func parseChangedLines(diff []byte) (map[string][]lineRange, error) {
|
|
changed := map[string][]lineRange{}
|
|
var path string
|
|
scanner := bufio.NewScanner(bytes.NewReader(diff))
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if strings.HasPrefix(line, "+++ ") {
|
|
path = strings.TrimPrefix(line, "+++ ")
|
|
path = strings.TrimPrefix(path, "b/")
|
|
if path == "/dev/null" || !isProductionGo(path) {
|
|
path = ""
|
|
}
|
|
continue
|
|
}
|
|
if path == "" || !strings.HasPrefix(line, "@@") {
|
|
continue
|
|
}
|
|
match := hunkHeader.FindStringSubmatch(line)
|
|
if len(match) == 0 {
|
|
return nil, fmt.Errorf("invalid diff hunk header %q", line)
|
|
}
|
|
start, _ := strconv.Atoi(match[1])
|
|
count := 1
|
|
if match[2] != "" {
|
|
count, _ = strconv.Atoi(match[2])
|
|
}
|
|
if count > 0 {
|
|
changed[path] = append(changed[path], lineRange{Start: start, End: start + count - 1})
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return changed, nil
|
|
}
|
|
|
|
func isProductionGo(path string) bool {
|
|
return strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go") && !strings.HasPrefix(path, "test/")
|
|
}
|
|
|
|
func normalizeProfilePath(path, modulePath string) string {
|
|
return strings.TrimPrefix(path, strings.TrimSuffix(modulePath, "/")+"/")
|
|
}
|
|
|
|
func intersectsAny(block coverageBlock, ranges []lineRange) bool {
|
|
for _, changed := range ranges {
|
|
if block.StartLine <= changed.End && block.EndLine >= changed.Start {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func coveragePercent(blocks []coverageBlock) float64 {
|
|
covered, total := 0, 0
|
|
for _, block := range blocks {
|
|
total += block.Statements
|
|
if block.Count > 0 {
|
|
covered += block.Statements
|
|
}
|
|
}
|
|
if total == 0 {
|
|
return 0
|
|
}
|
|
return float64(covered) * 100 / float64(total)
|
|
}
|
|
|
|
func roundOne(value float64) float64 { return math.Round(value*10) / 10 }
|