mirror of
https://github.com/boshu2/agentops.git
synced 2026-09-14 15:08:13 +08:00
39f0850640
## Summary Adds a blocking T1 CI gate that ratchets test coverage in `cli/internal/` packages: once tests are added, a PR cannot delete them without a deliberate, recorded justification. Motivated by PR #558–#561, which added 248 tests across 6 internal packages with nothing preventing a future PR from silently removing them. - `scripts/check-test-count-regression.sh` — for each `cli/internal/<pkg>` with a changed `_test.go` file between the PR base and HEAD, sums `^func Test` declarations per package at both refs. A net per-package decrease fails the gate **unless** a `Test-Removal-Reason:` trailer is present in a commit body in the range. Counts are summed per package (not per file), so moving a test between files in the same package is net-zero. Emergency bypass: `AGENTOPS_TEST_COUNT_NOREGRESS=skip` (logged). Fail-open when the base ref is unresolvable (shallow clone), fail-closed only on a verifiable decrease. - `tests/scripts/check-test-count-regression.bats` — 8 scenarios (add / remove-no-trailer / remove-with-trailer / rename / skip-bypass / no-op / unresolvable-base / cross-file move) driven by throwaway git repos. Auto-discovered by the `bats-tests` job. - Wired as blocking `validate-test-count-noregress` (T1): job block + `summary.needs` + `validate-pr-evidence-claims.needs` in `.github/workflows/validate.yml`, manifest entry in `docs/contracts/ci-jobs.yaml`, and regenerated `AGENTS-CI.md` (parity gate green, 66 rows). `fetch-depth: 0` so the base ref resolves for the before/after diff. Green by construction on this PR: it changes no `cli/internal/**/_test.go`, so the gate is a no-op PASS. ## Acceptance scenarios (from ag-h2z) - PR adds tests to `cli/internal/X` → gate passes (count up or stable). ✅ bats #1 - PR removes a test without a trailer → gate fails, naming the test + pkg. ✅ bats #2 - PR removes a test **with** `Test-Removal-Reason:` trailer → gate passes. ✅ bats #3 - PR renames a test (`Test_X` → `TestX`) → net count stable → gate passes. ✅ bats #4 ## Test plan - `bats tests/scripts/check-test-count-regression.bats` — 8/8 green - `shellcheck scripts/check-test-count-regression.sh` — clean - `bash scripts/validate-ci-policy-parity.sh` — PASS (66 rows) ## Follow-ups (out of scope, immutable) - Promoting `validate-test-count-noregress` to a GitHub branch-protection *required status* is repo-admin (immutable scope) — not done here. The job is blocking via `summary.needs`. Closes-scenario: ag-h2z#test-count-noregress Bounded-context: BC2-Validation Evidence: PASS: no cli/internal/*_test.go changes
132 lines
4.6 KiB
Bash
Executable File
132 lines
4.6 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# check-test-count-regression.sh
|
|
# Test-count non-regression ratchet for cli/internal/ packages (ag-h2z).
|
|
#
|
|
# For each cli/internal/<pkg> with a changed _test.go file between BASE_REF and
|
|
# HEAD, sum `^func Test` declarations across the package's _test.go files at the
|
|
# base ref and at HEAD. A net per-package decrease fails the gate unless a
|
|
# `Test-Removal-Reason:` trailer appears in any commit body in the range. Counts
|
|
# are summed per package (not per file) so moving a test between files in the
|
|
# same package is net-zero.
|
|
#
|
|
# Environment:
|
|
# BASE_REF base ref to diff against (default origin/main)
|
|
# AGENTOPS_TEST_COUNT_NOREGRESS=skip emergency bypass (logged, exit 0)
|
|
#
|
|
# Exit codes:
|
|
# 0 - pass / not applicable / skipped
|
|
# 1 - net test-count regression without a Test-Removal-Reason: trailer
|
|
#
|
|
# FAIL diagnostics go to stderr; PASS/SKIP summaries go to stdout.
|
|
|
|
if [[ "${AGENTOPS_TEST_COUNT_NOREGRESS:-}" == "skip" ]]; then
|
|
echo "SKIP: test-count non-regression gate disabled via AGENTOPS_TEST_COUNT_NOREGRESS=skip"
|
|
exit 0
|
|
fi
|
|
|
|
BASE_REF="${BASE_REF:-origin/main}"
|
|
|
|
if ! git rev-parse --git-dir >/dev/null 2>&1; then
|
|
echo "SKIP: not inside a git repository"
|
|
exit 0
|
|
fi
|
|
|
|
# Fail-open when the base ref cannot be resolved (shallow/local clone). The gate
|
|
# fails closed only on a real, verifiable net decrease.
|
|
if ! base_sha="$(git rev-parse --verify --quiet "${BASE_REF}^{commit}")"; then
|
|
echo "SKIP: base ref '${BASE_REF}' is unresolvable (shallow clone or missing fetch) — fail-open"
|
|
exit 0
|
|
fi
|
|
|
|
# Changed cli/internal _test.go files between base and HEAD (three-dot: changes
|
|
# on HEAD since the merge base, i.e. PR-diff semantics).
|
|
mapfile -t changed_test_files < <(
|
|
git diff --name-only "${base_sha}...HEAD" -- cli/internal 2>/dev/null \
|
|
| grep -E '^cli/internal/.*_test\.go$' || true
|
|
)
|
|
|
|
if [[ "${#changed_test_files[@]}" -eq 0 ]]; then
|
|
echo "PASS: no cli/internal/*_test.go changes — test-count gate not applicable"
|
|
exit 0
|
|
fi
|
|
|
|
# Unique package directories of the changed test files.
|
|
declare -A pkg_seen=()
|
|
pkgs=()
|
|
for f in "${changed_test_files[@]}"; do
|
|
pkg="$(dirname "$f")"
|
|
if [[ -z "${pkg_seen[$pkg]:-}" ]]; then
|
|
pkg_seen[$pkg]=1
|
|
pkgs+=("$pkg")
|
|
fi
|
|
done
|
|
|
|
# A single Test-Removal-Reason: trailer anywhere in the range is the deliberate
|
|
# escape hatch for the whole push.
|
|
trailer_present=0
|
|
if git log "${base_sha}..HEAD" --format='%B' 2>/dev/null \
|
|
| grep -qiE '^[[:space:]]*Test-Removal-Reason:[[:space:]]*[^[:space:]]'; then
|
|
trailer_present=1
|
|
fi
|
|
|
|
# test_names_at_ref <ref> <pkg-dir> — sorted-unique `Test*` function names across
|
|
# the package's direct _test.go files present at that ref.
|
|
test_names_at_ref() {
|
|
local ref="$1" pkg="$2" file
|
|
while IFS= read -r file; do
|
|
[[ -z "$file" ]] && continue
|
|
git show "${ref}:${file}" 2>/dev/null \
|
|
| grep -oE '^func Test[A-Za-z0-9_]*' \
|
|
| sed 's/^func //' || true
|
|
done < <(
|
|
git ls-tree -r --name-only "$ref" -- "$pkg" 2>/dev/null \
|
|
| grep -E "^${pkg}/[^/]+_test\.go$" || true
|
|
) | sort -u
|
|
}
|
|
|
|
count_lines() {
|
|
# Count non-empty lines from stdin.
|
|
grep -c . || true
|
|
}
|
|
|
|
failures=()
|
|
for pkg in "${pkgs[@]}"; do
|
|
base_names="$(test_names_at_ref "$base_sha" "$pkg")"
|
|
head_names="$(test_names_at_ref "HEAD" "$pkg")"
|
|
|
|
base_count=0
|
|
[[ -n "$base_names" ]] && base_count="$(printf '%s\n' "$base_names" | count_lines)"
|
|
head_count=0
|
|
[[ -n "$head_names" ]] && head_count="$(printf '%s\n' "$head_names" | count_lines)"
|
|
|
|
if (( head_count < base_count )); then
|
|
removed="$(comm -23 <(printf '%s\n' "$base_names") <(printf '%s\n' "$head_names") \
|
|
| sed '/^$/d' | paste -sd, - | sed 's/,/, /g')"
|
|
delta=$(( head_count - base_count ))
|
|
failures+=("${pkg}: ${base_count} -> ${head_count} (${delta} tests, removed: ${removed})")
|
|
else
|
|
echo "PASS: ${pkg}: ${base_count} -> ${head_count}"
|
|
fi
|
|
done
|
|
|
|
if [[ "${#failures[@]}" -gt 0 ]]; then
|
|
if [[ "$trailer_present" -eq 1 ]]; then
|
|
echo "PASS: net test-count decrease allowed by a Test-Removal-Reason: trailer"
|
|
printf ' %s\n' "${failures[@]}"
|
|
exit 0
|
|
fi
|
|
{
|
|
echo "FAIL: test-count regression in cli/internal/ without a Test-Removal-Reason: trailer"
|
|
printf ' %s\n' "${failures[@]}"
|
|
echo ""
|
|
echo "If this removal is deliberate, add a 'Test-Removal-Reason: <one-line>' trailer"
|
|
echo "to a commit in this branch, or set AGENTOPS_TEST_COUNT_NOREGRESS=skip to bypass."
|
|
} >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "PASS: test-count non-regression gate — no per-package regressions"
|
|
exit 0
|