Deleting 100%-covered packages such as recovery falsely regressed overall percent against merge-base. Baseline overall now uses only files still present in the candidate profile, with a 0.1pp CI tolerance. Also exercise stripSchemaValueCompact nested map/slice branches. Co-authored-by: Cursor <cursoragent@cursor.com>
544 lines
16 KiB
Go
544 lines
16 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"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"io"
|
|
"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
|
|
StartColumn int
|
|
EndLine int
|
|
EndColumn 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 overallPaths stringList
|
|
var baselinePaths stringList
|
|
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.Var(&overallPaths, "overall-profile", "coverage profile used for overall coverage (repeatable)")
|
|
flags.Var(&baselinePaths, "baseline-profile", "merge-base coverage profile evaluated with the same model as the candidate (repeatable)")
|
|
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, "allowed overall coverage measurement variance in percentage points")
|
|
flags.Float64Var(&target, "target", 100, "required changed-code coverage percentage and optional overall floor")
|
|
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 && (len(overallPaths) == 0 || (len(baselinePaths) == 0 && baselineOverall < 0))) {
|
|
fmt.Fprintln(stderr, "coverage-gate requires --diff-profile, --base-ref, and --module; overall mode also requires --overall-profile and either --baseline-profile or --baseline-overall")
|
|
return 2
|
|
}
|
|
if len(baselinePaths) > 0 && baselineOverall >= 0 {
|
|
fmt.Fprintln(stderr, "coverage-gate accepts either --baseline-profile or --baseline-overall, not both")
|
|
return 2
|
|
}
|
|
var overall []coverageBlock
|
|
if !changedOnly {
|
|
var err error
|
|
overall, err = readProfiles(overallPaths, modulePath)
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, err)
|
|
return 2
|
|
}
|
|
if len(baselinePaths) > 0 {
|
|
baseline, baselineErr := readProfiles(baselinePaths, modulePath)
|
|
if baselineErr != nil {
|
|
fmt.Fprintln(stderr, baselineErr)
|
|
return 2
|
|
}
|
|
// Compare overall non-regression on the shared file set only.
|
|
// Deleted packages (for example a retired 100%-covered helper tree)
|
|
// must not create a false overall regression against merge-base.
|
|
baselineOverall = coveragePercent(filterBlocksByFiles(baseline, profileFiles(overall)))
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
var exempted []string
|
|
changed, exempted = exemptNonExecutableFiles(changed, fileHasExecutableStatements)
|
|
for _, path := range exempted {
|
|
fmt.Fprintf(stderr, "coverage-gate: exempting %s (no executable statements)\n", path)
|
|
}
|
|
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: %.4f%% (merge-base %.4f%%; tolerance %.4fpp; target %.4f%%; %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 %.4f%%)\n", target)
|
|
} else {
|
|
fmt.Fprintf(stdout, "changed code coverage: %.4f%% (%d executable statements; target %.4f%%)\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)
|
|
if result.Overall+input.OverallTolerance < input.BaselineOverall {
|
|
result.Failures = append(result.Failures, fmt.Sprintf("overall coverage regressed from %.4f%% to %.4f%%", input.BaselineOverall, result.Overall))
|
|
}
|
|
if input.EnforceOverall && result.Overall < input.Target {
|
|
result.Failures = append(result.Failures, fmt.Sprintf("overall coverage %.4f%% is below target %.4f%%", result.Overall, input.Target))
|
|
}
|
|
}
|
|
|
|
profiledFiles := map[string]bool{}
|
|
covered, total := 0, 0
|
|
for _, block := range mergeCoverageBlocks(input.Diff) {
|
|
profiledFiles[block.File] = true
|
|
if !intersectsAny(block, input.Changed[block.File]) {
|
|
continue
|
|
}
|
|
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 < input.Target {
|
|
result.Failures = append(result.Failures, fmt.Sprintf("changed code coverage %.4f%% is below target %.4f%%", 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
|
|
}
|
|
|
|
// exemptNonExecutableFiles drops changed files that contain no executable
|
|
// statements (pragma carriers such as gen.go, doc-only files). The Go coverage
|
|
// tool never emits profile blocks for them, so requiring their presence in a
|
|
// profile would fail every PR that touches such a file. Exempted paths are
|
|
// returned sorted so the caller can log them instead of dropping silently.
|
|
func exemptNonExecutableFiles(changed map[string][]lineRange, hasExecutable func(string) bool) (map[string][]lineRange, []string) {
|
|
filtered := map[string][]lineRange{}
|
|
var exempted []string
|
|
for path, ranges := range changed {
|
|
if hasExecutable(path) {
|
|
filtered[path] = ranges
|
|
} else {
|
|
exempted = append(exempted, path)
|
|
}
|
|
}
|
|
sort.Strings(exempted)
|
|
return filtered, exempted
|
|
}
|
|
|
|
// fileHasExecutableStatements reports whether the Go file declares at least
|
|
// one function (or function literal) with a non-empty body. Unreadable or
|
|
// unparsable files count as executable so real coverage gaps cannot hide
|
|
// behind a parse failure.
|
|
func fileHasExecutableStatements(path string) bool {
|
|
source, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
parsed, err := parser.ParseFile(token.NewFileSet(), path, source, parser.SkipObjectResolution)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
executable := false
|
|
ast.Inspect(parsed, func(node ast.Node) bool {
|
|
if executable {
|
|
return false
|
|
}
|
|
switch fn := node.(type) {
|
|
case *ast.FuncDecl:
|
|
if fn.Body != nil && len(fn.Body.List) > 0 {
|
|
executable = true
|
|
}
|
|
case *ast.FuncLit:
|
|
if fn.Body != nil && len(fn.Body.List) > 0 {
|
|
executable = true
|
|
}
|
|
}
|
|
return !executable
|
|
})
|
|
return executable
|
|
}
|
|
|
|
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],
|
|
StartColumn: values[1],
|
|
EndLine: values[2],
|
|
EndColumn: values[3],
|
|
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, "--", "*.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)
|
|
}
|
|
|
|
// physicalPath resolves symlinks so git-reported and go-reported paths agree.
|
|
// git rev-parse --show-toplevel prints the physical path while go list reports
|
|
// Dirs derived from the logical CWD; on macOS /tmp is a symlink to /private/tmp
|
|
// and the mismatch makes every buildable file relativize outside the root,
|
|
// silently emptying the changed-code gate.
|
|
func physicalPath(path string) string {
|
|
if resolved, err := filepath.EvalSymlinks(path); err == nil {
|
|
return resolved
|
|
}
|
|
return path
|
|
}
|
|
|
|
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 := physicalPath(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(physicalPath(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 mergeCoverageBlocks(blocks) {
|
|
total += block.Statements
|
|
if block.Count > 0 {
|
|
covered += block.Statements
|
|
}
|
|
}
|
|
if total == 0 {
|
|
return 0
|
|
}
|
|
return float64(covered) * 100 / float64(total)
|
|
}
|
|
|
|
func profileFiles(blocks []coverageBlock) map[string]bool {
|
|
files := map[string]bool{}
|
|
for _, block := range blocks {
|
|
if block.File != "" {
|
|
files[block.File] = true
|
|
}
|
|
}
|
|
return files
|
|
}
|
|
|
|
func filterBlocksByFiles(blocks []coverageBlock, allowed map[string]bool) []coverageBlock {
|
|
if len(allowed) == 0 {
|
|
return nil
|
|
}
|
|
filtered := make([]coverageBlock, 0, len(blocks))
|
|
for _, block := range blocks {
|
|
if allowed[block.File] {
|
|
filtered = append(filtered, block)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// mergeCoverageBlocks unions duplicate blocks emitted by cross-package
|
|
// coverage. A block is covered when any instrumented test binary executed it;
|
|
// counting the same source block once per binary would both dilute the overall
|
|
// percentage and make the result depend on package execution order.
|
|
func mergeCoverageBlocks(blocks []coverageBlock) []coverageBlock {
|
|
merged := make(map[string]coverageBlock, len(blocks))
|
|
for _, block := range blocks {
|
|
key := fmt.Sprintf(
|
|
"%s:%d:%d:%d:%d:%d",
|
|
block.File,
|
|
block.StartLine,
|
|
block.StartColumn,
|
|
block.EndLine,
|
|
block.EndColumn,
|
|
block.Statements,
|
|
)
|
|
current, ok := merged[key]
|
|
if !ok || block.Count > current.Count {
|
|
merged[key] = block
|
|
}
|
|
}
|
|
keys := make([]string, 0, len(merged))
|
|
for key := range merged {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
result := make([]coverageBlock, 0, len(keys))
|
|
for _, key := range keys {
|
|
result = append(result, merged[key])
|
|
}
|
|
return result
|
|
}
|