build(ci): add coverage ratchet gate with per-package floors

Add automated coverage floor validation:
- scripts/check_coverage.go: parses coverage.out, compares to per-package
  thresholds, supports --update to lock current floors
- scripts/coverage_thresholds.json: 61 packages, initial floors from Phase 1
- .github/workflows/ci.yaml: new 'Coverage ratchet gate' step in CI
- internal/testutil/: test utilities (context builders, TestDB, doc.go)
This commit is contained in:
viettranx committed 2026-04-11 21:22:23 +07:00
1 parent eb2419beed
commit 27c9415193
7 files changed
+463 -1

No files matched your search

+258
View File
@@ -0,0 +1,258 @@
//go:build ignore
// Coverage ratchet gate: fails CI if any package drops below its stored threshold.
//
// Usage:
//
// go run scripts/check_coverage.go [-coverprofile=coverage.out] [-thresholds=scripts/coverage_thresholds.json] [-update]
//
// Flags:
//
// -coverprofile: Path to coverage.out (default: coverage.out)
// -thresholds: Path to thresholds JSON (default: scripts/coverage_thresholds.json)
// -update: Write current coverage as new thresholds (ratchet up, explicit opt-in)
//
// Exit codes:
//
// 0 = all packages meet threshold (or --update succeeded)
// 1 = at least one package below threshold
// 2 = parse/IO error
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"os"
"sort"
"strconv"
"strings"
)
const modulePrefix = "github.com/nextlevelbuilder/goclaw/"
// Trivial/infra-only packages excluded from coverage gate.
var excluded = map[string]bool{
"internal/version": true,
"internal/webui": true,
"internal/updater": true,
"pkg/protocol": true,
"tests/zalo_e2e": true,
"ui/desktop": true,
"cmd": true,
"scripts": true,
}
type pkgCoverage struct {
pkg string
statements int
covered int
}
// parseCoverProfile reads a Go coverage profile and groups statements by package.
// Coverage lines look like: "github.com/foo/bar/file.go:12.1,15.2 3 1"
// Fields: file:start,end numStatements count
func parseCoverProfile(path string) (map[string]*pkgCoverage, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
packages := make(map[string]*pkgCoverage)
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
first := true
for scanner.Scan() {
line := scanner.Text()
if first {
first = false
if strings.HasPrefix(line, "mode:") {
continue
}
}
if line == "" {
continue
}
// Split into 3 parts: "file:range numStmt count"
// Last two fields are numeric; everything before is the filename.
parts := strings.Fields(line)
if len(parts) < 3 {
continue
}
fileRange := strings.Join(parts[:len(parts)-2], " ")
numStmt, err := strconv.Atoi(parts[len(parts)-2])
if err != nil {
continue
}
count, err := strconv.Atoi(parts[len(parts)-1])
if err != nil {
continue
}
// Extract file path (before ':')
fullFile, _, ok := strings.Cut(fileRange, ":")
if !ok {
continue
}
// Strip module prefix to get relative path.
rel := strings.TrimPrefix(fullFile, modulePrefix)
// Package is the directory.
slash := strings.LastIndex(rel, "/")
if slash < 0 {
continue
}
pkg := rel[:slash]
if pkg == "" {
continue
}
entry, ok := packages[pkg]
if !ok {
entry = &pkgCoverage{pkg: pkg}
packages[pkg] = entry
}
entry.statements += numStmt
if count > 0 {
entry.covered += numStmt
}
}
return packages, scanner.Err()
}
func isExcluded(pkg string) bool {
if excluded[pkg] {
return true
}
for p := range excluded {
if strings.HasPrefix(pkg, p+"/") {
return true
}
}
return false
}
func loadThresholds(path string) (map[string]float64, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return map[string]float64{}, nil
}
return nil, err
}
var out map[string]float64
if err := json.Unmarshal(data, &out); err != nil {
return nil, err
}
return out, nil
}
func writeThresholds(path string, thresholds map[string]float64) error {
// Sorted output for deterministic diffs.
keys := make([]string, 0, len(thresholds))
for k := range thresholds {
keys = append(keys, k)
}
sort.Strings(keys)
ordered := make(map[string]float64, len(keys))
for _, k := range keys {
ordered[k] = thresholds[k]
}
data, err := json.MarshalIndent(ordered, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
return os.WriteFile(path, data, 0o644)
}
func pctStr(p float64) string {
return fmt.Sprintf("%5.1f%%", p)
}
func main() {
var (
profilePath = flag.String("coverprofile", "coverage.out", "coverage profile path")
thresholdsPath = flag.String("thresholds", "scripts/coverage_thresholds.json", "thresholds JSON path")
update = flag.Bool("update", false, "write current coverage as new thresholds")
)
flag.Parse()
packages, err := parseCoverProfile(*profilePath)
if err != nil {
fmt.Fprintf(os.Stderr, "error parsing %s: %v\n", *profilePath, err)
os.Exit(2)
}
thresholds, err := loadThresholds(*thresholdsPath)
if err != nil {
fmt.Fprintf(os.Stderr, "error loading thresholds: %v\n", err)
os.Exit(2)
}
type row struct {
pkg string
current float64
threshold float64
delta float64
status string
}
var rows []row
failed := 0
newThresholds := make(map[string]float64)
for _, entry := range packages {
if isExcluded(entry.pkg) {
continue
}
if entry.statements == 0 {
continue
}
current := 100 * float64(entry.covered) / float64(entry.statements)
threshold := thresholds[entry.pkg]
delta := current - threshold
status := "PASS"
if current+0.01 < threshold { // tiny epsilon for float comparison
status = "FAIL"
failed++
}
rows = append(rows, row{entry.pkg, current, threshold, delta, status})
// Preserve existing thresholds for packages that still exist.
newThresholds[entry.pkg] = current
}
// Also preserve thresholds for packages still in file but not in coverage
// (e.g., tests excluded in this run); keep them to avoid false passes.
if !*update {
for k, v := range thresholds {
if _, ok := newThresholds[k]; !ok {
newThresholds[k] = v
}
}
}
sort.Slice(rows, func(i, j int) bool { return rows[i].pkg < rows[j].pkg })
fmt.Println("Coverage Gate Report")
fmt.Println("====================")
fmt.Printf("%-60s %8s %8s %8s %s\n", "PACKAGE", "CURRENT", "FLOOR", "DELTA", "STATUS")
for _, r := range rows {
fmt.Printf("%-60s %8s %8s %+7.1f%% %s\n",
r.pkg, pctStr(r.current), pctStr(r.threshold), r.delta, r.status)
}
fmt.Println()
if *update {
if err := writeThresholds(*thresholdsPath, newThresholds); err != nil {
fmt.Fprintf(os.Stderr, "error writing thresholds: %v\n", err)
os.Exit(2)
}
fmt.Printf("Updated %s with %d package thresholds\n", *thresholdsPath, len(newThresholds))
os.Exit(0)
}
if failed > 0 {
fmt.Printf("FAIL: %d package(s) below threshold\n", failed)
os.Exit(1)
}
fmt.Printf("PASS: all %d package(s) meet threshold\n", len(rows))
}
+63
View File
@@ -0,0 +1,63 @@
{
"internal/agent": 36.809815950920246,
"internal/backup": 18.803418803418804,
"internal/bootstrap": 29.152542372881356,
"internal/bus": 30.578512396694215,
"internal/cache": 96.875,
"internal/channels": 26.524390243902438,
"internal/channels/discord": 27.680311890838208,
"internal/channels/facebook": 23.076923076923077,
"internal/channels/feishu": 0,
"internal/channels/media": 9.174311926605505,
"internal/channels/pancake": 55.319148936170215,
"internal/channels/slack": 19.313850063532403,
"internal/channels/telegram": 13.217072051399725,
"internal/channels/telegram/voiceguard": 100,
"internal/channels/typing": 91.80327868852459,
"internal/channels/whatsapp": 21.323529411764707,
"internal/channels/zalo": 7.203389830508475,
"internal/channels/zalo/personal": 0,
"internal/channels/zalo/personal/protocol": 19.56989247311828,
"internal/channels/zalo/personal/zalomethods": 0,
"internal/config": 48.17275747508306,
"internal/consolidation": 73.77049180327869,
"internal/cron": 73.71428571428571,
"internal/crypto": 75.40983606557377,
"internal/edition": 100,
"internal/eventbus": 79.59183673469387,
"internal/gateway": 15.11056511056511,
"internal/gateway/methods": 7.384515289525049,
"internal/heartbeat": 12.244897959183673,
"internal/http": 12.458820005989818,
"internal/i18n": 100,
"internal/knowledgegraph": 91.76470588235294,
"internal/mcp": 26.271970397779832,
"internal/media": 0,
"internal/memory": 10.169491525423728,
"internal/oauth": 56.17391304347826,
"internal/orchestration": 100,
"internal/permissions": 98.18181818181819,
"internal/pipeline": 76.90417690417691,
"internal/providerresolve": 88.88888888888889,
"internal/providers": 56.99965433805738,
"internal/providers/acp": 0,
"internal/safego": 100,
"internal/sandbox": 6.593406593406593,
"internal/scheduler": 69.1029900332226,
"internal/sessions": 94.37751004016064,
"internal/skills": 37.5,
"internal/store": 25.880281690140844,
"internal/store/base": 95.95959595959596,
"internal/store/pg": 1.2770943175161102,
"internal/tasks": 55.4140127388535,
"internal/testutil": 0,
"internal/tokencount": 77.17391304347827,
"internal/tools": 26.612716763005782,
"internal/tracing": 5,
"internal/tracing/otelexport": 11.235955056179776,
"internal/tts": 0,
"internal/upgrade": 0,
"internal/vault": 27.379400260756192,
"internal/workspace": 87.34177215189874,
"pkg/browser": 6.208425720620842
}