|
1 | 1 | #!/usr/bin/env bash |
| 2 | +# Tiered statement coverage using coverage.py JSON output. |
| 3 | +# |
| 4 | +# Usage: check-coverage.sh [coverage_json] |
| 5 | +# Default: coverage.json (run pytest with --cov first) |
2 | 6 |
|
3 | 7 | set -euo pipefail |
4 | 8 |
|
5 | | -MIN_COVERAGE="${1:-95}" |
| 9 | +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" |
| 10 | +CONF="${ROOT}/scripts/coverage-tiers.conf" |
| 11 | +COVERAGE_JSON_ARG="${1:-coverage.json}" |
6 | 12 |
|
7 | | -COVERAGE=() |
8 | | -if command -v coverage >/dev/null 2>&1; then |
9 | | - COVERAGE=(coverage) |
| 13 | +if [[ "$COVERAGE_JSON_ARG" == /* ]]; then |
| 14 | + COVERAGE_JSON_PATH="$COVERAGE_JSON_ARG" |
10 | 15 | else |
11 | | - for py in python python3; do |
12 | | - if command -v "$py" >/dev/null 2>&1 && "$py" -m coverage --version >/dev/null 2>&1; then |
13 | | - COVERAGE=("$py" -m coverage) |
14 | | - break |
15 | | - fi |
16 | | - done |
| 16 | + COVERAGE_JSON_PATH="${ROOT}/${COVERAGE_JSON_ARG}" |
17 | 17 | fi |
18 | 18 |
|
19 | | -if [[ ${#COVERAGE[@]} -eq 0 ]]; then |
20 | | - echo "coverage CLI not found; install dev deps with: pip install -e \".[dev]\"" >&2 |
| 19 | +if [[ ! -f "$CONF" ]]; then |
| 20 | + echo "coverage tiers config not found: $CONF" >&2 |
21 | 21 | exit 2 |
22 | 22 | fi |
23 | 23 |
|
24 | | -TOTAL_LINE="$("${COVERAGE[@]}" report --include='src/intentproof/*' | awk '/^TOTAL/{print; exit}')" |
25 | | -if [[ -z "$TOTAL_LINE" ]]; then |
26 | | - echo "unable to read total coverage; run pytest with --cov first" >&2 |
| 24 | +# shellcheck disable=SC1090 |
| 25 | +source "$CONF" |
| 26 | + |
| 27 | +if [[ -z "${TOTAL_MIN:-}" ]]; then |
| 28 | + echo "coverage-tiers.conf must set TOTAL_MIN" >&2 |
| 29 | + exit 2 |
| 30 | +fi |
| 31 | + |
| 32 | +if [[ ! -f "$COVERAGE_JSON_PATH" ]]; then |
| 33 | + echo "coverage json not found: $COVERAGE_JSON_PATH (run pytest with --cov first)" >&2 |
| 34 | + exit 2 |
| 35 | +fi |
| 36 | + |
| 37 | +rules_file="$(mktemp)" |
| 38 | +trap 'rm -f "$rules_file"' EXIT |
| 39 | +printf '%s\n' "${CRITICAL_RULES[@]}" >"$rules_file" |
| 40 | + |
| 41 | +coverage_percent_display() { |
| 42 | + awk -v c="$1" -v t="$2" 'BEGIN { |
| 43 | + if (t == 0) { print "0.0"; exit } |
| 44 | + printf "%.1f", int(1000 * c / t) / 10 |
| 45 | + }' |
| 46 | +} |
| 47 | + |
| 48 | +threshold_met() { |
| 49 | + awk -v c="$1" -v t="$2" -v min="$3" \ |
| 50 | + 'BEGIN { exit !(t > 0 && c * 100 >= t * min) }' |
| 51 | +} |
| 52 | + |
| 53 | +report_threshold() { |
| 54 | + local label="$1" covered="$2" total="$3" min="$4" |
| 55 | + local pct |
| 56 | + pct="$(coverage_percent_display "$covered" "$total")" |
| 57 | + echo "${label}: ${pct}% (${covered}/${total} statements), minimum ${min}%" |
| 58 | + if threshold_met "$covered" "$total" "$min"; then |
| 59 | + echo " PASS" |
| 60 | + return 0 |
| 61 | + fi |
| 62 | + echo " FAIL" >&2 |
| 63 | + return 1 |
| 64 | +} |
| 65 | + |
| 66 | +stats_for_prefix() { |
| 67 | + local prefix="$1" |
| 68 | + python3 - "$COVERAGE_JSON_PATH" "$prefix" <<'PY' |
| 69 | +import json |
| 70 | +import sys |
| 71 | +
|
| 72 | +path = sys.argv[1] |
| 73 | +prefix = sys.argv[2] |
| 74 | +data = json.load(open(path)) |
| 75 | +covered = 0 |
| 76 | +total = 0 |
| 77 | +
|
| 78 | +def covered_statements(summary, entry, stmts): |
| 79 | + if "covered_lines" in summary: |
| 80 | + return int(summary["covered_lines"]) |
| 81 | + missing_lines = entry.get("missing_lines") |
| 82 | + if isinstance(missing_lines, list): |
| 83 | + return stmts - len(missing_lines) |
| 84 | + missing_count = summary.get("missing_lines") |
| 85 | + if isinstance(missing_count, int): |
| 86 | + return stmts - missing_count |
| 87 | + return 0 |
| 88 | +
|
| 89 | +for file_path, entry in data.get("files", {}).items(): |
| 90 | + if prefix not in file_path.replace("\\", "/"): |
| 91 | + continue |
| 92 | + summary = entry.get("summary", {}) |
| 93 | + stmts = int(summary.get("num_statements", 0)) |
| 94 | + if stmts == 0: |
| 95 | + continue |
| 96 | + total += stmts |
| 97 | + covered += covered_statements(summary, entry, stmts) |
| 98 | +print(covered, total) |
| 99 | +PY |
| 100 | +} |
| 101 | + |
| 102 | +read -r TOTAL_COVERED TOTAL_STMTS <<EOF |
| 103 | +$(stats_for_prefix "") |
| 104 | +EOF |
| 105 | + |
| 106 | +if [[ -z "$TOTAL_STMTS" || "$TOTAL_STMTS" -eq 0 ]]; then |
| 107 | + echo "unable to read total coverage from $COVERAGE_JSON_PATH" >&2 |
27 | 108 | exit 2 |
28 | 109 | fi |
29 | 110 |
|
30 | | -TOTAL_PERCENT="$(printf '%s' "$TOTAL_LINE" | awk '{print $NF}' | tr -d '%')" |
| 111 | +fail=0 |
| 112 | +report_threshold "Total coverage" "$TOTAL_COVERED" "$TOTAL_STMTS" "$TOTAL_MIN" || fail=1 |
31 | 113 |
|
32 | | -echo "Total coverage: ${TOTAL_PERCENT}%" |
33 | | -echo "Minimum required: ${MIN_COVERAGE}%" |
| 114 | +echo "Critical tiers:" |
| 115 | +while IFS= read -r rule; do |
| 116 | + [[ -n "$rule" ]] || continue |
| 117 | + min="${rule%%:*}" |
| 118 | + prefix="${rule#*:}" |
| 119 | + read -r c t <<EOF |
| 120 | +$(stats_for_prefix "$prefix") |
| 121 | +EOF |
| 122 | + if [[ "$t" -eq 0 ]]; then |
| 123 | + echo " ${prefix} (min ${min}%): no statements in profile, FAIL" >&2 |
| 124 | + fail=1 |
| 125 | + continue |
| 126 | + fi |
| 127 | + report_threshold " ${prefix}" "$c" "$t" "$min" || fail=1 |
| 128 | +done <"$rules_file" |
34 | 129 |
|
35 | | -if awk -v got="$TOTAL_PERCENT" -v min="$MIN_COVERAGE" 'BEGIN { exit !(got + 0 >= min + 0) }'; then |
36 | | - echo "PASS: coverage threshold met" |
37 | | - exit 0 |
| 130 | +if [[ "$fail" -ne 0 ]]; then |
| 131 | + echo "FAIL: coverage threshold not met" >&2 |
| 132 | + exit 1 |
38 | 133 | fi |
39 | 134 |
|
40 | | -echo "FAIL: coverage threshold not met" >&2 |
41 | | -exit 1 |
| 135 | +echo "PASS: coverage thresholds met" |
| 136 | +exit 0 |
0 commit comments