go-devops/devkit/coverage.go
Snider e20083d51b feat(devkit): Phase 4 — vulnerability scanning, complexity analysis, coverage trending
- govulncheck JSON output parsing with structured VulnFinding types (13 tests)
- Cyclomatic complexity analysis via go/ast with configurable threshold (21 tests)
- Coverage snapshot persistence and regression detection with CoverageStore (19 tests)

53 new tests, 68 total devkit tests. All pass with -race.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-20 06:28:37 +00:00

261 lines
6.4 KiB
Go

// Package devkit provides a developer toolkit for common automation commands.
// LEK-1 | lthn.ai | EUPL-1.2
package devkit
import (
"bufio"
"encoding/json"
"fmt"
"math"
"os"
"regexp"
"strconv"
"strings"
"time"
)
// CoverageSnapshot represents a point-in-time coverage measurement.
type CoverageSnapshot struct {
Timestamp time.Time `json:"timestamp"`
Packages map[string]float64 `json:"packages"` // package → coverage %
Total float64 `json:"total"` // overall coverage %
Meta map[string]string `json:"meta,omitempty"` // optional metadata (commit, branch, etc.)
}
// CoverageRegression flags a package whose coverage dropped between runs.
type CoverageRegression struct {
Package string
Previous float64
Current float64
Delta float64 // Negative means regression
}
// CoverageComparison holds the result of comparing two snapshots.
type CoverageComparison struct {
Regressions []CoverageRegression
Improvements []CoverageRegression
NewPackages []string // Packages present in current but not previous
Removed []string // Packages present in previous but not current
TotalDelta float64 // Change in overall coverage
}
// CoverageStore persists coverage snapshots to a JSON file.
type CoverageStore struct {
Path string // File path for JSON storage
}
// NewCoverageStore creates a store backed by the given file path.
func NewCoverageStore(path string) *CoverageStore {
return &CoverageStore{Path: path}
}
// Append adds a snapshot to the store.
func (s *CoverageStore) Append(snap CoverageSnapshot) error {
snapshots, err := s.Load()
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("load snapshots: %w", err)
}
snapshots = append(snapshots, snap)
data, err := json.MarshalIndent(snapshots, "", " ")
if err != nil {
return fmt.Errorf("marshal snapshots: %w", err)
}
if err := os.WriteFile(s.Path, data, 0644); err != nil {
return fmt.Errorf("write %s: %w", s.Path, err)
}
return nil
}
// Load reads all snapshots from the store.
func (s *CoverageStore) Load() ([]CoverageSnapshot, error) {
data, err := os.ReadFile(s.Path)
if err != nil {
return nil, err
}
var snapshots []CoverageSnapshot
if err := json.Unmarshal(data, &snapshots); err != nil {
return nil, fmt.Errorf("parse %s: %w", s.Path, err)
}
return snapshots, nil
}
// Latest returns the most recent snapshot, or nil if the store is empty.
func (s *CoverageStore) Latest() (*CoverageSnapshot, error) {
snapshots, err := s.Load()
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
if len(snapshots) == 0 {
return nil, nil
}
latest := &snapshots[0]
for i := range snapshots {
if snapshots[i].Timestamp.After(latest.Timestamp) {
latest = &snapshots[i]
}
}
return latest, nil
}
// ParseCoverProfile parses output from `go test -coverprofile=cover.out` format.
// Each line is: mode: set/count/atomic (first line) or
// package/file.go:startLine.startCol,endLine.endCol stmts count
func ParseCoverProfile(data string) (CoverageSnapshot, error) {
snap := CoverageSnapshot{
Timestamp: time.Now(),
Packages: make(map[string]float64),
}
type pkgStats struct {
covered int
total int
}
packages := make(map[string]*pkgStats)
scanner := bufio.NewScanner(strings.NewReader(data))
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "mode:") {
continue
}
// Format: pkg/file.go:line.col,line.col numStmt count
parts := strings.Fields(line)
if len(parts) != 3 {
continue
}
// Extract package from file path
filePath := parts[0]
colonIdx := strings.Index(filePath, ":")
if colonIdx < 0 {
continue
}
file := filePath[:colonIdx]
// Package is everything up to the last /
pkg := file
if lastSlash := strings.LastIndex(file, "/"); lastSlash >= 0 {
pkg = file[:lastSlash]
}
stmts, err := strconv.Atoi(parts[1])
if err != nil {
continue
}
count, err := strconv.Atoi(parts[2])
if err != nil {
continue
}
if _, ok := packages[pkg]; !ok {
packages[pkg] = &pkgStats{}
}
packages[pkg].total += stmts
if count > 0 {
packages[pkg].covered += stmts
}
}
totalCovered := 0
totalStmts := 0
for pkg, stats := range packages {
if stats.total > 0 {
snap.Packages[pkg] = math.Round(float64(stats.covered)/float64(stats.total)*1000) / 10
} else {
snap.Packages[pkg] = 0
}
totalCovered += stats.covered
totalStmts += stats.total
}
if totalStmts > 0 {
snap.Total = math.Round(float64(totalCovered)/float64(totalStmts)*1000) / 10
}
return snap, nil
}
// ParseCoverOutput parses the human-readable `go test -cover ./...` output.
// Extracts lines like: ok example.com/pkg 0.5s coverage: 85.0% of statements
func ParseCoverOutput(output string) (CoverageSnapshot, error) {
snap := CoverageSnapshot{
Timestamp: time.Now(),
Packages: make(map[string]float64),
}
re := regexp.MustCompile(`ok\s+(\S+)\s+.*coverage:\s+([\d.]+)%`)
scanner := bufio.NewScanner(strings.NewReader(output))
totalPct := 0.0
count := 0
for scanner.Scan() {
matches := re.FindStringSubmatch(scanner.Text())
if len(matches) == 3 {
pct, _ := strconv.ParseFloat(matches[2], 64)
snap.Packages[matches[1]] = pct
totalPct += pct
count++
}
}
if count > 0 {
snap.Total = math.Round(totalPct/float64(count)*10) / 10
}
return snap, nil
}
// CompareCoverage computes the difference between two snapshots.
func CompareCoverage(previous, current CoverageSnapshot) CoverageComparison {
comp := CoverageComparison{
TotalDelta: math.Round((current.Total-previous.Total)*10) / 10,
}
// Check each current package against previous
for pkg, curPct := range current.Packages {
prevPct, existed := previous.Packages[pkg]
if !existed {
comp.NewPackages = append(comp.NewPackages, pkg)
continue
}
delta := math.Round((curPct-prevPct)*10) / 10
if delta < 0 {
comp.Regressions = append(comp.Regressions, CoverageRegression{
Package: pkg,
Previous: prevPct,
Current: curPct,
Delta: delta,
})
} else if delta > 0 {
comp.Improvements = append(comp.Improvements, CoverageRegression{
Package: pkg,
Previous: prevPct,
Current: curPct,
Delta: delta,
})
}
}
// Check for removed packages
for pkg := range previous.Packages {
if _, exists := current.Packages[pkg]; !exists {
comp.Removed = append(comp.Removed, pkg)
}
}
return comp
}
// LEK-1 | lthn.ai | EUPL-1.2