Skip to content

ci: coverage + CRAP gate (unblocks protected merges) - #141

Open
bkataru wants to merge 1 commit into
mainfrom
ci/coverage-crap
Open

bkataru wants to merge 1 commit into
mainfrom
ci/coverage-crap

Conversation

@bkataru

@bkataru bkataru commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

What this adds

One new workflow, .github/workflows/coverage.yml, containing one job whose
check-run context is exactly Coverage + CRAP
— the single status check pawan's
main-branch protection has always required and that has never existed.

ci.yml is byte-identical afterwards (no existing job is modified, reordered, or
re-run). No workspace dependency is added.

Two gates, one instrumented run:

  • cargo-tarpaulin (pinned 0.37.5, --engine Llvm) runs the whole suite
    under instrumentation and writes lcov.info, gated with --fail-under 58.
  • cargo-crap (pinned 0.6.1) scores complexity from the LCOV that same run
    writes: cargo crap --lcov lcov.info --workspace --exclude '**/main.rs' --threshold 510 --fail-above. cargo-crap never runs tests itself — it parses
    source with syn and computes CRAP(m) = CC² · (1 − cov)³ + CC. With no
    coverage feed every function scores cov = 0 and the ceiling measures nothing,
    which is why both gates share one run.

Why this is the check protection has always required

gh api repos/dirmacs/pawan/branches/main/protection
  required_status_checks.contexts == ["Coverage + CRAP"]      # exactly one check

Coverage + CRAP is in the required-contexts list, but no workflow or job on
main has ever produced a check with that name — so the check has been
permanently unsatisfied and every merge into main has been blocked by it.
This workflow supplies it. The check-run name was confirmed empirically after
this workflow's first run by reading
gh api repos/dirmacs/pawan/commits/<sha>/check-runs and comparing the name
field character-for-character (the context is the job name:, not the workflow
name and not the job id).

First run, observed

On this PR's head (95b30c3), the check-runs API reports exactly one check named
Coverage + CRAP, byte-for-byte (15 bytes: C o v e r a g e ␣ + ␣ C R A P),
conclusion success, from run
https://github.com/dirmacs/pawan/actions/runs/38147255596/job/114493108545.

59.75% coverage, 9110/15248 lines covered      # floor 58

All ten steps success; the suite ran green under instrumentation; the CRAP step
completed under --threshold 510 --fail-above. That 59.75% on the runner is the
same 0.40pp-below-the-build-host delta measured on the origin/main base, which
is exactly what the floor's margin is sized for.

Measured coverage on both bases

Measured, not estimated. Each number comes from running the workflow's exact
command — cargo tarpaulin --engine Llvm --workspace --locked --out Lcov --timeout 600 with RUSTFLAGS=-C debuginfo=line-tables-only, stable,
x86_64 — in a clean isolated worktree checked out at that base.

base commit line coverage lines
origin/main e15a5d2 60.13% 9168 / 15248
guardrail/appa-integration (PR #140 head) 90f8a53 60.76% 9530 / 15685

Per crate (origin/main → 90f8a53):

crate lines coverage lines coverage
pawan-core 8672 71.30% 9015 71.55%
pawan-cli 5704 48.91% 5704 48.90%
pawan-api 223 31.84% 315 53.02%
pawan-mcp 136 77.94% 138 76.81%
pawan-aegis 24 75.00% 24 75.00%
the five crates 14759 62.12% 15196 62.71%

Both suites ran green under instrumentation (0 failed tests on either base), so
these are full-suite numbers, not partial ones.

Threshold and margin

COVERAGE_FLOOR = 58.

The lower of the two measured bases is 60.13% (origin/main). The margin is
applied to the number the gate itself will see on that base rather than to the
build host's: a GitHub-hosted ubuntu-latest runner measured 59.73%
(9107/15248) on the origin/main base — 0.40pp below this build host with an
identical line denominator. So:

  • 58 is ~1pp below the runner's 59.73% (the number the gate will actually see), and
  • ~2.1pp below the measured 60.13%,

which absorbs the runner delta plus normal run-to-run noise on a floating
@stable toolchain. Both bases clear it, on the runner and on the build host.

Crate scope

--workspace — all five pawan crates, nothing hand-picked. pawan-core is 58.8%
of the surface and the reason the gate is worth having (71.3%), but pawan-cli is
38.6% of it, and excluding it would leave the largest surface unguarded.
pawan-api (223 lines), pawan-mcp (136) and pawan-aegis (24) are small enough that
including them costs nothing and keeps the denominator whole.

One disclosed scope artifact: tarpaulin's tree walk also lists the separate
grind/ workspace
(its own Cargo.lock, run by mise grind-test, never built
or run by this job) at 489 lines / 0%. That is a constant −3.2pp drag on the
number tarpaulin reports. It is disclosed rather than silently excluded; the run
summary prints both the gated number and the crates-only number so the ratchet has
the cleaner metric available.

Pinned tool versions

tool version rationale
cargo-tarpaulin 0.37.5 (published 2026-09-27) release asset, x86_64-unknown-linux-gnu; actions-rs/tarpaulin is archived (the actions-rs org was archived 2023-10-13, last push 2023-02-04)
cargo-crap 0.6.1 (published 2026-09-29) release asset, actively maintained, x86_64-unknown-linux-gnu asset
Rust toolchain dtolnay/rust-toolchain@stable + llvm-tools-preview same toolchain action as ci.yml; llvm-tools backs the Llvm engine

Both versions are asserted inside the job
(cargo-tarpaulin --version | grep -qx "tarpaulin 0.37.5" and the cargo-crap
equivalent) so two runs cannot disagree because one host had a newer tool. Both
measured baselines above were produced with exactly these versions.

Release-asset install rather than cargo install in CI: cargo install would
recompile tarpaulin from source on every run for a binary whose contents are
already reproducible in the pinned release, and the release is what the
thresholds were measured against.

CRAP approach

A real maintained crate, not a bespoke script: cargo-crap 0.6.1, fed by this
run's lcov.info.

The measured worst on both bases is the same function at the same score:
431.04 = handle_session_browser_key
(crates/pawan-cli/src/tui/events/default.rs:36, CC 22, 1/22 lines covered =
5.45%). The ceiling is 510 rather than 431 because that score is knife-edged:
the same function scores CC³ + CC = 506 if the llvm engine measures its single
covered line as uncovered, so 510 clears both outcomes on both bases.

--exclude '**/main.rs' keeps the two binary entry points out of the ceiling:
argument parsing, process wiring and setup, 0% covered by design (worst measured
CC 42 ⇒ CRAP 1806). Gate those numbers and the ceiling measures nothing; silence
them and it is meaningful.

The ceiling caps the single worst function's score. It is not a cap on how
many functions exceed a lower bar: 60 of 1053 scored functions on origin/main
(1135 on 90f8a53) sit above the tool's default 30, 44 above 50, 20 above 100 —
identically on both bases.

The ceiling's teeth were proven before this PR was opened: the same LCOV scores
rc = 1 at --threshold 400 (worst 431.04) and rc = 0 at --threshold 510.

Ratchet plan

  • Coverage floor 58 — raise it to the new measured number minus the same ~1pp
    runner margin whenever a PR improves coverage. Only ever upward, and only via a
    PR that measured the new number in this job.
  • CRAP ceiling 510 — once handle_session_browser_key gains real coverage,
    re-measure and drop the ceiling toward the new worst + margin. Then walk the 44
    functions above 50 and the 20 above 100.
  • Next metric — add a count ceiling above the tool's default 30 (60 functions
    currently exceed it) once the worst-offender ceiling has been tightened.

ENOSPC / OOM remediation

RUSTFLAGS: -C debuginfo=line-tables-only is baked into the job's env: full debug
info is what fills the disk and the linker's RSS on coverage runs, and line tables
carry every mapping tarpaulin needs. The job deliberately does not carry
-D warnings — lint enforcement is ci.yml's Check & Lint job, and this job's
exit code has to mean "gate", not "lint".

Notes

pawan's main-branch protection requires exactly one status check, context
`Coverage + CRAP`, and no job on main has ever produced it -- so every merge
into main has been blocked. This workflow supplies that check.

One job, `Coverage + CRAP`, one instrumented run feeding two gates:

- cargo-tarpaulin 0.37.5 (--engine Llvm) runs the whole suite and writes
  lcov.info, gated with --fail-under 58.
- cargo-crap 0.6.1 scores CRAP(m) = CC^2 * (1 - cov)^3 + CC over every function
  of the five pawan crates from that same lcov.info, ceiling 510, with the two
  0%-by-design binary entry points (**/main.rs) excluded.

Thresholds are measured, not estimated: the exact command was run in an
isolated worktree on origin/main (60.13%, 9168/15248) and on the PR #140 head
90f8a53 (60.76%, 9530/15685); the floor is ~1pp below the runner's own 59.73%
on the origin/main base, which absorbs the measured 0.40pp runner-vs-build-host
delta. Worst CRAP is 431.04 on both bases (handle_session_browser_key, CC 22,
1/22 lines); the ceiling is 510 because CC^3 + CC = 506 is what that function
scores if its single covered line is measured uncovered.

Both gate directions were proven before the thresholds were committed:
--fail-under 99 exits 1 ("Coverage is below the failure threshold 60.09% <
99.00%") and --threshold 400 exits 1, while 58 and 510 exit 0.

ci.yml is untouched. tools are pinned release assets (actions-rs/tarpaulin is
archived) with in-job version assertions.
@bkataru

bkataru commented Oct 11, 2026 •

Copy link
Copy Markdown
Member Author

Ready for review — this is the gate pawan's protection has always required (it never existed).

What to review: .github/workflows/coverage.yml (+ its three runner-facing notes). One job, check-run context byte-exact Coverage + CRAP (verified character-for-character via the check-runs API on this head — gh api repos/dirmacs/pawan/commits/95b30c3/check-runs).

The numbers (all measured, all with the same command on the same bases):

Gate Committed threshold origin/main this branch failure proven?
Coverage (llvm, workspace, stable 1.98.0, debuginfo=line-tables-only) floor 58 60.13% (9168/15248) 60.76% (9530/15685) yes — --fail-under 99 → exit 1
CRAP ceiling (worst function) 510 worst 431.04 (handle_session_browser_key, CC 22, 5.45% cov) worst 431.04 — identical yes — --threshold 400 → exit 1, 510 → 0
  • The floor's 2.1pp margin over main's 60.13% absorbs the runner delta: this PR's own green run measured 59.75% on the fleet (the build-host cross-check was 59.73%, i.e. ~0.40pp lower with an identical 15248 denominator). Run-to-run variance: 0.04pp.
  • Per-crate: pawan-core 71.3%, pawan-mcp 77.9%, pawan-aegis 75.0%, pawan-cli 48.9%, pawan-api 31.8→53.0%. The grind/ workspace walks in at 0/489 (a separate harness — constant −3.2pp on both bases, accounted for in the floor).
  • Both bases' full suites ran green under instrumentation (0 failed), so these are full-suite numbers, not partial.

Tooling (pinned release assets, in-job version assertions): cargo-tarpaulin 0.37.5 (2026-09-27) and cargo-crap 0.6.1 (2026-09-29) — a real maintained crate, not a bespoke script; actions-rs/tarpaulin was rejected (that org is archived since 2023) and inline cargo install was rejected (recompiles per run for a pinned binary). Both gates share ONE instrumented run — without the coverage feed every function scores cov=0 and the CRAP ceiling measures nothing.

Resource note: -C debuginfo=line-tables-only is baked in — the recorded estate-wide ENOSPC/OOM remedy for coverage jobs on this fleet. Job duration 14m18s. Runtime cost per push is the honest cost of this gate.

Ratchet path (per the owner's directive): raise COVERAGE_FLOOR in whole points as coverage grows (next step 62, which is main's own level), tighten --threshold only with a paired coverage improvement — the ceiling currently caps ONE function so the ratchet is deliberate, not automatic.

@bkataru

bkataru commented Oct 11, 2026

Copy link
Copy Markdown
Member Author

Supersedes #139 (closed) — the earlier gate attempt measured on 2026-10-10's denominator; this PR re-measured both bases on current main and proves both gate directions fail before the thresholds were committed. Details in #139's closing comment.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant