* 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>
260 lines
8.6 KiB
Go
260 lines
8.6 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestRun(t *testing.T) {
|
|
profile := filepath.Join(t.TempDir(), "coverage.out")
|
|
body := "mode: atomic\nexample.com/project/internal/a.go:10.1,12.2 5 1\n"
|
|
if err := os.WriteFile(profile, []byte(body), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
args := []string{
|
|
"--overall-profile", profile,
|
|
"--diff-profile", profile,
|
|
"--base-ref", "base",
|
|
"--module", "example.com/project",
|
|
"--baseline-overall", "100",
|
|
"--target", "80",
|
|
"--enforce-overall-target=true",
|
|
}
|
|
loader := func(ref string) (map[string][]lineRange, error) {
|
|
if ref != "base" {
|
|
t.Fatalf("base ref=%q", ref)
|
|
}
|
|
return map[string][]lineRange{"internal/a.go": {{Start: 10, End: 12}}}, nil
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
if code := run(args, &stdout, &stderr, loader, nil); code != 0 {
|
|
t.Fatalf("run code=%d stderr=%s", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "overall coverage: 100.0%") || !strings.Contains(stdout.String(), "changed code coverage: 100.0%") {
|
|
t.Fatalf("unexpected output %q", stdout.String())
|
|
}
|
|
|
|
stderr.Reset()
|
|
if code := run(nil, &stdout, &stderr, loader, nil); code != 2 {
|
|
t.Fatalf("missing arguments code=%d, want 2", code)
|
|
}
|
|
stderr.Reset()
|
|
badLoader := func(string) (map[string][]lineRange, error) { return nil, errors.New("diff failed") }
|
|
if code := run(args, &stdout, &stderr, badLoader, nil); code != 2 || !strings.Contains(stderr.String(), "diff failed") {
|
|
t.Fatalf("loader failure code=%d stderr=%q", code, stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunChangedOnlyWithBuildableScope(t *testing.T) {
|
|
profile := filepath.Join(t.TempDir(), "coverage.out")
|
|
body := "mode: atomic\nexample.com/project/internal/a.go:10.1,12.2 5 1\n"
|
|
if err := os.WriteFile(profile, []byte(body), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
args := []string{
|
|
"--changed-only",
|
|
"--scope-buildable",
|
|
"--diff-profile", profile,
|
|
"--base-ref", "base",
|
|
"--module", "example.com/project",
|
|
"--target", "80",
|
|
}
|
|
loader := func(string) (map[string][]lineRange, error) {
|
|
return map[string][]lineRange{
|
|
"internal/a.go": {{Start: 10, End: 12}},
|
|
"internal/a_windows.go": {{Start: 1, End: 3}},
|
|
}, nil
|
|
}
|
|
buildableLoader := func() (map[string]bool, error) {
|
|
return map[string]bool{"internal/a.go": true}, nil
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
if code := run(args, &stdout, &stderr, loader, buildableLoader); code != 0 {
|
|
t.Fatalf("run code=%d stderr=%s", code, stderr.String())
|
|
}
|
|
if strings.Contains(stdout.String(), "overall coverage") || !strings.Contains(stdout.String(), "changed code coverage: 100.0%") {
|
|
t.Fatalf("unexpected changed-only output %q", stdout.String())
|
|
}
|
|
|
|
missingProfileScope := func() (map[string]bool, error) {
|
|
return map[string]bool{"internal/a.go": true, "internal/a_windows.go": true}, nil
|
|
}
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
if code := run(args, &stdout, &stderr, loader, missingProfileScope); code != 1 || !strings.Contains(stderr.String(), "internal/a_windows.go") {
|
|
t.Fatalf("missing native profile code=%d stderr=%q", code, stderr.String())
|
|
}
|
|
|
|
brokenScope := func() (map[string]bool, error) { return nil, errors.New("go list failed") }
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
if code := run(args, &stdout, &stderr, loader, brokenScope); code != 2 || !strings.Contains(stderr.String(), "go list failed") {
|
|
t.Fatalf("scope failure code=%d stderr=%q", code, stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestReadProfiles(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "coverage.out")
|
|
if err := os.WriteFile(path, []byte("mode: set\nexample.com/project/internal/a.go:1.1,2.2 3 1\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
blocks, err := readProfiles([]string{path}, "example.com/project")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(blocks) != 1 || blocks[0].File != "internal/a.go" || blocks[0].Statements != 3 {
|
|
t.Fatalf("blocks=%v", blocks)
|
|
}
|
|
if _, err := readProfiles([]string{filepath.Join(t.TempDir(), "missing")}, "example.com/project"); err == nil {
|
|
t.Fatal("missing profile should fail")
|
|
}
|
|
}
|
|
|
|
func TestParseChangedLinesRejectsInvalidHunk(t *testing.T) {
|
|
_, err := parseChangedLines([]byte("+++ b/internal/a.go\n@@ invalid @@\n"))
|
|
if err == nil {
|
|
t.Fatal("invalid hunk should fail")
|
|
}
|
|
}
|
|
|
|
func TestGitChangedLines(t *testing.T) {
|
|
repository := t.TempDir()
|
|
runGit := func(args ...string) string {
|
|
t.Helper()
|
|
command := exec.Command("git", args...)
|
|
command.Dir = repository
|
|
output, err := command.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("git %v: %v\n%s", args, err, output)
|
|
}
|
|
return strings.TrimSpace(string(output))
|
|
}
|
|
runGit("init")
|
|
runGit("config", "user.email", "coverage@example.com")
|
|
runGit("config", "user.name", "Coverage Test")
|
|
path := filepath.Join(repository, "a.go")
|
|
if err := os.WriteFile(path, []byte("package sample\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runGit("add", "a.go")
|
|
runGit("commit", "-m", "base")
|
|
base := runGit("rev-parse", "HEAD")
|
|
if err := os.WriteFile(path, []byte("package sample\n\nfunc added() int { return 1 }\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runGit("add", "a.go")
|
|
runGit("commit", "-m", "change")
|
|
|
|
previous, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chdir(repository); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.Chdir(previous) })
|
|
changed, err := gitChangedLines(base)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(changed["a.go"]) == 0 {
|
|
t.Fatalf("changed lines=%v", changed)
|
|
}
|
|
}
|
|
|
|
func TestParseChangedLines(t *testing.T) {
|
|
diff := []byte("diff --git a/internal/a.go b/internal/a.go\n+++ b/internal/a.go\n@@ -1,0 +2,3 @@\n+one\n+two\n+three\n" +
|
|
"diff --git a/internal/a_test.go b/internal/a_test.go\n+++ b/internal/a_test.go\n@@ -0,0 +1,2 @@\n+test\n")
|
|
got, err := parseChangedLines(diff)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := map[string][]lineRange{"internal/a.go": {{Start: 2, End: 4}}}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("parseChangedLines() = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestFilterChangedFiles(t *testing.T) {
|
|
changed := map[string][]lineRange{
|
|
"internal/a.go": {{Start: 1, End: 2}},
|
|
"internal/a_windows.go": {{Start: 3, End: 4}},
|
|
}
|
|
got := filterChangedFiles(changed, map[string]bool{"internal/a_windows.go": true})
|
|
want := map[string][]lineRange{"internal/a_windows.go": {{Start: 3, End: 4}}}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("filterChangedFiles() = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestEvaluateAllowsNonRegressionAndCoveredChanges(t *testing.T) {
|
|
result := evaluate(gateInput{
|
|
Overall: []coverageBlock{
|
|
{File: "internal/a.go", Statements: 8, Count: 1},
|
|
{File: "internal/a.go", Statements: 2, Count: 0},
|
|
},
|
|
Diff: []coverageBlock{
|
|
{File: "internal/a.go", StartLine: 10, EndLine: 12, Statements: 4, Count: 1},
|
|
{File: "internal/a.go", StartLine: 20, EndLine: 22, Statements: 1, Count: 0},
|
|
},
|
|
Changed: map[string][]lineRange{"internal/a.go": {{Start: 10, End: 22}}},
|
|
BaselineOverall: 80,
|
|
Target: 80,
|
|
})
|
|
if len(result.Failures) != 0 {
|
|
t.Fatalf("unexpected failures: %v", result.Failures)
|
|
}
|
|
if result.ChangedCoverage != 80 {
|
|
t.Fatalf("changed coverage = %.1f, want 80", result.ChangedCoverage)
|
|
}
|
|
}
|
|
|
|
func TestEvaluateAllowsMeasurementTolerance(t *testing.T) {
|
|
result := evaluate(gateInput{
|
|
Overall: []coverageBlock{{Statements: 403, Count: 1}, {Statements: 597, Count: 0}},
|
|
Changed: map[string][]lineRange{},
|
|
BaselineOverall: 40.4,
|
|
OverallTolerance: 0.1,
|
|
Target: 80,
|
|
})
|
|
if len(result.Failures) != 0 {
|
|
t.Fatalf("measurement tolerance should allow 40.3%% vs 40.4%%: %v", result.Failures)
|
|
}
|
|
}
|
|
|
|
func TestEvaluateFailsClosed(t *testing.T) {
|
|
result := evaluate(gateInput{
|
|
Overall: []coverageBlock{{Statements: 10, Count: 1}, {Statements: 10, Count: 0}},
|
|
Diff: []coverageBlock{{File: "internal/a.go", StartLine: 1, EndLine: 2, Statements: 2, Count: 0}},
|
|
Changed: map[string][]lineRange{"internal/a.go": {{Start: 1, End: 2}}, "internal/missing.go": {{Start: 1, End: 1}}},
|
|
BaselineOverall: 54.2,
|
|
Target: 80,
|
|
})
|
|
if len(result.Failures) != 3 {
|
|
t.Fatalf("got failures %v, want overall, changed-code, and missing-profile failures", result.Failures)
|
|
}
|
|
}
|
|
|
|
func TestEvaluateOverallTargetCanBeEnabled(t *testing.T) {
|
|
result := evaluate(gateInput{
|
|
Overall: []coverageBlock{{Statements: 60, Count: 1}, {Statements: 40, Count: 0}},
|
|
Diff: []coverageBlock{},
|
|
Changed: map[string][]lineRange{},
|
|
BaselineOverall: 54.2,
|
|
Target: 80,
|
|
EnforceOverall: true,
|
|
})
|
|
if len(result.Failures) != 1 {
|
|
t.Fatalf("failures = %v, want overall target failure", result.Failures)
|
|
}
|
|
}
|