C2: Fix coverage.sh — remove || true from lcov capture/extract steps so real
lcov failures are visible; empty coverage.info now exits with code 3.
C3: Add coverage gate to quality-gates CI job (SKIP_COVERAGE_GATE=1 ramp-up
mode until I5 is resolved — fast suite covers ~9.6% not 80%). Thresholds:
80% line / 70% branch / 90% function (agreed 2026-05-31).
V1: Wrap JSON parse + field extraction in try/catch — nlohmann parse_error and
type_error now surface as std::runtime_error with the file path.
V2: Wrap stoi/stod in XML Solver parser — missing/non-numeric attributes throw
std::runtime_error instead of leaking std::invalid_argument.
V4: Validate required JSON keys (dof_vector, solver, solver.*) before access —
missing field produces a clear named-field error message.
I2: 6 serialization negative tests (missing file, malformed JSON, missing
dof_vector, missing solver block, missing XML file, non-numeric XML attr).
I3: load_mesh throws on non-triangulated (quad) mesh — covers the
is_triangle_mesh guard that was previously untested.
I4: spherical_hessian throws on edge DOFs — covers the logic_error guard.
290/290 tests pass (+8 new).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
189 lines
7.4 KiB
Bash
Executable File
189 lines
7.4 KiB
Bash
Executable File
#!/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."
|