#!/usr/bin/env bash # scripts/quality/coverage.sh # # Measure line/branch test coverage of code/include/ via gcov + lcov. # # Builds the test suite with `--coverage` (gcc) or `-fprofile-instr-generate # -fcoverage-mapping` (clang), runs ctest, and emits: # * build-coverage/coverage.info — lcov tracefile # * build-coverage/lcov-html/index.html — browseable HTML report # * stdout: per-file summary + grand total # # Local + CI. Threshold gate: 80 % line / 70 % branch / 90 % function. # Set SKIP_COVERAGE_GATE=1 to measure without failing (ramp-up mode). # CI: invoked from cpp-tests.yml test-cgal job (after the full suite runs). # # Usage: # bash scripts/quality/coverage.sh # gcc default # CXX=g++-13 bash scripts/quality/coverage.sh # specific compiler # # Prerequisites: gcov + lcov (apt install lcov / brew install lcov) # # Exit codes: # 0 coverage report generated and all thresholds met # 1 tests failed (no usable trace) # 2 prerequisite missing (lcov / compiler not found) # 3 coverage.info is empty after lcov run (version mismatch) # 4 coverage below threshold set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$ROOT" BUILD_DIR="build-coverage" command -v lcov >/dev/null 2>&1 || { echo "FAIL: lcov not found in PATH. Install via:" >&2 echo " macOS: brew install lcov" >&2 echo " Linux: sudo apt install lcov" >&2 exit 2 } # On macOS we prefer brew-installed LLVM clang++ because Apple Clang's # GCov-compatible `--coverage` runtime is known to deadlock during # static-initializer profiling on arm64 with template-heavy code # (Eigen + CGAL); the LLVM build does not. Override with `CXX=...`. DEFAULT_CXX="g++" if [ -z "${CXX:-}" ] && [ "$(uname -s)" = "Darwin" ] \ && [ -x /opt/homebrew/opt/llvm/bin/clang++ ]; then DEFAULT_CXX="/opt/homebrew/opt/llvm/bin/clang++" fi CXX_BIN="${CXX:-$DEFAULT_CXX}" command -v "$CXX_BIN" >/dev/null 2>&1 || { echo "FAIL: $CXX_BIN not found" >&2; exit 2; } echo "========================================" echo " Coverage build (gcov + lcov)" echo " CXX: $CXX_BIN ($($CXX_BIN --version | head -1))" echo " build: $BUILD_DIR" echo "========================================" echo cmake -S code -B "$BUILD_DIR" \ -DCMAKE_CXX_COMPILER="$CXX_BIN" \ -DCMAKE_CXX_FLAGS="--coverage -O0 -g" \ -DCMAKE_EXE_LINKER_FLAGS="--coverage" \ -DCMAKE_BUILD_TYPE=Debug \ -DCMAKE_GTEST_DISCOVER_TESTS_DISCOVERY_MODE=PRE_TEST \ -Wno-dev nice -n 19 cmake --build "$BUILD_DIR" --target conformallab_tests \ -j"$(nproc 2>/dev/null || sysctl -n hw.logicalcpu 2>/dev/null || echo 2)" # Run the non-CGAL test suite. (CGAL tests under coverage instrumentation # blow up to ~6 GB RAM during compilation; out-of-scope for this gate.) cd "$BUILD_DIR" if ! ctest -E "^cgal\." --output-on-failure; then cd "$ROOT" echo "FAIL: coverage build's test run did not complete cleanly." >&2 exit 1 fi cd "$ROOT" # ── Capture trace ──────────────────────────────────────────────────────────── # lcov ≥ 2.0 became strict about "inconsistent" / "unsupported" / "negative" # diagnostics from gcov data; the GTest sources reliably trigger # "inconsistent" because of their preprocessor gymnastics, and brew clang's # gcov shim is older than the function-end-line tracking lcov wants. # These are noise we cannot fix in our source tree — suppress them. LCOV_TOLERANT=( --ignore-errors inconsistent --ignore-errors unsupported --ignore-errors negative --ignore-errors empty --ignore-errors mismatch --rc lcov_branch_coverage=1 ) # C2 fix: capture and extract must succeed (or we have no valid trace). # The --ignore-errors flags above suppress the gcov-version noise; any # remaining error is real (e.g. no .gcda files, compiler mismatch) and # should be visible as a non-zero exit. lcov --capture --directory "$BUILD_DIR" \ --output-file "$BUILD_DIR/coverage.raw.info" \ --no-external \ "${LCOV_TOLERANT[@]}" \ >/dev/null 2>&1 # Restrict to code/include/ (our public API surface; ignore deps/tests). lcov --extract "$BUILD_DIR/coverage.raw.info" \ "*/code/include/*" \ --output-file "$BUILD_DIR/coverage.info" \ "${LCOV_TOLERANT[@]}" \ >/dev/null 2>&1 # ── HTML report (best-effort; failure here does not fail the script) ────────── genhtml --branch-coverage --legend \ --output-directory "$BUILD_DIR/lcov-html" \ "${LCOV_TOLERANT[@]}" \ "$BUILD_DIR/coverage.info" >/dev/null 2>&1 || true # ── Summary to stdout ──────────────────────────────────────────────────────── echo echo "── Coverage summary (code/include/) ─────────────────────────" if [ ! -s "$BUILD_DIR/coverage.info" ]; then echo " FAIL: coverage.info is empty — likely an lcov/gcov version" >&2 echo " mismatch. Tests passed; raw .gcda files are in $BUILD_DIR." >&2 echo " Inspect with: find $BUILD_DIR -name '*.gcda' | head" >&2 exit 3 fi lcov --summary "$BUILD_DIR/coverage.info" "${LCOV_TOLERANT[@]}" 2>/dev/null \ | grep -E "lines\.\.\.\.|functions|branches" \ | sed 's/^/ /' echo echo "HTML report: $BUILD_DIR/lcov-html/index.html" echo " open $BUILD_DIR/lcov-html/index.html" echo # ── Coverage threshold gate (C3) ───────────────────────────────────────────── # Agreed thresholds (2026-05-31): 80 % line / 70 % branch / 90 % function. # Set SKIP_COVERAGE_GATE=1 to print without failing (useful during ramp-up). THRESHOLD_LINE=80 THRESHOLD_BRANCH=70 THRESHOLD_FUNC=90 if [ "${SKIP_COVERAGE_GATE:-0}" = "1" ]; then echo "NOTE: SKIP_COVERAGE_GATE=1 — thresholds not enforced." exit 0 fi SUMMARY=$(lcov --summary "$BUILD_DIR/coverage.info" "${LCOV_TOLERANT[@]}" 2>/dev/null) extract_pct() { echo "$SUMMARY" | grep -i "$1" | grep -oE '[0-9]+\.[0-9]+' | head -1 } LINE_PCT=$(extract_pct "lines") BRANCH_PCT=$(extract_pct "branches") FUNC_PCT=$(extract_pct "functions") FAIL=0 check_threshold() { local label="$1" actual="$2" threshold="$3" if [ -z "$actual" ]; then echo " WARN: could not parse $label coverage — skipping gate" >&2 return fi # Use awk for float comparison (bash cannot do floats) if awk "BEGIN { exit ($actual >= $threshold) ? 0 : 1 }"; then printf " ✓ %-10s %s%% >= %s%%\n" "$label" "$actual" "$threshold" else printf " ✗ %-10s %s%% < %s%% (threshold: %s%%)\n" \ "$label" "$actual" "$threshold" "$threshold" >&2 FAIL=1 fi } echo "── Coverage gate ─────────────────────────────────────────────" check_threshold "lines" "$LINE_PCT" "$THRESHOLD_LINE" check_threshold "branches" "$BRANCH_PCT" "$THRESHOLD_BRANCH" check_threshold "functions" "$FUNC_PCT" "$THRESHOLD_FUNC" echo if [ "$FAIL" -eq 1 ]; then echo "FAIL: coverage below threshold — see above." >&2 echo " To suppress (ramp-up): SKIP_COVERAGE_GATE=1 bash $0" >&2 exit 4 fi echo "PASS: all coverage thresholds met."