Skip to content

Commit 8e5376c

Browse files
Merge pull request #321 from marius-bughiu/claude/100-percent-coverage-6896fc
test(coverage): gate all six shipping packages at 100% line and branch
2 parents 780e1a1 + 2c0b5b2 commit 8e5376c

31 files changed

Lines changed: 5304 additions & 142 deletions

.github/workflows/coverage.yml

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
name: Coverage
22

33
# Code-coverage reporting and gate (issue #29). Collects line/branch coverage for
4-
# the Celerity library via coverlet, renders an HTML report + badges with
5-
# ReportGenerator, fails the build if coverage drops below the floor, comments the
6-
# summary on PRs, and publishes the HTML report to gh-pages (/coverage) on main.
4+
# all six shipping Celerity packages via coverlet, renders an HTML report + badges
5+
# with scripts/coverage_report.py (there is no ReportGenerator dependency), fails
6+
# the build if coverage drops below the floor, comments the summary on PRs, and
7+
# publishes the HTML report to gh-pages (/coverage) on main.
78

89
on:
910
push:
@@ -17,11 +18,18 @@ on:
1718
- 'src/**'
1819
- '.github/workflows/coverage.yml'
1920

20-
# Coverage floor. The suite sits well above this (~99.9% line); the floor is the
21-
# contract that guards against silent regressions, not the target.
21+
# Coverage floor. The suite is at 100% line and 100% branch across all six
22+
# shipping packages, so the floor is set to match: every reachable line and branch
23+
# is covered, and the handful of guards no test can reach (array-size ceilings,
24+
# clamps their caller's own validation already rules out) carry
25+
# [ExcludeFromCodeCoverage] with a Justification saying why.
26+
#
27+
# A 100 floor is deliberately a hair-trigger: new code arrives with its tests, or
28+
# the gate goes red. If a genuinely unreachable branch turns up, exclude it at the
29+
# source with a justification rather than lowering these numbers.
2230
env:
23-
MIN_LINE_COVERAGE: '95'
24-
MIN_BRANCH_COVERAGE: '90'
31+
MIN_LINE_COVERAGE: '100'
32+
MIN_BRANCH_COVERAGE: '100'
2533

2634
jobs:
2735
coverage:
@@ -38,16 +46,22 @@ jobs:
3846
fetch-depth: 0
3947
filter: tree:0
4048

49+
# Both SDKs: the core suite is collected on net8.0 (the floor TFM), while the
50+
# three showcase test projects are single-target net10.0.
4151
- name: Setup .NET
4252
uses: actions/setup-dotnet@v4
4353
with:
44-
dotnet-version: 8.0.x
54+
dotnet-version: |
55+
8.0.x
56+
10.0.x
4557
46-
# Coverage is collected on net8.0 only. The library source is identical
47-
# across the multi-targeted TFMs (#189) — no #if-gated code paths today —
48-
# so one TFM fully covers the line/branch surface, keeps the merged report
49-
# deterministic, and avoids provisioning the net9/net10 SDKs in this job.
50-
- name: Collect coverage
58+
# Coverage for the core packages is collected on net8.0 only. The library
59+
# source is identical across the multi-targeted TFMs (#189) — no #if-gated
60+
# code paths today — so one TFM fully covers the line/branch surface and
61+
# keeps the report deterministic. If a #if-gated path is ever introduced,
62+
# this step has to fan out over the TFMs, or the 100% floor will start
63+
# failing on the gated arm.
64+
- name: Collect coverage (core packages)
5165
working-directory: src
5266
run: >
5367
dotnet test Celerity.Tests/Celerity.Tests.csproj
@@ -57,13 +71,32 @@ jobs:
5771
--settings coverage.runsettings
5872
--results-directory ./TestResults/coverage
5973
74+
# The showcase packages ship too, so they are inside the gate (#314). Their
75+
# test projects are separate, hence a separate results directory that the
76+
# report step merges with the core one.
77+
- name: Collect coverage (showcase packages)
78+
working-directory: src
79+
run: |
80+
set -euo pipefail
81+
for project in Ring Sentinel Cardinality; do
82+
dotnet test "Celerity.${project}.Tests/Celerity.${project}.Tests.csproj" \
83+
--configuration Release \
84+
--collect:"XPlat Code Coverage" \
85+
--settings coverage.runsettings \
86+
--results-directory "./TestResults/showcase/${project}"
87+
done
88+
6089
# Renders the HTML report, badge, and PR summary, writes the run summary,
6190
# and fails the job if coverage is below the floor — all in one script, so
6291
# the report carries the project's own styling and no third-party upsell.
92+
# The two --input globs are merged on (source file, line); the showcase
93+
# projects also pull in Celerity.Collections, and a line covered by any run
94+
# counts as covered.
6395
- name: Generate report and enforce floor
6496
run: >
6597
python3 scripts/coverage_report.py
6698
--input "src/TestResults/coverage/**/coverage.cobertura.xml"
99+
--input "src/TestResults/showcase/*/**/coverage.cobertura.xml"
67100
--outdir coveragereport
68101
--min-line "$MIN_LINE_COVERAGE"
69102
--min-branch "$MIN_BRANCH_COVERAGE"

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ All notable changes to Celerity are documented here. This project follows [Keep
88

99
- **`BTreeDictionary<TKey, TValue, TComparer>` and `BTreeSet<T, TComparer>`** (with `BTreeDictionary<TKey, TValue>` / `BTreeSet<T>` aliases and the `DefaultComparer<T>` struct comparer) in `Celerity.Collections` — the library's first sorted map and set, and the B-tree the BCL lacks. Up to 31 keys per node keep a lookup `log₃₂(n)` node visits deep instead of chasing the `log₂(n)` pointers a red-black tree costs, and both add the ordered surface a hash table cannot answer: `Min` / `Max`, lower / upper bound, `EnumerateRange` in `O(log n + k)`, and in-order enumeration. They win on the interleaved insert + lookup + range-scan workload and on memory, and lose slightly on a delete-dominated one. Not thread-safe. Closes [#305](https://github.com/marius-bughiu/Celerity/issues/305).
1010

11+
### Fixed
12+
13+
- **The coverage gate measured only one of the six shipped packages.** Coverlet's assembly filter is exact-match, so `Celerity.Hashing`, `Celerity.Primitives`, and the three showcase packages had been outside the gate since the 2.0.0 package split — any of them could have dropped to 0% with CI green. All six are now measured, the gaps that exposed are backfilled to **100% line and branch** coverage, and the floor is raised from 95%/90% to match. Closes [#314](https://github.com/marius-bughiu/Celerity/issues/314).
14+
1115
## [2.4.0] - 2026-07-26
1216

1317
### Added

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ dotnet test # xUnit
2929
```
3030

3131
- `net8.0` is the floor. Shared code must not use net9/net10-only APIs unguarded — gate newer paths with `#if NET9_0_OR_GREATER` / `NET10_0_OR_GREATER` and keep a net8.0 fallback. The target list lives in `src/Directory.Build.props`.
32-
- Coverage is gated in CI: keep line ≥ 95%, branch ≥ 90%.
32+
- Coverage is gated in CI at 100% line and 100% branch across all six shipping packages. New code needs its tests. For a branch no test can reach, use `[ExcludeFromCodeCoverage(Justification = "…")]` rather than lowering the floor, and add a new shipping package's assembly to `src/coverage.runsettings` (the filter is exact-match, so an unlisted package is silently unmeasured).
3333
- Every public type/member needs an XML doc comment (`GenerateDocumentationFile` is on; missing docs warn).
3434
- Hashers are `struct`s passed as generic constraints (`where THasher : struct, IHashProvider<T>`) so the JIT devirtualizes them — do not turn them into classes/interfaces.
3535
- Avoid allocations on hot paths.

CONTRIBUTING.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ These are enforced by review, not by an analyzer. Reading the existing code is t
5959
- Prefer `[Fact]` for a single case, `[Theory] + [InlineData]` for parameterized cases.
6060
- When fixing a bug, add a test that fails on `main` and passes on your branch. It's fine to reference the issue number in a comment.
6161
- New collections are expected to carry parity coverage at every layer: behavioural tests, a CsCheck property test against the closest BCL oracle, and a `Celerity.Fuzz` target. See the [Testing & coverage guide](docs/testing.md) for how each layer works and how to run them.
62-
- Coverage is gated in CI (`.github/workflows/coverage.yml`); keep line coverage ≥ 95% and branch ≥ 90%. The suite normally sits near 100%.
62+
- Coverage is gated in CI (`.github/workflows/coverage.yml`) at **100% line and 100% branch**, across all six shipping packages. New code arrives with its tests, or the gate goes red. If you hit a branch no test can reach, exclude it at the source with `[ExcludeFromCodeCoverage(Justification = "…")]` explaining why — do not lower the floor. See the [Testing & coverage guide](docs/testing.md) for the current exclusions and the reasoning behind each.
63+
- Adding a new shipping package? Add its assembly to `src/coverage.runsettings` and its test project to the coverage workflow. Coverlet's assembly filter is exact-match, so an unlisted package is silently unmeasured.
6364

6465
## Benchmarks
6566

docs/testing.md

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Celerity's first guiding principle is *correctness first* — "a fast collection
1212
| Differential fuzzer | `Celerity.Fuzz` | A long random walk finds no divergence from the BCL; failures replay deterministically from a seed. | `dotnet run -c Release` |
1313
| Native AOT smoke test | `Celerity.AotSmokeTest` | Every collection/hasher works in a trimmed, AOT-compiled native binary. | see [aot.md](aot.md) |
1414

15-
All of these run in CI. Coverage is measured on the library assembly and gated; the rendered report is published to [the coverage dashboard](https://marius-bughiu.github.io/Celerity/coverage/).
15+
All of these run in CI. Coverage is measured on all six shipping assemblies and gated at 100% line and branch; the rendered report is published to [the coverage dashboard](https://marius-bughiu.github.io/Celerity/coverage/).
1616

1717
## Philosophy: example tests, then adversarial tests
1818

@@ -107,24 +107,58 @@ Add an entry to `Differential.All` in `src/Celerity.Fuzz/Differential.cs` and wr
107107

108108
## Code coverage
109109

110-
Coverage is collected with [coverlet](https://github.com/coverlet-coverage/coverlet) (already referenced by the test project) and scoped to the shipping `Celerity` assembly via [`src/coverage.runsettings`](../src/coverage.runsettings) — the test, benchmark, fuzz, and AOT-smoke assemblies are tooling, not the subject under measurement.
110+
Coverage is collected with [coverlet](https://github.com/coverlet-coverage/coverlet) and scoped to all six shipping assemblies — `Celerity`, `Celerity.Hashing`, `Celerity.Primitives`, `Celerity.Ring`, `Celerity.Sentinel`, `Celerity.Cardinality` — via [`src/coverage.runsettings`](../src/coverage.runsettings). The test, benchmark, fuzz, and AOT-smoke assemblies are tooling, not the subject under measurement.
111+
112+
Four test projects contribute: `Celerity.Tests` for the three core packages, plus `Celerity.Ring.Tests` / `Celerity.Sentinel.Tests` / `Celerity.Cardinality.Tests` for the showcase tier. Their Cobertura reports are merged on (source file, line number), so a line covered by any run counts as covered — which matters because the showcase projects also exercise `Celerity.Collections` transitively.
113+
114+
The suite covers **100% of lines and 100% of branches** across all six. A small number of guards are excluded at the source with `[ExcludeFromCodeCoverage(Justification = "…")]`, and only where no test could ever reach them:
115+
116+
| Guard | Why no test can reach it |
117+
|---|---|
118+
| `Deque.ClampToArrayMaxLength` | Needs a backing array above 2³⁰ elements. Pinned by a real `[MemoryIntensiveFact(3100)]` test in `DequeGrowthTests`, which allocates ~3 GiB and skips on memory-capped runners — excluded so the gate does not depend on whether the runner had the headroom. |
119+
| `IndexedPriorityQueue.ClampGrowth` | Needs 2³⁰ *live* entries with distinct elements, pushing the backing array past the 2 GiB single-object limit. `EnsureCapacity` calls `Resize` directly, so capacity cannot be pre-inflated into it. |
120+
| `FrozenCelerityDictionary.ThrowIfKeyCountExceedsCeiling`, `FrozenCeleritySet.ThrowIfElementCountExceedsCeiling` | The count is taken *after* materializing the source into a `List<string>`, so reaching 2³⁰ needs an 8.6 GB `string[]` — past the 2 GiB array limit. A source that merely reports a huge `ICollection.Count` cannot reach it; that count is only a capacity hint. |
121+
| `CuckooFilter.AtLeastOne`, `XorFilter.AtLeastOne` | Dead by construction: the constructors' own argument validation already forces both sizing expressions above the floor. |
122+
| `XorFilter.BuildOrThrow`, `XorFilter.TryBuild` | The peel retry schedule is independent of the element set, so no hasher can stall all `MaxConstructionAttempts` seeds. Individual attempts *do* stall and retry — that path lives in `TryPeel`, which stays measured. |
123+
| `Hash64Source.CreateNative` | Its `null` arm is unobservable. `Native` is read only by `Hash64`, and every caller guards that on `IsNative64` being true, so the class is never initialized for a 32-bit-only `THasher` — the arm is evaluated only if the runtime runs the `beforefieldinit` initializer eagerly, which is its option and not a contract. |
124+
125+
That table is the complete set; `grep -rn "ExcludeFromCodeCoverage" src/Celerity*/` should return nothing beyond it.
126+
127+
The rule for new code: exclusions are for genuinely unreachable code and must carry a `Justification` that says *why*. Anything a test can reach gets a test.
128+
129+
> **Adding a shipping package?** Add its assembly to the `<Include>` list in `src/coverage.runsettings`, and its test project to `.github/workflows/coverage.yml`. Coverlet's assembly filter is **exact-match, not a prefix**`[Celerity]*` compiles to `^Celerity$` and matches only the `Celerity.Collections` assembly. That is how the 2.0.0 package split left five of six packages silently outside the gate until [#314](https://github.com/marius-bughiu/Celerity/issues/314). An unlisted package is unmeasured, and the gate stays green no matter what its coverage is.
111130
112131
Collect and render a report locally:
113132

114133
```bash
115-
# 1. collect Cobertura coverage for the library only
134+
# 1. collect Cobertura coverage for the three core packages.
135+
# Clear stale results first: the four reports are merged by source-file path, and
136+
# SourceLink resolves those paths from the build's git state — so mixing reports
137+
# from different commits makes the same file appear twice under two spellings and
138+
# the merged totals come out roughly halved.
116139
cd src
140+
rm -rf ./TestResults/coverage ./TestResults/showcase
117141
dotnet test Celerity.Tests/Celerity.Tests.csproj \
118142
--collect:"XPlat Code Coverage" \
119143
--settings coverage.runsettings \
120144
--results-directory ./TestResults/coverage
121145

122-
# 2. render the HTML report + badge (pure Python, no extra tooling)
146+
# 2. and for the three showcase packages
147+
for project in Ring Sentinel Cardinality; do
148+
dotnet test "Celerity.${project}.Tests/Celerity.${project}.Tests.csproj" \
149+
--collect:"XPlat Code Coverage" \
150+
--settings coverage.runsettings \
151+
--results-directory "./TestResults/showcase/${project}"
152+
done
153+
154+
# 3. render the HTML report + badge (pure Python, no extra tooling).
155+
# --input is repeatable; the reports are merged.
123156
python3 ../scripts/coverage_report.py \
124157
--input "./TestResults/coverage/**/coverage.cobertura.xml" \
125-
--outdir ../coveragereport --min-line 95 --min-branch 90
158+
--input "./TestResults/showcase/*/**/coverage.cobertura.xml" \
159+
--outdir ../coveragereport --min-line 100 --min-branch 100
126160

127-
# 3. open coveragereport/index.html
161+
# 4. open coveragereport/index.html
128162
```
129163

130164
The report is rendered by [`scripts/coverage_report.py`](../scripts/coverage_report.py) — a small generator that reads the Cobertura XML coverlet produces and emits an `index.html` styled like the rest of the Celerity site, a `badge.svg`, and a `summary.md`. It exists so the report carries the project's own look and no third-party "sponsors only" upsell; there is no dependency on ReportGenerator.
@@ -134,7 +168,7 @@ The report is rendered by [`scripts/coverage_report.py`](../scripts/coverage_rep
134168
The `coverage` workflow (`.github/workflows/coverage.yml`) runs on every PR and on `main`:
135169

136170
- Collects coverage, renders the report + badge with `scripts/coverage_report.py`, and uploads it as a build artifact.
137-
- **Fails the build** if line coverage drops below `MIN_LINE_COVERAGE` (95%) or branch coverage below `MIN_BRANCH_COVERAGE` (90%). The suite sits far above these (100% line and branch) — the floor guards against silent regressions; it is not the target.
171+
- **Fails the build** if line coverage drops below `MIN_LINE_COVERAGE` (100%) or branch coverage below `MIN_BRANCH_COVERAGE` (100%). The floor is deliberately a hair-trigger: new code arrives with its tests, or the gate goes red. If you hit a genuinely unreachable branch, exclude it at the source with a justification rather than lowering the floor.
138172
- Posts a coverage summary comment on the PR.
139173
- On `main`, publishes the HTML report to `gh-pages` under [`/coverage`](https://marius-bughiu.github.io/Celerity/coverage/) and refreshes the README badge.
140174

0 commit comments

Comments
 (0)