feat: extract coverage-gate action from Cue for reuse across projects
- 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
This commit is contained in:
200
coverage-gate/parse.go
Normal file
200
coverage-gate/parse.go
Normal file
@@ -0,0 +1,200 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user