#!/usr/bin/env bash # SPDX-License-Identifier: MIT # SPDX-FileCopyrightText: 2026 Martino Ferrari # # Reproducible coverage for src/**, with a floor. # # One command: configure and build the instrumented tree, run the whole suite, # capture src/** with lcov into build/cov/coverage.info, render the HTML report # under build/cov/html/, print the per-file table, and exit non-zero when # tools/coverage_floor.py finds a file below 80% lines or the src/ aggregate # below 90%. The floors are tools/coverage_floor.py's constants; the measured # table and the floor verdict are written back into docs/coverage.md's generated # region by `coverage_floor.py --write-doc`, from the very report this run # captured, so that document cannot drift from the command that measures it. # # Idempotent by construction: gcov counters accumulate in .gcda files, so a # second run that inherited the first run's counters would report a superset of # what it exercised (and a source file deleted since the last run would report # stale data for a file that no longer exists). This script therefore deletes # every .gcda under the build tree *before* it runs the suite, so the captured # counters are exactly the ones this run produced. Run it twice on an unchanged # tree and build/cov/coverage.info is byte-for-byte identical. # # Usage: tools/coverage.sh [build-dir] # build-dir defaults to build/cov, the linux-coverage preset's binaryDir. # Any other directory is used as-is and must already be configured with # --coverage (the `coverage` CMake target passes its own build directory). set -euo pipefail readonly ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" readonly PRESET_BUILD="$ROOT/build/cov" readonly BUILD="${1:-$PRESET_BUILD}" readonly FLOOR="$ROOT/tools/coverage_floor.py" readonly RAW="$BUILD/coverage.raw.info" readonly INFO="$BUILD/coverage.info" readonly HTML="$BUILD/html" readonly DOC="$ROOT/docs/coverage.md" log() { printf '==> %s\n' "$*"; } for tool in cmake ctest lcov genhtml python3; do if ! command -v "$tool" >/dev/null 2>&1; then printf 'coverage: %s is required (Arch: pacman -S cmake ninja lcov python)\n' "$tool" >&2 exit 2 fi done if [[ "$BUILD" == "$PRESET_BUILD" ]]; then # The preset file lives at the repository root, but the `coverage` target runs # this script with the build directory as its working directory (that is how # ninja invokes a target's command), so the configure step states where to look # rather than assuming. Every other path in this script is absolute for the # same reason. log "configuring $BUILD from the linux-coverage preset" cd "$ROOT" cmake --preset linux-coverage --log-level=WARNING elif [[ ! -f "$BUILD/CMakeCache.txt" ]]; then printf 'coverage: %s is not a configured build tree; use --preset linux-coverage\n' "$BUILD" >&2 exit 2 fi log "building" cmake --build "$BUILD" # Before the suite, not after: see the idempotency note at the top of the file. log "clearing stale gcov counters under $BUILD" find "$BUILD" -name '*.gcda' -delete log "running the suite" ctest --test-dir "$BUILD" --output-on-failure log "capturing src/** with lcov into $INFO" # The unexecuted-block warning comes from libstdc++ headers on this toolchain; # geninfo_unexecuted_blocks=1 records those blocks as unexecuted instead of # warning about them, which is what the report means anyway. lcov --capture --directory "$BUILD" --output-file "$RAW" \ --rc branch_coverage=0 --rc geninfo_unexecuted_blocks=1 \ --ignore-errors inconsistent,inconsistent lcov --extract "$RAW" "$ROOT/src/*" --output-file "$INFO" --ignore-errors unused rm -f "$RAW" log "rendering the HTML report into $HTML/index.html" rm -rf "$HTML" genhtml "$INFO" --output-directory "$HTML" --prefix "$ROOT" \ --title "plot -- line coverage of src/**" --rc branch_coverage=0 \ --ignore-errors inconsistent,inconsistent log "per-file coverage for src/**" set +e python3 "$FLOOR" --table "$INFO" readonly FLOOR_STATUS=$? set -e # The measured table is written into the document that states it, from the very # report this run captured, so docs/coverage.md cannot drift from the command # that produces it. This happens before the floor gate below, so a failing tree # still gets an honest table (and its failure recorded) in the document. log "regenerating the measured table in $DOC" python3 "$FLOOR" --write-doc "$DOC" "$INFO" if (( FLOOR_STATUS != 0 )); then printf '\ncoverage: FLOOR FAILED (exit %d) -- report %s, HTML %s/index.html\n' \ "$FLOOR_STATUS" "$INFO" "$HTML" >&2 exit "$FLOOR_STATUS" fi printf '\ncoverage: floor satisfied -- report %s, HTML %s/index.html\n' "$INFO" "$HTML"