go-devops/devkit/coverage.go

324 lines
8.3 KiB
Go
Raw Normal View History

package devkit
import (
"encoding/json"
"fmt"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// CoveragePackage describes coverage for a single package or directory.
type CoveragePackage struct {
Name string `json:"name"`
CoveredStatements int `json:"covered_statements"`
TotalStatements int `json:"total_statements"`
Coverage float64 `json:"coverage"`
}
// CoverageSnapshot captures a point-in-time view of coverage across packages.
type CoverageSnapshot struct {
CapturedAt time.Time `json:"captured_at"`
Packages []CoveragePackage `json:"packages"`
Total CoveragePackage `json:"total"`
}
// CoverageDelta describes how a single package changed between snapshots.
type CoverageDelta struct {
Name string `json:"name"`
Previous float64 `json:"previous"`
Current float64 `json:"current"`
Delta float64 `json:"delta"`
}
// CoverageComparison summarises the differences between two coverage snapshots.
type CoverageComparison struct {
Regressions []CoverageDelta `json:"regressions"`
Improvements []CoverageDelta `json:"improvements"`
NewPackages []CoveragePackage `json:"new_packages"`
Removed []CoveragePackage `json:"removed"`
TotalDelta float64 `json:"total_delta"`
}
// CoverageStore persists coverage snapshots to disk.
type CoverageStore struct {
path string
}
type coverageBucket struct {
covered int
total int
}
var coverProfileLineRE = regexp.MustCompile(`^(.+?):\d+\.\d+,\d+\.\d+\s+(\d+)\s+(\d+)$`)
var coverOutputLineRE = regexp.MustCompile(`^(?:ok|\?)?\s*(\S+)\s+.*coverage:\s+([0-9]+(?:\.[0-9]+)?)% of statements$`)
// NewCoverageStore creates a store backed by the given file path.
func NewCoverageStore(path string) *CoverageStore {
return &CoverageStore{path: path}
}
// Append stores a new snapshot, creating the parent directory if needed.
func (s *CoverageStore) Append(snapshot CoverageSnapshot) error {
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
snapshots, err := s.Load()
if err != nil {
return err
}
snapshot.CapturedAt = snapshot.CapturedAt.UTC()
snapshots = append(snapshots, snapshot)
data, err := json.MarshalIndent(snapshots, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0o600)
}
// Load reads all snapshots from disk.
func (s *CoverageStore) Load() ([]CoverageSnapshot, error) {
data, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
if len(strings.TrimSpace(string(data))) == 0 {
return nil, nil
}
var snapshots []CoverageSnapshot
if err := json.Unmarshal(data, &snapshots); err != nil {
return nil, err
}
return snapshots, nil
}
// Latest returns the newest snapshot in the store.
func (s *CoverageStore) Latest() (CoverageSnapshot, error) {
snapshots, err := s.Load()
if err != nil {
return CoverageSnapshot{}, err
}
if len(snapshots) == 0 {
return CoverageSnapshot{}, fmt.Errorf("coverage store is empty")
}
return snapshots[len(snapshots)-1], nil
}
// ParseCoverProfile parses go test -coverprofile output into a coverage snapshot.
func ParseCoverProfile(data string) (CoverageSnapshot, error) {
if strings.TrimSpace(data) == "" {
return CoverageSnapshot{}, nil
}
packages := make(map[string]*coverageBucket)
total := coverageBucket{}
for _, rawLine := range strings.Split(strings.TrimSpace(data), "\n") {
line := strings.TrimSpace(rawLine)
if line == "" || strings.HasPrefix(line, "mode:") {
continue
}
match := coverProfileLineRE.FindStringSubmatch(line)
if match == nil {
return CoverageSnapshot{}, fmt.Errorf("invalid cover profile line: %s", line)
}
file := filepath.ToSlash(match[1])
stmts, err := strconv.Atoi(match[2])
if err != nil {
return CoverageSnapshot{}, err
}
count, err := strconv.Atoi(match[3])
if err != nil {
return CoverageSnapshot{}, err
}
dir := path.Dir(file)
if dir == "" {
dir = "."
}
b := packages[dir]
if b == nil {
b = &coverageBucket{}
packages[dir] = b
}
b.total += stmts
total.total += stmts
if count > 0 {
b.covered += stmts
total.covered += stmts
}
}
return snapshotFromBuckets(packages, total), nil
}
// ParseCoverOutput parses human-readable go test -cover output into a snapshot.
func ParseCoverOutput(output string) (CoverageSnapshot, error) {
if strings.TrimSpace(output) == "" {
return CoverageSnapshot{}, nil
}
packages := make(map[string]*CoveragePackage)
var total CoveragePackage
for _, rawLine := range strings.Split(strings.TrimSpace(output), "\n") {
line := strings.TrimSpace(rawLine)
if line == "" {
continue
}
match := coverOutputLineRE.FindStringSubmatch(line)
if match == nil {
continue
}
name := match[1]
coverage, err := strconv.ParseFloat(match[2], 64)
if err != nil {
return CoverageSnapshot{}, err
}
pkg := &CoveragePackage{
Name: name,
Coverage: coverage,
}
packages[name] = pkg
total.Coverage += coverage
total.TotalStatements++
}
if len(packages) == 0 {
return CoverageSnapshot{}, nil
}
snapshot := CoverageSnapshot{
CapturedAt: time.Now().UTC(),
Packages: make([]CoveragePackage, 0, len(packages)),
}
for _, pkg := range packages {
snapshot.Packages = append(snapshot.Packages, *pkg)
}
sort.Slice(snapshot.Packages, func(i, j int) bool {
return snapshot.Packages[i].Name < snapshot.Packages[j].Name
})
snapshot.Total.Name = "total"
if total.TotalStatements > 0 {
snapshot.Total.Coverage = total.Coverage / float64(total.TotalStatements)
}
return snapshot, nil
}
// CompareCoverage compares two snapshots and reports regressions and improvements.
func CompareCoverage(previous, current CoverageSnapshot) CoverageComparison {
prevPackages := coverageMap(previous.Packages)
currPackages := coverageMap(current.Packages)
comparison := CoverageComparison{
NewPackages: make([]CoveragePackage, 0),
Removed: make([]CoveragePackage, 0),
}
for name, curr := range currPackages {
prev, ok := prevPackages[name]
if !ok {
comparison.NewPackages = append(comparison.NewPackages, curr)
continue
}
delta := curr.Coverage - prev.Coverage
change := CoverageDelta{
Name: name,
Previous: prev.Coverage,
Current: curr.Coverage,
Delta: delta,
}
if delta < 0 {
comparison.Regressions = append(comparison.Regressions, change)
} else if delta > 0 {
comparison.Improvements = append(comparison.Improvements, change)
}
}
for name, prev := range prevPackages {
if _, ok := currPackages[name]; !ok {
comparison.Removed = append(comparison.Removed, prev)
}
}
sortCoverageComparison(&comparison)
comparison.TotalDelta = current.Total.Coverage - previous.Total.Coverage
return comparison
}
func snapshotFromBuckets(packages map[string]*coverageBucket, total coverageBucket) CoverageSnapshot {
snapshot := CoverageSnapshot{
CapturedAt: time.Now().UTC(),
Packages: make([]CoveragePackage, 0, len(packages)),
}
for name, b := range packages {
snapshot.Packages = append(snapshot.Packages, coverageAverage(name, b.covered, b.total))
}
sort.Slice(snapshot.Packages, func(i, j int) bool {
return snapshot.Packages[i].Name < snapshot.Packages[j].Name
})
snapshot.Total = coverageAverage("total", total.covered, total.total)
return snapshot
}
func coverageAverage(name string, covered, total int) CoveragePackage {
pkg := CoveragePackage{
Name: name,
CoveredStatements: covered,
TotalStatements: total,
}
if total > 0 {
pkg.Coverage = float64(covered) / float64(total) * 100
}
return pkg
}
func coverageMap(packages []CoveragePackage) map[string]CoveragePackage {
result := make(map[string]CoveragePackage, len(packages))
for _, pkg := range packages {
result[pkg.Name] = pkg
}
return result
}
func sortCoverageComparison(comparison *CoverageComparison) {
sort.Slice(comparison.Regressions, func(i, j int) bool {
return comparison.Regressions[i].Name < comparison.Regressions[j].Name
})
sort.Slice(comparison.Improvements, func(i, j int) bool {
return comparison.Improvements[i].Name < comparison.Improvements[j].Name
})
sort.Slice(comparison.NewPackages, func(i, j int) bool {
return comparison.NewPackages[i].Name < comparison.NewPackages[j].Name
})
sort.Slice(comparison.Removed, func(i, j int) bool {
return comparison.Removed[i].Name < comparison.Removed[j].Name
})
}