screen-locker/scripts/check_flutter_coverage.sh
Krzysztof kuhy Rudnicki 8b0b881ec1
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Split the Flutter coverage gate into per-tree shard hooks plus a merge hook
The whole-suite check (~96 s capped) outlived the 60 s action cap. Manual-stage ids flutter-test-coverage-shard-0..3 each record their bucket's lcov under the HEAD tree hash; flutter-test-coverage-merge fails unless all four exist for the same tree, then applies the unchanged 100% bar and lib-file guard to the union. CI (--shard/--merged) is unchanged.

Claude-Session: https://claude.ai/code/session_01WzxdWYmRsPBNf6gi2LVjFA

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 18:18:25 +01:00

207 lines
8.9 KiB
Bash
Executable File

#!/usr/bin/env bash
# Enforces 100% line coverage for the Flutter workout app.
#
# Run from the repo root. Fails if:
# 1. Any lib/**/*.dart file is missing from coverage/lcov.info (unreachable
# file — the 100% would be fake without this check).
# 2. Line coverage across all lib/ files is below 100%.
#
# Usage:
# scripts/check_flutter_coverage.sh whole suite, then both checks
# scripts/check_flutter_coverage.sh --shard I/N test bucket I of N (0-based)
# only; leaves its lcov.info,
# checks nothing
# scripts/check_flutter_coverage.sh --merged N LCOV...
# union N shards' lcov files
# into coverage/lcov.info,
# then both checks
# scripts/check_flutter_coverage.sh --gate-shard I
# local-gate form of --shard:
# bucket I of GATE_SHARDS on
# a CLEAN tree, lcov kept for
# that tree hash
# scripts/check_flutter_coverage.sh --gate-merge the checks on the union of
# ALL GATE_SHARDS kept lcovs
# of the current tree
#
# Why shards: the suite took ~150 s on one 4-core runner, almost all of it
# compiling 77 test files. `flutter test --total-shards` cannot help -- it
# splits the test CASES inside every file, so each shard still compiles all
# 77 -- hence buckets of whole files, one per CI runner, and a fan-in job that
# runs the same two checks on their union.
#
# The --gate-* forms exist because one whole-suite run (~96 s under the 4 GiB
# cap) outlives the 60 s per-command limit. They are the manual-stage hooks
# flutter-test-coverage-shard-0..N-1 and flutter-test-coverage-merge, and stay
# fail-closed: a shard refuses a tree that is not exactly HEAD, files its lcov
# under that tree's hash, and the merge refuses unless every shard of the SAME
# tree is present -- so the gate stamp needs all shards green on one tree.
set -euo pipefail
APP_DIR="stronglift_replacement/workout_app"
LCOV_INFO="$APP_DIR/coverage/lcov.info"
# Number of local-gate buckets; must equal the flutter-test-coverage-shard-*
# hooks in .pre-commit-config.yaml (the merge counts them, see below).
GATE_SHARDS=4
die() {
echo "ERROR: $*" >&2
exit 1
}
# shellcheck source=flutter_coverage_lib.sh
source "$(dirname "${BASH_SOURCE[0]}")/flutter_coverage_lib.sh"
# Runs `flutter test --coverage` in the app, on the given test files (none =
# the whole suite).
run_flutter_tests() {
echo "Running flutter test --coverage ..."
# env -u GIT_DIR: git exports it to hooks, and the flutter tool shells out to
# git to identify its own SDK. With it set, flutter reads THIS repository as
# the SDK -- reporting our HEAD as the framework revision -- and pub then
# resolves every version constraint against "0.0.0-unknown" and fails.
# FLUTTER_TEST_CONCURRENCY: one test isolate per core is the default and
# peaks well over 4 GiB on this suite, which the 4 GiB resource cap
# (capped.sh) OOM-kills. Set it to 1-2 under the cap; unset keeps the default.
local concurrency=()
if [[ -n "${FLUTTER_TEST_CONCURRENCY:-}" ]]; then
concurrency=(--concurrency "$FLUTTER_TEST_CONCURRENCY")
fi
(cd "$APP_DIR" && env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE \
flutter test --coverage "${concurrency[@]}" "$@")
}
case "${1:-}" in
"")
run_flutter_tests
;;
--shard)
[[ ${2:-} =~ ^([0-9]+)/([1-9][0-9]*)$ ]] || die "--shard wants I/N, got '${2:-}'"
index=${BASH_REMATCH[1]}
total=${BASH_REMATCH[2]}
(( index < total )) || die "shard index $index is not below $total"
mapfile -t files < <(shard_files "$index" "$total")
(( ${#files[@]} > 0 )) || die "shard $index/$total has no test files"
echo "Shard $index/$total: ${#files[@]} test files"
run_flutter_tests "${files[@]}"
exit 0
;;
--gate-shard)
[[ ${2:-} =~ ^[0-9]+$ ]] || die "--gate-shard wants a shard index"
index=$2
(( index < GATE_SHARDS )) || die "shard index $index is not below $GATE_SHARDS"
require_hooks_match
require_clean_tree
tree=$(git rev-parse 'HEAD^{tree}')
dir=$(gate_dir)
# Older trees can never be stamped again; this bounds the cache.
find "$dir" -mindepth 1 -maxdepth 1 -type d ! -name "$tree" -exec rm -rf {} + 2>/dev/null || true
mkdir -p "$dir/$tree"
rm -f "$dir/$tree/shard-$index.lcov"
mapfile -t files < <(shard_files "$index" "$GATE_SHARDS")
(( ${#files[@]} > 0 )) || die "shard $index/$GATE_SHARDS has no test files"
echo "Gate shard $index/$GATE_SHARDS: ${#files[@]} test files, tree ${tree:0:12}"
rm -f "$LCOV_INFO"
run_flutter_tests "${files[@]}"
[[ -s "$LCOV_INFO" ]] || die "flutter produced no $LCOV_INFO"
# The tests must have left the tree untouched, or the lcov is not "of" it.
require_clean_tree
[[ $(git rev-parse 'HEAD^{tree}') == "$tree" ]] || die "HEAD moved while the shard ran"
cp "$LCOV_INFO" "$dir/$tree/shard-$index.lcov.tmp"
mv -f "$dir/$tree/shard-$index.lcov.tmp" "$dir/$tree/shard-$index.lcov"
echo "Recorded shard $index for tree ${tree:0:12}."
exit 0
;;
--gate-merge)
require_hooks_match
require_clean_tree
tree=$(git rev-parse 'HEAD^{tree}')
dir=$(gate_dir)/$tree
shard_lcovs=()
for (( i = 0; i < GATE_SHARDS; i++ )); do
[[ -s "$dir/shard-$i.lcov" ]] \
|| die "no lcov for shard $i of tree ${tree:0:12} -- run hook flutter-test-coverage-shard-$i first."
shard_lcovs+=("$dir/shard-$i.lcov")
done
merge_lcov "$LCOV_INFO" "${shard_lcovs[@]}"
;;
--merged)
[[ ${2:-} =~ ^[1-9][0-9]*$ ]] || die "--merged wants a shard count first"
expected=$2
shift 2
# A shard whose artifact went missing would quietly shrink the union.
(( $# == expected )) || die "expected $expected shard lcov files, got $#"
merge_lcov "$LCOV_INFO" "$@"
;;
*)
die "unknown argument '$1' (see the usage at the top of $0)"
;;
esac
if [[ ! -f "$LCOV_INFO" ]]; then
echo "ERROR: $LCOV_INFO not found — did flutter test run?" >&2
exit 1
fi
# ── Guard: every lib/**/*.dart must appear in the coverage report ──────────────
# Flutter only reports files that are transitively imported by a test.
# Unreferenced files silently score 100% by absence — catch that here.
#
# Exception: a file whose only content is a conditional `export` (a
# platform-gate barrel like google_platform.dart, re-exporting google_platform_io.dart
# or google_platform_web.dart per dart.library.js_interop) has zero executable
# statements. Dart's coverage instrumentation never emits an SF: entry for such
# a file regardless of whether it was imported -- there is no "silently 100% by
# absence" for it to hide, since there is nothing to cover. Importing it (even
# transitively) is what makes its *resolved* sibling appear in lcov instead.
barrel_pattern="^(library;)?[[:space:]]*export[[:space:]]+'[^']+'[[:space:]]+if[[:space:]]*\([^)]+\)[[:space:]]+'[^']+';[[:space:]]*\$"
missing=0
while IFS= read -r dart_file; do
rel="${dart_file#"$APP_DIR"/}"
# Strip //-comments, ///-doc-comments and blank lines, then collapse
# whitespace, so a wrapped `export 'x' if (...) 'y';` statement (the
# platform-gate barrel shape) becomes matchable on one logical line
# regardless of how it's line-wrapped in source.
code_only=$(grep -vE '^[[:space:]]*(///|//|$)' "$dart_file" | tr '\n' ' ' | tr -s ' ')
if [[ $code_only =~ $barrel_pattern ]]; then
continue
fi
if ! grep -qF "SF:$rel" "$LCOV_INFO" && \
! grep -qF "SF:lib/${rel#lib/}" "$LCOV_INFO"; then
echo "MISSING FROM COVERAGE: $dart_file" >&2
missing=1
fi
done < <(find "$APP_DIR/lib" -name "*.dart" ! -path "*/generated/*")
if [[ $missing -eq 1 ]]; then
echo ""
echo "ERROR: Some lib/ files are not covered. Import them in a test." >&2
exit 1
fi
# ── Compute line coverage percentage from lcov.info ───────────────────────────
total_found=0
total_hit=0
while IFS= read -r line; do
if [[ $line == LF:* ]]; then
total_found=$(( total_found + ${line#LF:} ))
elif [[ $line == LH:* ]]; then
total_hit=$(( total_hit + ${line#LH:} ))
fi
done < "$LCOV_INFO"
if [[ $total_found -eq 0 ]]; then
echo "ERROR: No line coverage data found in $LCOV_INFO" >&2
exit 1
fi
if [[ $total_hit -lt $total_found ]]; then
missed=$(( total_found - total_hit ))
echo "ERROR: Line coverage is not 100% — $missed/$total_found lines not covered." >&2
echo "Run: flutter test --coverage && genhtml coverage/lcov.info -o coverage/html" >&2
exit 1
fi
echo "Coverage OK: $total_hit/$total_found lines covered (100%)."