feat(devkit): add coverage trending helpers
Implement coverage profile and output parsing, snapshot comparison, and a JSON-backed coverage store. Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
6eef0ff234
commit
cbf650918a
2 changed files with 431 additions and 0 deletions
323
devkit/coverage.go
Normal file
323
devkit/coverage.go
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
108
devkit/coverage_test.go
Normal file
108
devkit/coverage_test.go
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
package devkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseCoverProfile_Good(t *testing.T) {
|
||||||
|
snapshot, err := ParseCoverProfile(`mode: set
|
||||||
|
github.com/acme/project/foo/foo.go:1.1,3.1 2 1
|
||||||
|
github.com/acme/project/foo/bar.go:1.1,4.1 3 0
|
||||||
|
github.com/acme/project/baz/baz.go:1.1,2.1 4 4
|
||||||
|
`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, snapshot.Packages, 2)
|
||||||
|
require.Equal(t, "github.com/acme/project/baz", snapshot.Packages[0].Name)
|
||||||
|
require.Equal(t, "github.com/acme/project/foo", snapshot.Packages[1].Name)
|
||||||
|
require.InDelta(t, 100.0, snapshot.Packages[0].Coverage, 0.0001)
|
||||||
|
require.InDelta(t, 40.0, snapshot.Packages[1].Coverage, 0.0001)
|
||||||
|
require.InDelta(t, 66.6667, snapshot.Total.Coverage, 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCoverProfile_Bad(t *testing.T) {
|
||||||
|
_, err := ParseCoverProfile("mode: set\nbroken line")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCoverOutput_Good(t *testing.T) {
|
||||||
|
snapshot, err := ParseCoverOutput(`ok github.com/acme/project/foo 0.123s coverage: 75.0% of statements
|
||||||
|
ok github.com/acme/project/bar 0.456s coverage: 50.0% of statements
|
||||||
|
`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, snapshot.Packages, 2)
|
||||||
|
require.Equal(t, "github.com/acme/project/bar", snapshot.Packages[0].Name)
|
||||||
|
require.Equal(t, "github.com/acme/project/foo", snapshot.Packages[1].Name)
|
||||||
|
require.InDelta(t, 62.5, snapshot.Total.Coverage, 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareCoverage_Good(t *testing.T) {
|
||||||
|
previous := CoverageSnapshot{
|
||||||
|
Packages: []CoveragePackage{
|
||||||
|
{Name: "pkg/a", Coverage: 90.0},
|
||||||
|
{Name: "pkg/b", Coverage: 80.0},
|
||||||
|
},
|
||||||
|
Total: CoveragePackage{Name: "total", Coverage: 85.0},
|
||||||
|
}
|
||||||
|
current := CoverageSnapshot{
|
||||||
|
Packages: []CoveragePackage{
|
||||||
|
{Name: "pkg/a", Coverage: 87.5},
|
||||||
|
{Name: "pkg/b", Coverage: 82.0},
|
||||||
|
{Name: "pkg/c", Coverage: 100.0},
|
||||||
|
},
|
||||||
|
Total: CoveragePackage{Name: "total", Coverage: 89.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
comparison := CompareCoverage(previous, current)
|
||||||
|
require.Len(t, comparison.Regressions, 1)
|
||||||
|
require.Len(t, comparison.Improvements, 1)
|
||||||
|
require.Len(t, comparison.NewPackages, 1)
|
||||||
|
require.Empty(t, comparison.Removed)
|
||||||
|
require.Equal(t, "pkg/a", comparison.Regressions[0].Name)
|
||||||
|
require.Equal(t, "pkg/b", comparison.Improvements[0].Name)
|
||||||
|
require.Equal(t, "pkg/c", comparison.NewPackages[0].Name)
|
||||||
|
require.InDelta(t, 4.0, comparison.TotalDelta, 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverageStore_Good(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := NewCoverageStore(filepath.Join(dir, "coverage.json"))
|
||||||
|
|
||||||
|
first := CoverageSnapshot{
|
||||||
|
CapturedAt: time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC),
|
||||||
|
Packages: []CoveragePackage{{Name: "pkg/a", Coverage: 80.0}},
|
||||||
|
Total: CoveragePackage{Name: "total", Coverage: 80.0},
|
||||||
|
}
|
||||||
|
second := CoverageSnapshot{
|
||||||
|
CapturedAt: time.Date(2026, 4, 1, 11, 0, 0, 0, time.UTC),
|
||||||
|
Packages: []CoveragePackage{{Name: "pkg/a", Coverage: 82.5}},
|
||||||
|
Total: CoveragePackage{Name: "total", Coverage: 82.5},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, store.Append(first))
|
||||||
|
require.NoError(t, store.Append(second))
|
||||||
|
|
||||||
|
snapshots, err := store.Load()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, snapshots, 2)
|
||||||
|
require.Equal(t, first.CapturedAt, snapshots[0].CapturedAt)
|
||||||
|
require.Equal(t, second.CapturedAt, snapshots[1].CapturedAt)
|
||||||
|
|
||||||
|
latest, err := store.Latest()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, second.CapturedAt, latest.CapturedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverageStore_Bad(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "coverage.json")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("{"), 0o600))
|
||||||
|
|
||||||
|
store := NewCoverageStore(path)
|
||||||
|
_, err := store.Load()
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue