Coverage Gate
The diff-based coverage gate (issue #267) fails a PR whose changed lines fall short of a per-area patch-coverage target. It is a gate on changes, not an absolute coverage floor — the project-wide number is deliberately not gated (an “at least N%” threshold on the whole codebase is the worst variant of a coverage gate and is rejected).
How it works
Codecov computes patch coverage: the fraction of the PR’s changed lines that the coverage tooling records as covered. The gate is one status per area, each scoped to its own flag so a PR touching more than one area is judged separately per area instead of on one blended number:
coverage:
status:
patch:
backend:
target: 95%
threshold: 5%
flags: [backend]
only_pulls: true
frontend:
target: 90%
threshold: 10%
flags: [frontend]
only_pulls: true
android:
target: 80%
threshold: 15%
flags: [android]
only_pulls: true
This block is not illustrative — it is asserted byte-for-byte (after YAML parsing) against coverage.status.patch in the real codecov.yml by cmd/codecovcheck (issue #979). Changing a target/threshold/flag/only_pulls value here without changing codecov.yml to match, or vice versa, fails CI.
target is the patch-coverage floor; threshold is how far a PR may miss it and still pass (e.g. backend’s 95%/5% passes anything at or above 90%). only_pulls: true scopes the statuses to pull requests — a commit pushed straight to main has no PR diff to act on and is not gated. Each status shows up as its own GitHub check: codecov/patch/backend, codecov/patch/frontend, codecov/patch/android.
Why three different numbers, not one. They aren’t fitted to whatever PRs have historically passed — they come from reading what’s actually testable in each area (2026-09-04 review, prompted by legitimate PRs failing the old flat target: 100%, threshold: 0%):
- Backend stays closest to 100% because almost everything here really is testable and tests demonstrably catch real regressions —
dbtest.New(t)runs against the real migrated schema (see backend/AGENTS.md’s “Backend traps” for bugs a real-DB test caught), and INT-02 builtfaults.Hookseams specifically so failure branches that used to be untestable now are. The 5% threshold absorbs ordinary noise, not a declared “some backend code is fine untested.” - Frontend is looser because a large share of files are presentational MUI/JSX (component files substantially outnumber hook/logic files in
frontend/src/) where a test that only assertsrender()doesn’t throw adds coverage without catching anything, and the tests that do protect real behavior (hooks, dialog state machines) sometimes hit genuinely fiddly async/race branches (e.g.useContacts.ts’s stale-response guard) for a disproportionate cost per marginal branch. - Android is loosest because JaCoCo here only instruments debug unit tests — the unflavored
testDebugUnitTestfor libraries and the coverage flavor:app:testObtainiumDebugUnitTestfor the app (seeAndroidConfig.kt’sconfigureJacocodoc comment) — code exercised only by the instrumented E2E suite (issue #238) is structurally invisible to it. The clean-cut cases — hand-written Hilt DI wiring (*Moduleclasses: one-line@Provides/@Bindsdelegations with no branch) and Activity/Application framework-lifecycle callback bodies (their real logic is already factored into separately-covered pure functions) — are excluded at the JaCoCo level inJACOCO_EXCLUDES, the same mechanism already used for Hilt/Room/Moshi-generated code, rather than papered over with a lower percentage. What’s left and not cleanly excludable by file — Keystore-backed code (RoomPassphraseStore) and Firebase-SDK-availability gates — has too much real logic mixed in to blanket-exclude, so it’s absorbed by a target still below backend/frontend instead of being pretended equally testable.
Three coverage reports feed the gate, one per area, all uploaded with the flags in codecov.yml:
| Area | Report | Uploaded from |
|---|---|---|
| Backend (Go) | merged coverage.out (coverprofile, atomic mode, all three test legs) | unit-tests.yml → backend job |
| Frontend (vitest) | coverage/lcov.info (v8) | unit-tests.yml → frontend job |
| Android (lcov, converted from JaCoCo) | jacocoTestReportAggregated.lcov, written from jacocoTestReportAggregated.xml by cmd/jacoco2lcov | android-tests.yml → test job |
What counts as “uncovered”
A changed line is uncovered when the report for that area records it as executed by no test:
- Go coverprofile: a statement block with count
0. - lcov: a
DA:<line>,<count>record with count0. - JaCoCo: a
<line nr=".." mi=".." ci=".."/>with all instructions missed. Android reaches Codecov as lcov converted from that line (DA:<nr>,0), so the rule is unchanged; see Android coverage is uploaded as lcov.
Non-executable lines (comments, blank lines, braces, declarations with no statement coverage) are not recorded by the tooling and therefore never count against the gate. A source file that never appears in its area’s report (an untested new Kotlin class, for example) counts its changed lines as uncovered.
When it does not fire
- Project coverage — the
codecov/projectstatus stays informational (always green) by design. - Pushes to
main—only_pulls: true. - PRs with no changed covered lines — a test-only or workflow-only change still triggers a suite, so Codecov receives a report and posts all three statuses (with the unaffected areas carried forward); there is simply nothing to gate on that PR. A coverage-measured line is not required for the statuses to appear.
- PRs that touch an area whose tests are path-gated off — the flag carries forward (
carryforward: trueincodecov.yml) and unaffected areas are not re-measured; that area’s status is simply not re-evaluated (a mixed PR can still fail on the one area it actually regressed).
The no-upload case (issue #1188)
The one case the rules above do not cover is a PR that triggers none of the coverage-uploading suites, so no report reaches Codecov at all: a documentation-only change (docs/** maps to nothing in .github/filters.yaml, so every Detect Changes output is false), an infra-only one (the root Dockerfile, docker/**), or codecov.yml itself. Codecov then creates no codecov/patch/* check run, and the three required contexts sit at “Expected — waiting for status to be reported” indefinitely — the PR can only merge through a bypass. (Verified on #1187, docs-only: zero codecov check runs.) This is unrelated to whether the change has covered lines: a test-only PR runs a suite, uploads, and gets all three statuses.
Two mechanisms close it, deliberately overlapping:
codecov.ymlsetscoverage.status.default_rules.flag_coverage_not_uploaded_behavior: pass— a status whose flag had no newly-uploaded coverage reports success instead of being withheld. This is the Codecov-side fix and covers every no-upload PR, including Dependabot’s.-
The
codecov-patch-stubjob inunit-tests.yml— when none ofbackend/frontend/android/openapi/workflowschanged, it posts a success commit status for each area directly, the same “always run, always report” shapereproducibility.ymluses for the byte-reproducibility check (issues #264/#448). It is the deterministic backstop, and the only one that works for the maintainer’s own PRs independent of Codecov behavior; it is skipped for fork/Dependabot PRs, whoseGITHUB_TOKENis read-only.cmd/codecovcheck(issue #1188) asserts the job’sPATCH_AREASequalscodecov.yml’scoverage.status.patchkeys, so adding a fourth area without arming the stub fails CI rather than stranding its context.
Override path
Prefer the override path over leaning on an area’s threshold buffer — the buffer is for ordinary noise, not a substitute for marking a specific line as deliberately untested. In order of preference:
- Write the test. The gate is meant to be satisfiable by real tests, and most changed lines in every area are.
- Line-level ignore, using the mechanism native to each area’s coverage tooling (these exclude the line from the report the gate reads, so they work deterministically):
- Go:
// # pragma: no coverat the end of the line. The Go coverprofile has no native exclusion syntax, so this relies on Codecov honoring the# pragma: no covermarker in the source comment. - Frontend (vitest/v8):
/* v8 ignore next */on the line above (v8’s native coverage exclusion, honored by@vitest/coverage-v8). - Android (JaCoCo): annotate the declaration with
@Generated(JaCoCo’s standard exclusion). - Fallback for all three: Codecov’s
# pragma: no covermarker. Use these only for code that structurally cannot be hit (a defensive branch that only executes on a broken invariant), and keep the reason discoverable — the marker is invisible in the diff otherwise.
cmd/pragmacheckmakes “keep the reason discoverable” mechanical: it scans every non-test Go file underbackend/for# pragma: no coverand every file underfrontend/srcfor/* v8 ignore ... */, and fails if a marker has no reason — either non-trivial text after the marker on its own line, or on the line directly above it (the common pattern of stating the reason once on a guardingifand marking every line inside the block). It runs inunit-tests.yml’sdocs-citationsjob and in.githooks/pre-commit, both unconditionally, for the same reasoncitecheck/docscheckare: a marker with no accompanying test change never trips a path filter. - Go:
-
File-level ignore. Add the path to the
ignore:list incodecov.ymlwith a justifying comment. Coarse — only for an entire file that is genuinely outside the coverage model.android/build-logic/.../AndroidConfig.kt’sJACOCO_EXCLUDESis the Android-specific version of this same idea, applied at the JaCoCo report level rather than incodecov.yml— used for whole categories of hand-written framework glue (Hilt DI modules, Activity/Application lifecycle callback bodies) that JaCoCo’s debug-unit-test-only instrumentation (libraries’testDebugUnitTestplus the app’s coverage flavor:app:testObtainiumDebugUnitTest) structurally cannot see, alongside the codegen it already excluded.cmd/codecovcheck(issue #979) enforces the “with a justifying comment” part mechanically: every entry incodecov.yml’signore:list must have its own non-empty#comment — immediately above it, or trailing on the same line. It must be that entry’s own comment, not one shared with a neighboring entry: a bare, uncommentedignore:entry appended right after an already-justified one — the shape a silent scope-narrowing edit would take — still fails CI.
When measurement itself fails
The backend coverage number is a merge of seven per-leg profiles (unit-tests.yml’s backend-tests matrix). A leg’s dedicated no-rerun coverage pass is continue-on-error — a crash or timeout there must not fail the test gate — so its profile can go missing without the leg itself going red. On a push to main (where only_pulls: true means nothing is gated anyway) a missing leg is a ::warning::: the Codecov upload is skipped and carryforward holds the last known number. On a pull request, the same gap is a hard failure of the Backend (Go) job instead (issue #979) — silently leaving codecov/patch/backend to grade the PR against stale carryforward data, with only a warning annotation as evidence, is the exact failure mode that motivated this doc’s own cmd/codecovcheck gate above.
Android coverage is uploaded as lcov
Since 2026-10-16, Codecov’s processing has ended every JaCoCo upload for this repository in upload state error, with no detail and no incident on its status page (reported upstream as codecov/feedback#988). A commit with no processed upload gets no codecov/patch/* check runs, so every Android-only PR sat waiting for codecov/patch/android and needed a bypass (#1742, #1744). The probe in PR #1748 isolated it to the format: a 13 KB single-package JaCoCo report errors under any flag, network filter, file-fix or upload-endpoint setting, while the same coverage converted to lcov merges.
So the gating android flag is uploaded as lcov, converted in CI from the aggregated JaCoCo XML by go run ./cmd/jacoco2lcov (rules in backend/internal/jacocolcov):
DA:<nr>,1|0per JaCoCo line (covered if any instruction ran), and the line’s branch counters asBRDArecords, so branch-partial lines stay partial in Codecov.- Each
<package>/<sourcefile>is resolved to the one file underandroid/whose path ends with it. Generated or inlined code with no repository file (Hilt factories, Kotlin stdlib inlined into our packages) and source files with no lines are dropped; a key matching more than one file is dropped with a::warning::rather than guessed. The command fails if nothing resolves, rather than uploading an empty report. - Instruction-only partials (some instructions of a branchless line missed) have no lcov form and count as covered. Codecov’s own JaCoCo processing already scored them as hits: its last good
androidtotals onmain(28a259f2: 2,678 partials) line up with the report’s branch-partial lines, not with branch plus instruction partials. The converter prints the count anyway.
Every run appends a JaCoCo-vs-lcov reconciliation (files, lines, covered lines, branches, what was dropped) to the job’s step summary. On the 2026-10-16 report it was 30,939 covered lines and 11,665/17,916 covered branches in both, with 13 uncovered lines dropped (generated code), 82.29% → 82.32%.
The JaCoCo XML stays the source of truth: the per-file ratchet below reads it unchanged, and it is still uploaded to Codecov under the non-gating android-jacoco flag (no patch area, no carryforward). Once Codecov processes JaCoCo again, compare the android and android-jacoco flag totals on the same commit. If they agree, delete the android-jacoco flag and its upload step; lcov stays the gating format either way unless the comparison shows a real difference.
The exec’d server binary (package main) is not measured
main.go is baselined at 0% (issue #1623 §2): Go coverage only instruments the test process, and the two tests that exercise the server run it as a separate, exec’d binary rather than calling it in-process — both in the per-PR rest leg:
backend/main_test.go’shostBinarybuildspackage mainand runs it with--embedded-host, driving the real argv-0 embedded-host path the Android client depends on (issue #1339).backend/config/startup_smoke_test.go’sbackendBinarybuilds the same binary and runs it under a broken environment, proving a misconfiguration fails at startup naming the offending variable (issue #450).
Two things sometimes named for this are not in the class: the upgrade suite (backend/internal/schemafixture/upgrade_test.go) drives the migrator in-process via database.InitDB, so its lines are already measured; and the real deploy-smoke run boots the binary inside a Docker container (deploy-smoke.yml) and uploads no backend coverage at all.
Go ≥1.20 can close the gap — go build -cover -covermode=atomic instruments the binary, GOCOVERDIR makes it emit covdata, and go tool covdata textfmt renders a coverprofile. A -covermode=atomic go test -coverprofile pass even folds an exec’d child’s blocks in automatically when the child is built -covermode=atomic and inherits GOCOVERDIR (hand-verified; no explicit covdata step is needed). The blocker is the test edit, not the merge: both tests build with a bare go build and run the child in a deliberately scrubbed environment (cmd.Env = []string{"HOME=" + dir}, or a hand-built PATH/HOME), so -cover and GOCOVERDIR would have to be threaded through the test itself. Until that edit lands, a PR’s main.go changed lines are carried only by the backend threshold buffer (target: 95%, threshold: 5% — 90% passes), not by measurement.
An operator can measure it by hand without editing the tests:
cd backend
go build -cover -covermode=atomic -o /tmp/mycorrhizal . # instruments package main only
mkdir -p /tmp/covdata
GOCOVERDIR=/tmp/covdata /tmp/mycorrhizal # any real run; a startup-config failure counts too
go tool covdata textfmt -i=/tmp/covdata -o=main-cov.out # mode: atomic profile for main.go
The final covdata textfmt output is the profile an eventual fix would merge into the CI upload; the exact form was verified against a toy package main.
Per-file no-regression ratchet
The patch-coverage gate above only judges a PR’s changed lines. That leaves two gaps it cannot close by design:
- A file that already sits at 0% (or 40%, or whatever) coverage stays there forever — no status ever re-measures a file a PR doesn’t touch.
- A PR that deletes or guts a test for a file it doesn’t otherwise edit trips nothing: the file’s source lines are unchanged, so there’s nothing for Codecov’s patch diff to flag, even though its tests just got weaker.
Both are closed by a ratchet, not an absolute floor — deliberately the same non-absolute philosophy as the patch gate and as frontend/bundle-budget.json (issue #556): it fails on regression past a tolerance, never on an existing low number by itself.
- Backend:
backend/cmd/coverageratchet(logic inbackend/internal/coverageratchet) reads a cross-package profile (coverage-cross.out, see below), computes each file’s statement-coverage percentage, and compares it against the committedbackend/internal/coverageratchet/testdata/baseline.json. It honors the// # pragma: no covermarker (this page’s Override path) at the coverprofile’s own block granularity — a marked line anywhere inside a block excludes that whole block — so a deliberately-excluded line doesn’t masquerade as a real drop. Blocks that appear more than once in the profile are merged (counts summed) while reading, so a file is covered if any occurrence ran. - Frontend:
frontend/scripts/check-coverage-ratchet.mjsreadscoverage/coverage-summary.json(thejson-summaryreporter added tovitest.config.tsalongside the existingtext/html/lcovreporters) and compares each file’s line% and branch% against the committedfrontend/coverage-baseline.json. - Android:
backend/cmd/androidcoverageratchet(logic inbackend/internal/androidcoverage) reads the aggregated JaCoCo XML report (jacocoTestReportAggregated.xmlunderandroid/build/reports/jacoco/jacocoTestReportAggregated/, produced by./gradlew jacocoTestReportAggregated, issue #342) and compares each source file’s line coverage against the committedandroid/coverage-baseline.json. A file’s percentage issum(ci) / (sum(ci) + sum(mi))over its JaCoCo<line>elements, keyed by the report’s own<package>/<sourcefile>path (e.g.com/mycorrhizal/crm/ui/components/ChipListEditor.kt) — the stable, module-independent key the cross-module aggregation already credits the coverage to. It runs inandroid-tests.yml’stestjob onpull_requestand push tomain, neverschedule(the requiredAndroid (Gradle)context; issue #1660), like the backend ratchet. A redAndroid Testspush run is alerted bymain-failure-alert.yml(issue #1568).
All three share the same rules:
- A file whose gated metric(s) drop by more than the baseline’s tolerance fails. The effective tolerance per file is the larger of the configured percentage points and one unit of that file (one line or branch on the frontend, one statement on the backend, one JaCoCo line unit on Android): losing a single unit is always allowed, losing two is not. An improved file never fails, regardless of magnitude.
- A new file with no baseline entry is not gated here — that’s
codecov/patch/*’s job; gating it twice would just let the two disagree on some edge case. - A removed or renamed file drops out silently (reported, not failed) — the baseline is simply stale for that entry until the next regeneration.
One profile, two readers (issue #1477). The backend ratchet and the codecov/patch/backend status are both judged on the cross-package profile (coverage-cross.out), so both answer “is any test exercising this line?” rather than “does this package’s own suite cover it?”:
- The ratchet compares each file’s statement coverage on that profile against the committed baseline.
- The patch gate reads the same profile: the
unit-tests.ymlCodecov upload selectscoverage-cross.outwhenever it exists (i.e. onpull_requestand push tomain) and falls back to the per-package merge on schedule, where the expensive cross run does not happen. A line proven only by an integration test in another package (the routes DB-fault sweep, a routes service test) is now credited.
Before the cross-package profile existed, both used the per-leg profiles (no -coverpkg), which credit a file only for its own package’s tests: 25 files sat baselined at 0–50% while integration tests elsewhere covered them fully (services/api_token_service.go: 0% → 100% via routes/session_lifecycle_test.go), and the same-package semantics of the patch gate nudged authors toward a duplicate same-package test instead of the integration test that actually proves the behavior. A file baselined at 0 could never regress, and the baseline pointed reviewers at the wrong gaps; files that stay low in the cross-package baseline are the real backlog. The per-package merge still drives the informational codecov/project trend and the Merge coverage profiles step’s hard failure when a PR leg produced no profile (issue #979).
The cross-package run is heavy (whole suite, a few minutes, hundreds of MB raw before merge), which is why it runs once, on pull_request and push to main, in the backend gate job rather than per leg. A failing test or unbuildable package in that run fails the job (deferred to the final “Fail on cross-package test failures” step so the ratchet still reports on the partial profile first), and the run prints the merged total statement coverage to the step log and the job summary — the citable cross-package number.
Regenerating the baseline (a deliberate, reviewed act — the diff is the review, same convention as bundle-budget.json):
cd backend && make gen-coverage-baseline # runs the whole suite once with -coverpkg=./... (heavy), then rewrites the baseline
cd frontend && yarn coverage:ratchet:update # needs frontend/coverage/coverage-summary.json from `yarn test:coverage`
cd android && ./gradlew jacocoTestReportAggregated # produces the aggregated report the Android ratchet reads
cd backend && go run ./cmd/androidcoverageratchet -update # rewrite android/coverage-baseline.json from that report
All three regenerate in place, keeping the existing tolerancePercentPoints / tolerancePct unless you edit it by hand.
Tolerance rationale. The backend suite includes property/generative tests (TEST-07, issue #435) whose iteration budget (RAPID_CHECKS) is tiered by trigger — 200 on a PR, 1000 on a push, 8000 on the nightly schedule — and whose generators use randomized inputs. A run-to-run comparison of the property leg’s coverprofile (two back-to-back local runs at the PR-tier 200 iterations) found the statement invocation counts varying by up to ~5% run to run (expected: different random inputs exercise a block a different number of times), but zero blocks flipped between covered and uncovered across the two runs — the set of count == 0 blocks was byte-identical. Coverage percentage is boolean per block (covered/not), not proportional to invocation count, so this variance does not by itself threaten the ratchet; the committed default (1.5 percentage points, both sides) is a safety margin above that empirical zero, not a number chosen to paper over observed flakiness. Because the nightly’s tiered iteration count means its coverage numbers are not directly comparable to the PR-tier baseline, the backend ratchet runs on pull_request and push to main, never schedule (unit-tests.yml’s “Per-file coverage ratchet (pull_request and push to main)” step). The cross-package step pins RAPID_CHECKS/ADDRESS_PROPERTY_ITERATIONS to the PR tier itself, so a push run measures the same package set at the depth the baseline was generated at. The push run exists so a PR merged with the ratchet red cannot leave main drifting silently; a red Unit Tests push run is alerted by main-failure-alert.yml (issue #1568). The frontend ratchet has no property/generative variance and runs on every trigger the frontend job runs on (PRs, push to main, schedule; issue #1660 confirmed it was never PR-only) — but it does have incidental coverage: a line one component’s own tests never reach, hit only because another test file happens to render it. That flipped on the first CI run of this ratchet (OccasionEventAttendeesDialog.tsx: 43/45 lines in a local full run, 42/45 in CI — 2.2pt on a 45-line file, over a flat 1.5pt). The one-unit floor is the fix for that class; the file’s own error paths were also pinned directly so its number no longer depends on other tests.
If a real, intentional coverage change makes the ratchet fail (a test legitimately removed because the code it tested was deleted, a refactor that moves logic into a file whose baseline entry no longer applies), regenerate the baseline and commit the diff — same override philosophy as the patch gate: write the test first if you can, only lower the baseline when the drop is deliberate.
Making the gate block merges
The status checks are codecov/patch/backend, codecov/patch/frontend, and codecov/patch/android (see “How it works” above — one per area, not a single codecov/patch). For a red status to actually prevent a merge it must be in the repository’s required checks in GitHub branch protection, alongside the other required checks — add each area’s status separately, or only the ones you want to block on. Configuring branch protection is a GitHub-side setting, not something this repo’s CI does.
Reference
- Issue #267 — the gate; #251 — the coverage visibility it builds on; #294 — Codecov integration.
- Codecov: patch status
- Codecov: “ensure all code is covered”