Implement coverage profile and output parsing, snapshot comparison, and a JSON-backed coverage store. Co-Authored-By: Virgil <virgil@lethean.io>
323 lines
8.3 KiB
Go
323 lines
8.3 KiB
Go
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
|
|
})
|
|
}
|