From 8b0b881ec16bb02162f4827d8bdf2836e405d30e Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 1 Nov 2026 18:18:25 +0100 Subject: [PATCH] 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) --- .pre-commit-config.yaml | 42 +++++++++- scripts/check_flutter_coverage.sh | 126 ++++++++++++++---------------- scripts/flutter_coverage_lib.sh | 94 ++++++++++++++++++++++ 3 files changed, 192 insertions(+), 70 deletions(-) create mode 100644 scripts/flutter_coverage_lib.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f30ed6e..aa22932 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -272,12 +272,46 @@ repos: language: system files: '^stronglift_replacement/workout_app/.*\.(dart|yaml)$' pass_filenames: false - - id: flutter-test-coverage - name: flutter test --coverage 100% (workout app) - entry: bash scripts/check_flutter_coverage.sh + - id: flutter-test-coverage-shard-0 + name: flutter test bucket 0/4 (workout app, local gate) + entry: bash scripts/check_flutter_coverage.sh --gate-shard 0 language: system - files: '^stronglift_replacement/workout_app/.*\.dart$' pass_filenames: false + always_run: true + require_serial: true + stages: [manual] + - id: flutter-test-coverage-shard-1 + name: flutter test bucket 1/4 (workout app, local gate) + entry: bash scripts/check_flutter_coverage.sh --gate-shard 1 + language: system + pass_filenames: false + always_run: true + require_serial: true + stages: [manual] + - id: flutter-test-coverage-shard-2 + name: flutter test bucket 2/4 (workout app, local gate) + entry: bash scripts/check_flutter_coverage.sh --gate-shard 2 + language: system + pass_filenames: false + always_run: true + require_serial: true + stages: [manual] + - id: flutter-test-coverage-shard-3 + name: flutter test bucket 3/4 (workout app, local gate) + entry: bash scripts/check_flutter_coverage.sh --gate-shard 3 + language: system + pass_filenames: false + always_run: true + require_serial: true + stages: [manual] + # Fails unless every shard above passed on THIS tree, then applies the + # unchanged 100% bar and every-lib-file guard to their union. + - id: flutter-test-coverage-merge + name: flutter coverage 100% (union of the 4 buckets) + entry: bash scripts/check_flutter_coverage.sh --gate-merge + language: system + pass_filenames: false + always_run: true require_serial: true stages: [manual] diff --git a/scripts/check_flutter_coverage.sh b/scripts/check_flutter_coverage.sh index 06411e4..ccd52b0 100755 --- a/scripts/check_flutter_coverage.sh +++ b/scripts/check_flutter_coverage.sh @@ -15,22 +15,43 @@ # 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() { @@ -50,72 +71,6 @@ run_flutter_tests() { flutter test --coverage "${concurrency[@]}" "$@") } -# Prints bucket INDEX of TOTAL: every TOTAL-th test file, in a fixed sort -# order, so the N buckets partition the suite exactly. -shard_files() { - local index=$1 total=$2 i=0 file - while IFS= read -r file; do - if (( i % total == index )); then - printf '%s\n' "$file" - fi - i=$(( i + 1 )) - done < <(cd "$APP_DIR" && find test -name '*_test.dart' | LC_ALL=C sort) -} - -# Writes to OUT the union of the given lcov files: per source file, every -# line any shard instrumented, hit if any shard hit it -- which is what one -# unsharded run reports, since flutter merges its test isolates the same way. -# LF/LH are recomputed from that union rather than summed, because a lib file -# loaded by two shards would otherwise count its lines twice. -merge_lcov() { - local out=$1 file line sf="" rest ln hits key lf lh - shift - local -A hit=() lines_of=() seen=() - local -a order=() - for file in "$@"; do - while IFS= read -r line; do - case $line in - SF:*) - sf=${line#SF:} - if [[ -z ${seen[$sf]:-} ]]; then - seen[$sf]=1 - order+=("$sf") - fi - ;; - DA:*) - rest=${line#DA:} - ln=${rest%%,*} - rest=${rest#*,} - hits=${rest%%,*} - key="$sf|$ln" - if [[ -z ${hit[$key]:-} ]]; then - hit[$key]=$hits - lines_of[$sf]+="$ln " - else - hit[$key]=$(( hit[$key] + hits )) - fi - ;; - esac - done < "$file" - done - mkdir -p "$(dirname "$out")" - for sf in "${order[@]}"; do - echo "SF:$sf" - lf=0 - lh=0 - for ln in ${lines_of[$sf]:-}; do - echo "DA:$ln,${hit[$sf|$ln]}" - lf=$(( lf + 1 )) - if (( hit[$sf|$ln] > 0 )); then - lh=$(( lh + 1 )) - fi - done - echo "LF:$lf" - echo "LH:$lh" - echo "end_of_record" - done > "$out" -} - case "${1:-}" in "") run_flutter_tests @@ -131,6 +86,45 @@ case "${1:-}" in 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 diff --git a/scripts/flutter_coverage_lib.sh b/scripts/flutter_coverage_lib.sh new file mode 100644 index 0000000..aa68841 --- /dev/null +++ b/scripts/flutter_coverage_lib.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Helpers for check_flutter_coverage.sh (sourced, never run): test bucketing, +# lcov union, and the clean-tree guards of the local-gate shard hooks. Split out +# to keep both files under the 250-line cap. Expects APP_DIR, GATE_SHARDS and +# die() from the caller. + +# Prints bucket INDEX of TOTAL: every TOTAL-th test file, in a fixed sort +# order, so the N buckets partition the suite exactly. +shard_files() { + local index=$1 total=$2 i=0 file + while IFS= read -r file; do + if (( i % total == index )); then + printf '%s\n' "$file" + fi + i=$(( i + 1 )) + done < <(cd "$APP_DIR" && git ls-files -- 'test/*_test.dart' | LC_ALL=C sort) +} + +# Gate helpers ----------------------------------------------------------------- + +gate_dir() { + printf '%s/coverage-shards\n' "$(git rev-parse --git-dir)" +} + +# Refuses unless the working tree is exactly HEAD: the stamp records HEAD's +# tree, so the tests must have seen nothing else. Untracked files are ignored +# on purpose (as gate_stamp.sh does); shard_files only lists tracked tests. +require_clean_tree() { + [[ -z "$(git status --porcelain --untracked-files=no)" ]] \ + || die "working tree differs from HEAD -- commit first, then run the gate." +} + +# Ensures the hook list in .pre-commit-config.yaml has GATE_SHARDS shards, so +# raising/lowering the constant alone cannot silently drop a bucket. +require_hooks_match() { + local n + n=$(grep -c '^ - id: flutter-test-coverage-shard-' .pre-commit-config.yaml || true) + (( n == GATE_SHARDS )) \ + || die ".pre-commit-config.yaml declares $n flutter-test-coverage-shard-* hooks, GATE_SHARDS is $GATE_SHARDS" +} + +# Writes to OUT the union of the given lcov files: per source file, every +# line any shard instrumented, hit if any shard hit it -- which is what one +# unsharded run reports, since flutter merges its test isolates the same way. +# LF/LH are recomputed from that union rather than summed, because a lib file +# loaded by two shards would otherwise count its lines twice. +merge_lcov() { + local out=$1 file line sf="" rest ln hits key lf lh + shift + local -A hit=() lines_of=() seen=() + local -a order=() + for file in "$@"; do + while IFS= read -r line; do + case $line in + SF:*) + sf=${line#SF:} + if [[ -z ${seen[$sf]:-} ]]; then + seen[$sf]=1 + order+=("$sf") + fi + ;; + DA:*) + rest=${line#DA:} + ln=${rest%%,*} + rest=${rest#*,} + hits=${rest%%,*} + key="$sf|$ln" + if [[ -z ${hit[$key]:-} ]]; then + hit[$key]=$hits + lines_of[$sf]+="$ln " + else + hit[$key]=$(( hit[$key] + hits )) + fi + ;; + esac + done < "$file" + done + mkdir -p "$(dirname "$out")" + for sf in "${order[@]}"; do + echo "SF:$sf" + lf=0 + lh=0 + for ln in ${lines_of[$sf]:-}; do + echo "DA:$ln,${hit[$sf|$ln]}" + lf=$(( lf + 1 )) + if (( hit[$sf|$ln] > 0 )); then + lh=$(( lh + 1 )) + fi + done + echo "LF:$lf" + echo "LH:$lh" + echo "end_of_record" + done > "$out" +}