From 1ceb7b8f0fe05ed125bb3e6e5ac5f2fe7f232ba9 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 23 May 2026 22:43:26 +0100 Subject: [PATCH] Add coverage CRAP indicators --- README.md | 2 +- scripts/ci/check-ci-policy.sh | 1 + scripts/ci/check-coverage.sh | 21 +---- scripts/ci/report-coverage-indicators.sh | 97 ++++++++++++++++++++++++ scripts/ci/run-fast-checks.sh | 1 + scripts/ci/test-coverage-indicators.sh | 72 ++++++++++++++++++ 6 files changed, 173 insertions(+), 21 deletions(-) create mode 100755 scripts/ci/report-coverage-indicators.sh create mode 100755 scripts/ci/test-coverage-indicators.sh diff --git a/README.md b/README.md index 8c3be1c..68ebafb 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Run the same local gate that backs PR `CI Gate` before opening a PR: bash scripts/ci/run-fast-checks.sh ``` -The gate checks CI policy drift, shell syntax, secret patterns, privacy fixtures, Swift tests, debug and release builds, and CLI help output. +The gate checks CI policy drift, shell syntax, secret patterns, privacy fixtures, Swift tests, source coverage with CRAP-style low-coverage indicators, debug and release builds, and CLI help output. ## Roadmap diff --git a/scripts/ci/check-ci-policy.sh b/scripts/ci/check-ci-policy.sh index 1f85cc7..6ee3204 100755 --- a/scripts/ci/check-ci-policy.sh +++ b/scripts/ci/check-ci-policy.sh @@ -41,6 +41,7 @@ if [[ -f "$fast_script" ]]; then require_line "$fast_script" "bash scripts/check-detect-secrets.sh --all-files" require_line "$fast_script" "bash scripts/check-privacy-fixtures.sh" require_line "$fast_script" "swift test" + require_line "$fast_script" "bash scripts/ci/test-coverage-indicators.sh" require_line "$fast_script" "swift build" require_line "$fast_script" "swift build -c release" require_line "$fast_script" "swift run icloud-cli --help" diff --git a/scripts/ci/check-coverage.sh b/scripts/ci/check-coverage.sh index 81ef41a..eafc65a 100755 --- a/scripts/ci/check-coverage.sh +++ b/scripts/ci/check-coverage.sh @@ -1,7 +1,6 @@ #!/usr/bin/env bash set -euo pipefail -minimum_line_coverage="${COVERAGE_MIN_LINES:-68}" module_cache="${CLANG_MODULE_CACHE_PATH:-$PWD/.build/module-cache}" mkdir -p "$module_cache" export CLANG_MODULE_CACHE_PATH="$module_cache" @@ -22,22 +21,4 @@ else coverage_json="$(swift test --show-codecov-path)" fi -source_line_coverage="$( - jq -r --arg prefix "$PWD/Sources/ICloudCLICore/" ' - [.data[0].files[] - | select(.filename | startswith($prefix)) - | .summary.lines] - | {count: (map(.count) | add), covered: (map(.covered) | add)} - | (.covered * 100 / .count) - ' "$coverage_json" -)" - -printf 'Source line coverage: %.2f%% (minimum %.2f%%)\n' "$source_line_coverage" "$minimum_line_coverage" - -awk -v actual="$source_line_coverage" -v minimum="$minimum_line_coverage" ' - BEGIN { - if (actual + 0 < minimum + 0) { - exit 1 - } - } -' +bash scripts/ci/report-coverage-indicators.sh "$coverage_json" "$PWD/Sources/ICloudCLICore/" diff --git a/scripts/ci/report-coverage-indicators.sh b/scripts/ci/report-coverage-indicators.sh new file mode 100755 index 0000000..55d26f5 --- /dev/null +++ b/scripts/ci/report-coverage-indicators.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +coverage_json="${1:?usage: report-coverage-indicators.sh COVERAGE_JSON [SOURCE_PREFIX]}" +source_prefix="${2:-$PWD/Sources/ICloudCLICore/}" +minimum_line_coverage="${COVERAGE_MIN_LINES:-68}" +crap_indicator_limit="${CRAP_INDICATOR_LIMIT:-5}" +crap_max_coverage="${CRAP_MAX_COVERAGE:-80}" +crap_min_file_lines="${CRAP_MIN_FILE_LINES:-10}" +crap_min_function_lines="${CRAP_MIN_FUNCTION_LINES:-8}" + +source_line_coverage="$( + jq -r --arg prefix "$source_prefix" ' + [.data[0].files[] + | select(.filename | startswith($prefix)) + | .summary.lines] + | {count: (map(.count // 0) | add // 0), covered: (map(.covered // 0) | add // 0)} + | if .count == 0 then 0 else (.covered * 100 / .count) end + ' "$coverage_json" +)" + +printf 'Source line coverage: %.2f%% (minimum %.2f%%)\n' "$source_line_coverage" "$minimum_line_coverage" + +printf 'CRAP indicators (coverage-driven; llvm-cov JSON does not include cyclomatic complexity):\n' +crap_indicators="$( + jq -r \ + --arg prefix "$source_prefix" \ + --argjson limit "$crap_indicator_limit" \ + --argjson max_coverage "$crap_max_coverage" \ + --argjson min_file_lines "$crap_min_file_lines" \ + --argjson min_function_lines "$crap_min_function_lines" ' + def pct($covered; $count): + if ($count | tonumber) == 0 then 0 else (($covered | tonumber) * 100 / ($count | tonumber)) end; + def line_summary: + .summary.lines as $lines + | { + count: ($lines.count // 0), + covered: ($lines.covered // 0), + pct: pct(($lines.covered // 0); ($lines.count // 0)) + }; + + ( + [.data[0].files[] + | select(.filename | startswith($prefix)) + | line_summary as $lines + | select($lines.count >= $min_file_lines and $lines.pct < $max_coverage) + | { + kind: "file", + name: (.filename | ltrimstr($prefix)), + pct: $lines.pct, + covered: $lines.covered, + count: $lines.count + }] + + + [.data[0].files[] + | select(.filename | startswith($prefix)) + | .filename as $filename + | (.functions // [])[] + | line_summary as $lines + | select($lines.count >= $min_function_lines and $lines.pct < $max_coverage) + | { + kind: "function", + name: (($filename | ltrimstr($prefix)) + "::" + (.name // "")), + pct: $lines.pct, + covered: $lines.covered, + count: $lines.count + }] + ) + | sort_by(.pct, (.count * -1), .name) + | .[:$limit] + | .[] + | [.kind, .name, .pct, .covered, .count] + | @tsv + ' "$coverage_json" \ + | awk -F '\t' ' + $1 == "file" { + printf "- %s: %.2f%% line coverage (%s/%s)\n", $2, $3, $4, $5 + } + $1 == "function" { + printf "- %s: %.2f%% function line coverage (%s/%s)\n", $2, $3, $4, $5 + } + ' +)" + +if [[ -z "$crap_indicators" ]]; then + echo "- none below ${crap_max_coverage}% coverage with current size thresholds" +else + echo "$crap_indicators" +fi + +awk -v actual="$source_line_coverage" -v minimum="$minimum_line_coverage" ' + BEGIN { + if (actual + 0 < minimum + 0) { + exit 1 + } + } +' diff --git a/scripts/ci/run-fast-checks.sh b/scripts/ci/run-fast-checks.sh index 1f5f576..1c07a9f 100755 --- a/scripts/ci/run-fast-checks.sh +++ b/scripts/ci/run-fast-checks.sh @@ -6,6 +6,7 @@ bash scripts/ci/check-shell-syntax.sh bash scripts/check-detect-secrets.sh --all-files bash scripts/check-privacy-fixtures.sh swift test +bash scripts/ci/test-coverage-indicators.sh bash scripts/ci/check-coverage.sh bash scripts/ci/run-mutation-smoke.sh swift build diff --git a/scripts/ci/test-coverage-indicators.sh b/scripts/ci/test-coverage-indicators.sh new file mode 100755 index 0000000..1cb5044 --- /dev/null +++ b/scripts/ci/test-coverage-indicators.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +fixture="$tmpdir/coverage.json" +source_prefix="$tmpdir/Sources/ICloudCLICore/" +mkdir -p "$source_prefix" + +cat >"$fixture" </dev/null +grep -F "CRAP indicators" <<<"$output" >/dev/null +grep -F "Risky.swift: 25.00% line coverage (10/40)" <<<"$output" >/dev/null +grep -F "Risky.swift::untouchedBranch(): 0.00% function line coverage (0/12)" <<<"$output" >/dev/null + +if COVERAGE_MIN_LINES=80 bash scripts/ci/report-coverage-indicators.sh "$fixture" "$source_prefix" >/tmp/icloud-cli-coverage-threshold.log 2>&1; then + echo "Expected coverage threshold failure" >&2 + cat /tmp/icloud-cli-coverage-threshold.log >&2 + exit 1 +fi + +echo "Coverage indicator tests passed"