Coverage signals — reconciling the three "coverage" numbers
Three different measurements are all colloquially called "coverage" on a Spec Kitty pull request, and they routinely disagree by tens of percentage points. That disagreement is expected and by design — it is not, on its own, a bug or a regression. This guide explains what each number measures, why they differ, and gives you a decision aid for the moment they seem to contradict each other: is this a real regression, or an expected scope difference?
If you only remember one thing: the internal diff-coverage gate and the
SonarCloud numbers measure different files, different lines, and against
different baselines. A passing internal gate next to a low SonarCloud number is
the normal, healthy state — not a contradiction.
The three signals at a glance
| Signal | Where it runs | Which files it scores | Which lines it counts | Threshold |
|---|---|---|---|---|
Internal diff-coverage gate |
CI diff-coverage job, on pull_request only (.github/workflows/ci-quality.yml) |
A deliberate critical-path subset of src/ (see list below) |
Only the lines your PR changed vs the base branch (diff-cover --compare-branch=origin/<base>) |
90%, blocking |
SonarCloud coverage |
Nightly Sonar analysis (cron 17 2 * * *) / manual dispatch — never per PR |
The whole src/ tree (sonar.sources=src) |
Every executable line in the tree, cumulative | No blocking floor on the overall number (the gate uses the new_* metrics) |
SonarCloud new_coverage |
Same nightly analysis | The whole src/ tree |
Lines in the New Code Period (since the projectVersion baseline) |
80%, blocking Sonar's own gate |
There is also a second, advisory diff-cover step in the same CI job that
scores the full PR diff (all changed files, not just critical-path) with no
--fail-under; it prints a number but never blocks a merge.
The internal gate's critical-path allowlist
The enforced internal gate restricts itself (diff-cover ... --include) to these
paths — the kernel, doctrine, charter, status, merge, and mission-runtime
surfaces where a coverage miss is highest-risk:
src/kernel/*
src/doctrine/*
src/charter/*
src/specify_cli/status/*
src/specify_cli/core/mission_detection.py
src/specify_cli/dashboard/handlers/*
src/specify_cli/dashboard/scanner.py
src/specify_cli/merge/*
src/runtime/next/*
src/mission_runtime/*
That is roughly 247 Python files — a strict subset of the ~969 tracked
.py files SonarCloud scores across the whole src/ tree (SonarCloud indexes
1305 files in total under src/, spanning all seven top-level packages:
specify_cli, doctrine, charter, runtime, glossary, kernel,
mission_runtime).
Why the numbers differ (the evidence)
The reconciliation below was produced against SonarCloud's public read API
(no token) using the read-only tool scripts/ci/sonarcloud_branch_review.sh.
You can reproduce every figure yourself:
scripts/ci/sonarcloud_branch_review.sh coverage # overall + new_coverage
scripts/ci/sonarcloud_branch_review.sh quality-gate # gate conditions + thresholds
scripts/ci/sonarcloud_branch_review.sh version # projectVersion / baseline history
Three independent axes make the numbers diverge:
File set (scope of files). SonarCloud scores all of
src/; the enforced internal gate scores only the critical-path subset above. So SonarCloud will surface uncovered lines in packages (for examplesrc/specify_cli/cli/,.../missions/,.../doctor/) that the enforced internal gate never looks at. This is intentional — coverage effort is kept proportional to risk — and the full-diff advisory step exists precisely so the wider diff is still visible without blocking.Line basis (which lines, over what history). This is the dominant driver.
- SonarCloud
coverage(47.3%) is a cumulative average over the entire codebase:lines_to_cover = 95,111,uncovered_lines = 50,090. It counts every executable line ever written, covered or not. - The internal gate (90%) counts only the handful of lines the current PR changed. A ten-line PR is judged on ten lines.
These two can never be close: one is a whole-history denominator of ~95k lines, the other is your diff.
- SonarCloud
Baseline (what "new" means). SonarCloud
new_coverage(50.06%) sounds like it should match the internal per-PR gate — both mention "new" code — but it does not, for a concrete reason:new_lines_to_cover = 80,038. About 84% of the whole tree is currently counted as "new code," because every recent nightly analysis reportsprojectVersion = "not provided"and the New Code Period baseline is therefore frozen. So todaynew_coverage(~50%) is effectively another whole-repo number, not a per-PR one — which is why it sits right next to the overallcoverage, nowhere near 90%. (See Known caveats — a separate change wiresprojectVersionfrompyproject.tomlso this baseline resets per release cycle. Even after that fix,new_coveragebecomes a per-release-cycle number, still not a per-PR one.)
The verdict: file-set and philosophy differ — but nothing is misconfigured
Investigation finding, stated plainly:
- The file sets do differ. SonarCloud scores the entire
src/tree; the enforced internal gate scores only a critical-path subset of the PR diff. - The measurement philosophy also differs, and more decisively: whole-repo cumulative average (and a baseline-anchored "new code" number) versus per-PR changed-lines-only.
- Neither difference is a
sources/exclusionsmisconfiguration.sonar.sources=srcwithsonar.exclusions=**/__pycache__/**,**/*.pyc,**/migrations/**is correct and standard: tests are registered separately assonar.tests(so they are not counted as production lines to cover), and no generated or vendored code is wrongly swept in (the agent directories live at the repo root, outsidesrc/). The internal allowlist is a deliberate, risk-proportional choice, not an accident.
Because the divergence is a philosophy-and-baseline difference rather than a genuine file-set misconfiguration, it is discharged by this document, not by a config change (research-first, per the mission constraint C-002). In particular, this guide deliberately does not recommend narrowing Sonar's scope to flatter the number — doing so would mask genuinely untested code, which the project's no-ratchet standing order forbids.
Decision aid: real regression, or expected scope difference?
When a SonarCloud number looks alarming next to the internal gate, walk these questions in order:
Did the internal
diff-coverage(critical-path, enforced) gate pass on your PR?- Yes → the lines you changed in critical-path modules are ≥90% covered.
A low SonarCloud overall
coverageor currentnew_coverageis not a regression you introduced — it is the whole-repo / frozen-baseline scope. Stop here for those two numbers. - No → you have a real gap in changed critical-path lines. Add tests before merge. This is a real signal, not a scope artifact.
- Yes → the lines you changed in critical-path modules are ≥90% covered.
A low SonarCloud overall
Is the number you are worried about SonarCloud's whole-repo
coverage(~47%)? Judge it against the previous nightly analysis'scoverage, not against the 90% per-PR gate. A drop versus the previous analysis is worth investigating; a low absolute value is expected and structural.Is it SonarCloud
new_coverage? Until theprojectVersionbaseline is wired (see caveats), "new code" ≈ the whole repo, so read it like the overall number. After that fix it becomes a per-release-cycle figure — still not the per-PR diff the internal gate reports.Did your PR change files outside the critical-path allowlist? The enforced internal gate does not measure them (only the advisory full-diff step does); SonarCloud does. So SonarCloud can legitimately show uncovered new lines that the enforced gate stayed silent on — an expected scope difference, not a gate bug. If those lines carry real logic, add focused tests anyway (the charter's rule: every new branch/helper ships with tests).
If after these steps a real coverage gap remains in code you changed, treat it as a regression and add tests. If every "gap" resolves to whole-repo scope or a frozen baseline, it is an expected scope difference — record that reasoning in the PR so the next reader does not re-litigate it.
Where each signal is configured
- Internal
diff-coveragegate — thediff-coveragejob in.github/workflows/ci-quality.yml(--fail-under=90,--include <critical-paths>,--compare-branch). - SonarCloud scope and exclusions —
sonar-project.properties(sonar.sources=src,sonar.tests=tests,sonar.exclusions=...). - Read-only query tool —
scripts/ci/sonarcloud_branch_review.sh, which reproduces every number above against the public API with noSONAR_TOKEN. - Related testing guides — Test-flakiness handling policy, Running the test suite in parallel, Review gates.
Known caveats and follow-ups
projectVersionbaseline is frozen. Every recent nightly reportsprojectVersion = "not provided", so SonarCloud's New Code Period never resets andnew_coveragecurrently behaves like a whole-repo metric. A companion change in this same mission wiressonar.projectVersionfrompyproject.tomlso the baseline resets per release cycle; the effect lands on the next nightly run after that change merges, not on merge itself.- One stale entry in the internal allowlist. The critical-path
--includelist referencessrc/specify_cli/core/mission_detection.py, which no longer exists at that path (the module moved). This is an incidental staleness nit in the internal gate's config, not a SonarCloud misconfiguration, and it is out of scope for this document; a small follow-up to refresh the allowlist is recommended rather than fixed here.