- Move coveragegate tool from cue/tools to vociferate/coverage-gate - Create composite action with JSON metrics output for CI - Update tool to export passes/total_coverage/packages_checked/packages_failed - Support per-package threshold policy via JSON configuration - Change module path to git.hrafn.xyz/aether/vociferate/coverage-gate
201 lines
4.9 KiB
Go
201 lines
4.9 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Policy describes coverage threshold configuration.
|
|
type Policy struct {
|
|
MinimumStatementCoverage float64 `json:"minimum_statement_coverage"`
|
|
CriticalPackages []PackagePolicy `json:"critical_packages"`
|
|
}
|
|
|
|
// PackagePolicy overrides defaults for a specific package.
|
|
type PackagePolicy struct {
|
|
Package string `json:"package"`
|
|
MinimumStatementCoverage float64 `json:"minimum_statement_coverage"`
|
|
Include bool `json:"include"`
|
|
Exclusions []string `json:"exclusions"`
|
|
}
|
|
|
|
// Coverage aggregates covered and total statements for a package.
|
|
type Coverage struct {
|
|
Covered int64
|
|
Total int64
|
|
}
|
|
|
|
// PackageResult is the policy-evaluated coverage result for one package.
|
|
type PackageResult struct {
|
|
Package string
|
|
Covered int64
|
|
Total int64
|
|
Percent float64
|
|
Threshold float64
|
|
Pass bool
|
|
}
|
|
|
|
// LoadPolicy reads policy JSON from disk.
|
|
func LoadPolicy(path string) (Policy, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return Policy{}, fmt.Errorf("open policy: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
var p Policy
|
|
if err := json.NewDecoder(f).Decode(&p); err != nil {
|
|
return Policy{}, fmt.Errorf("decode policy: %w", err)
|
|
}
|
|
if p.MinimumStatementCoverage <= 0 {
|
|
p.MinimumStatementCoverage = 80.0
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// ParseCoverProfile parses a Go coverprofile and aggregates package coverage.
|
|
func ParseCoverProfile(profilePath string, policy Policy) (map[string]Coverage, error) {
|
|
f, err := os.Open(profilePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open coverage profile: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
coverage := make(map[string]Coverage)
|
|
s := bufio.NewScanner(f)
|
|
lineNo := 0
|
|
for s.Scan() {
|
|
lineNo++
|
|
line := strings.TrimSpace(s.Text())
|
|
if lineNo == 1 {
|
|
if !strings.HasPrefix(line, "mode:") {
|
|
return nil, fmt.Errorf("invalid coverage profile header")
|
|
}
|
|
continue
|
|
}
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
parts := strings.Fields(line)
|
|
if len(parts) != 3 {
|
|
return nil, fmt.Errorf("invalid coverage line %d", lineNo)
|
|
}
|
|
fileAndRange := parts[0]
|
|
numStmts, err := strconv.ParseInt(parts[1], 10, 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid statements count at line %d: %w", lineNo, err)
|
|
}
|
|
execCount, err := strconv.ParseInt(parts[2], 10, 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid execution count at line %d: %w", lineNo, err)
|
|
}
|
|
|
|
idx := strings.Index(fileAndRange, ":")
|
|
if idx < 0 {
|
|
return nil, fmt.Errorf("invalid file segment at line %d", lineNo)
|
|
}
|
|
filePath := fileAndRange[:idx]
|
|
pkg := filepath.ToSlash(filepath.Dir(filePath))
|
|
if isExcludedFile(pkg, filePath, policy) {
|
|
continue
|
|
}
|
|
|
|
agg := coverage[pkg]
|
|
agg.Total += numStmts
|
|
if execCount > 0 {
|
|
agg.Covered += numStmts
|
|
}
|
|
coverage[pkg] = agg
|
|
}
|
|
if err := s.Err(); err != nil {
|
|
return nil, fmt.Errorf("scan coverage profile: %w", err)
|
|
}
|
|
|
|
return coverage, nil
|
|
}
|
|
|
|
// EvaluateCoverage evaluates package coverage against policy thresholds.
|
|
func EvaluateCoverage(packages []string, byPackage map[string]Coverage, policy Policy) []PackageResult {
|
|
results := make([]PackageResult, 0, len(packages))
|
|
for _, pkg := range packages {
|
|
if !isPackageIncluded(pkg, policy) {
|
|
continue
|
|
}
|
|
agg := byPackage[pkg]
|
|
percent := 100.0
|
|
if agg.Total > 0 {
|
|
percent = float64(agg.Covered) * 100.0 / float64(agg.Total)
|
|
}
|
|
threshold := thresholdForPackage(pkg, policy)
|
|
pass := agg.Total == 0 || percent >= threshold
|
|
results = append(results, PackageResult{
|
|
Package: pkg,
|
|
Covered: agg.Covered,
|
|
Total: agg.Total,
|
|
Percent: percent,
|
|
Threshold: threshold,
|
|
Pass: pass,
|
|
})
|
|
}
|
|
sort.Slice(results, func(i, j int) bool {
|
|
return results[i].Package < results[j].Package
|
|
})
|
|
return results
|
|
}
|
|
|
|
func thresholdForPackage(pkg string, policy Policy) float64 {
|
|
for _, entry := range policy.CriticalPackages {
|
|
if entry.Package == pkg && entry.MinimumStatementCoverage > 0 {
|
|
return entry.MinimumStatementCoverage
|
|
}
|
|
}
|
|
if policy.MinimumStatementCoverage > 0 {
|
|
return policy.MinimumStatementCoverage
|
|
}
|
|
return 80.0
|
|
}
|
|
|
|
func isPackageIncluded(pkg string, policy Policy) bool {
|
|
for _, entry := range policy.CriticalPackages {
|
|
if entry.Package == pkg {
|
|
return entry.Include
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func isExcludedFile(pkg string, filePath string, policy Policy) bool {
|
|
base := filepath.Base(filePath)
|
|
|
|
// Exclude known generated artifacts and thin composition wiring.
|
|
if strings.HasSuffix(base, "_gen.go") ||
|
|
base == "generated.go" ||
|
|
base == "models_gen.go" ||
|
|
base == "schema.resolvers.go" ||
|
|
base == "main.go" {
|
|
return true
|
|
}
|
|
|
|
for _, entry := range policy.CriticalPackages {
|
|
if entry.Package != pkg {
|
|
continue
|
|
}
|
|
for _, ex := range entry.Exclusions {
|
|
if ex == "" {
|
|
continue
|
|
}
|
|
if strings.HasSuffix(filePath, ex) || strings.Contains(filePath, "/"+ex) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|