Files
玉澜andCursor fd3c82aa91 fix(coverage): compare overall on shared files and cover compact edges
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>
2026-08-06 13:06:52 +08:00

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
}