Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 46 additions & 13 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
name: Coverage

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

on:
push:
Expand All @@ -17,11 +18,18 @@ on:
- 'src/**'
- '.github/workflows/coverage.yml'

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

jobs:
coverage:
Expand All @@ -38,16 +46,22 @@ jobs:
fetch-depth: 0
filter: tree:0

# Both SDKs: the core suite is collected on net8.0 (the floor TFM), while the
# three showcase test projects are single-target net10.0.
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
dotnet-version: |
8.0.x
10.0.x

# Coverage is collected on net8.0 only. The library source is identical
# across the multi-targeted TFMs (#189) — no #if-gated code paths today —
# so one TFM fully covers the line/branch surface, keeps the merged report
# deterministic, and avoids provisioning the net9/net10 SDKs in this job.
- name: Collect coverage
# Coverage for the core packages is collected on net8.0 only. The library
# source is identical across the multi-targeted TFMs (#189) — no #if-gated
# code paths today — so one TFM fully covers the line/branch surface and
# keeps the report deterministic. If a #if-gated path is ever introduced,
# this step has to fan out over the TFMs, or the 100% floor will start
# failing on the gated arm.
- name: Collect coverage (core packages)
working-directory: src
run: >
dotnet test Celerity.Tests/Celerity.Tests.csproj
Expand All @@ -57,13 +71,32 @@ jobs:
--settings coverage.runsettings
--results-directory ./TestResults/coverage

# The showcase packages ship too, so they are inside the gate (#314). Their
# test projects are separate, hence a separate results directory that the
# report step merges with the core one.
- name: Collect coverage (showcase packages)
working-directory: src
run: |
set -euo pipefail
for project in Ring Sentinel Cardinality; do
dotnet test "Celerity.${project}.Tests/Celerity.${project}.Tests.csproj" \
--configuration Release \
--collect:"XPlat Code Coverage" \
--settings coverage.runsettings \
--results-directory "./TestResults/showcase/${project}"
done

# Renders the HTML report, badge, and PR summary, writes the run summary,
# and fails the job if coverage is below the floor — all in one script, so
# the report carries the project's own styling and no third-party upsell.
# The two --input globs are merged on (source file, line); the showcase
# projects also pull in Celerity.Collections, and a line covered by any run
# counts as covered.
- name: Generate report and enforce floor
run: >
python3 scripts/coverage_report.py
--input "src/TestResults/coverage/**/coverage.cobertura.xml"
--input "src/TestResults/showcase/*/**/coverage.cobertura.xml"
--outdir coveragereport
--min-line "$MIN_LINE_COVERAGE"
--min-branch "$MIN_BRANCH_COVERAGE"
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ All notable changes to Celerity are documented here. This project follows [Keep

- **`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).

### Fixed

- **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).

## [2.4.0] - 2026-07-26

### Added
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ dotnet test # xUnit
```

- `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`.
- Coverage is gated in CI: keep line ≥ 95%, branch ≥ 90%.
- 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).
- Every public type/member needs an XML doc comment (`GenerateDocumentationFile` is on; missing docs warn).
- 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.
- Avoid allocations on hot paths.
Expand Down
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ These are enforced by review, not by an analyzer. Reading the existing code is t
- Prefer `[Fact]` for a single case, `[Theory] + [InlineData]` for parameterized cases.
- 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.
- 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.
- Coverage is gated in CI (`.github/workflows/coverage.yml`); keep line coverage ≥ 95% and branch ≥ 90%. The suite normally sits near 100%.
- 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.
- 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.

## Benchmarks

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

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/).
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/).

## Philosophy: example tests, then adversarial tests

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

## Code coverage

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.
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.

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.

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:

| Guard | Why no test can reach it |
|---|---|
| `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. |
| `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. |
| `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. |
| `CuckooFilter.AtLeastOne`, `XorFilter.AtLeastOne` | Dead by construction: the constructors' own argument validation already forces both sizing expressions above the floor. |
| `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. |
Comment thread
marius-bughiu marked this conversation as resolved.
| `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. |

That table is the complete set; `grep -rn "ExcludeFromCodeCoverage" src/Celerity*/` should return nothing beyond it.

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.

> **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.

Collect and render a report locally:

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

# 2. render the HTML report + badge (pure Python, no extra tooling)
# 2. and for the three showcase packages
for project in Ring Sentinel Cardinality; do
dotnet test "Celerity.${project}.Tests/Celerity.${project}.Tests.csproj" \
--collect:"XPlat Code Coverage" \
--settings coverage.runsettings \
--results-directory "./TestResults/showcase/${project}"
done

# 3. render the HTML report + badge (pure Python, no extra tooling).
# --input is repeatable; the reports are merged.
python3 ../scripts/coverage_report.py \
--input "./TestResults/coverage/**/coverage.cobertura.xml" \
--outdir ../coveragereport --min-line 95 --min-branch 90
--input "./TestResults/showcase/*/**/coverage.cobertura.xml" \
--outdir ../coveragereport --min-line 100 --min-branch 100

# 3. open coveragereport/index.html
# 4. open coveragereport/index.html
```

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.
Expand All @@ -134,7 +168,7 @@ The report is rendered by [`scripts/coverage_report.py`](../scripts/coverage_rep
The `coverage` workflow (`.github/workflows/coverage.yml`) runs on every PR and on `main`:

- Collects coverage, renders the report + badge with `scripts/coverage_report.py`, and uploads it as a build artifact.
- **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.
- **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.
- Posts a coverage summary comment on the PR.
- On `main`, publishes the HTML report to `gh-pages` under [`/coverage`](https://marius-bughiu.github.io/Celerity/coverage/) and refreshes the README badge.

Expand Down
Loading
Loading