Coverage Gates
Per-package statement-coverage gates enforced by tools/covgate and
called from CI via make coverage-gate. Targets match
PROJECT-DETAILS.md §5.3 and AGENTS.md §5:
| Category | Threshold | Packages |
|---|---|---|
| Engine | ≥ 85% | The state engine: internal/statemgmt (the v0.5 gate bar — see VERSIONING.md § v0.5 gate § Engine + acceptance) |
| Module | ≥ 80% | State stdlib modules: internal/statemgmt/stdlib and every child (the v0.5 gate bar) |
| Critical | ≥ 70% | Business-logic packages: most of internal/* and pkg/* (excluding the engine/module packages above) |
| CLI | ≥ 40% | cmd/* binaries and internal/cli/* packages |
| Excluded | — | Generated code (pkg/api/v1), tooling (tools/), test scaffolding (test/), example modules (modules/examples/) |
The Engine and Module bars (85% / 80%) lock in the v0.5 gate’s coverage
acceptance criterion. They resolve before the critical rules in
classifyPackage, so the state engine and stdlib modules are held to the
higher bars rather than the critical floor.
How it works
make test-coverageruns the unit-test suite with-coverprofile=coverage.out.make coverage-gaterunsgo run ./tools/covgate --profile=coverage.out.- covgate parses the profile, aggregates statement-coverage per
package, classifies each via
tools/covgate/config.go, and asserts the per-category threshold. - Exit 0 on all-pass, 1 on any FAIL.
CI runs the gate immediately after make test-coverage in the test
job — there’s no separate workflow.
Verdicts
| Verdict | Meaning |
|---|---|
PASS | Coverage meets or exceeds the category threshold. |
FAIL | Coverage is below the category threshold and the package is not on the allowList (or the allowList entry is stale — see below). |
ALLOWED | Coverage is below threshold but the package has an explicit allowList entry. Counts as PASS for exit-code purposes. |
SKIP | Package matched excludedPrefixes. No assertion. |
WARN | Package didn’t match any classification rule. With --strict-unmatched this is promoted to FAIL. |
The allowList
Some packages are below threshold today and tracked for graduation.
Each lives in tools/covgate/config.go’s allowList map with the
package’s current measured coverage. The gate then enforces two
invariants:
- Coverage must not regress below the allowList value — i.e. if
pkg/api/clusteris allow-listed at 68.7%, dropping to 65% fails the gate. - Coverage rising above the category threshold removes the exception — once a package crosses 70% (critical) or 40% (CLI), the gate fails with a “remove the entry” message so operators graduate it into the regular gate.
Every allowList entry MUST carry a comment with one of:
- The PR / commit that will raise coverage.
- A
gate-v1.0orv1.xROADMAP entry that schedules the work. - An explicit “ships with epic NN task M” commitment.
Untriaged exceptions are not allowed in the file.
Adding a new critical package
- Open
tools/covgate/config.go. - Add the package’s relative path to
criticalPackages(or tocriticalPrefixesif it’s a cohesive subtree likeinternal/ratelimit). A new state stdlib module underinternal/statemgmt/stdlibis picked up automatically bymodulePrefixesand held to the 80% module bar — no edit needed. - Run
make test-coverage && make coverage-gatelocally to confirm the new package passes its bar.
Graduating an allowList entry
- Add tests (or wait for a domain epic to add them).
- Run
make test-coverage && make coverage-gatelocally. The gate will FAIL with “coverage X% now exceeds allowList entry Y% — remove the entry.” - Remove the entry from
allowList. Re-run the gate; it should PASS. - PR the removal alongside the test additions.
Why per-package, not project-wide?
A single project-wide threshold makes regressions in one package
invisible behind another package’s high coverage. Per-package keeps
each module honest. The category split (critical vs cli) reflects
where bugs are expensive — control-plane / agent / state code needs
the higher bar; CLI wrappers around tested cores need less.
Why statement coverage, not branch?
Go’s go test -coverprofile produces statement coverage only.
Branch coverage requires external tooling (e.g. gocov) and is a v1.x
follow-up tracked under docs/project/ROADMAP.md “Benchmark suite
for regression detection.”
Why mode: set not mode: count?
-race enables atomic mode by default for race-safety. The gate
reads count and atomic profiles identically — any hit > 0 counts
the block as covered. The mode doesn’t affect the gate verdict.
Deferred to v1.x
- Diff coverage (per-PR delta) — covgate looks at absolute numbers only.
- Branch coverage — needs gocov or similar.
- Codecov / Coveralls upload — local-CI gate is sufficient for v1.0; an external dashboard is post-v1.0.
- Function-level gates — package granularity is the agreed shape (epics + AGENTS state package-level).