Files
ironclaw/.github/scripts/test-pr-labeler.sh
Illia Polosukhin af0b9de2f9 chore(ci): dev metrics + composition mass ratchet gate (#6167)
* chore(ci): dev metrics + composition mass ratchet gate

Adds a three-tier development-metrics tool and a guardrail that stops the
ironclaw_reborn_composition crate from accreting more of the codebase.

scripts/dev_metrics.py — three tiers from git + GitHub + working tree:
  - Tier 1 flow/speed: PR lead time, size distribution, merge cadence
  - Tier 2 quality/stability: change-failure proxy, rework, test share
  - Tier 3 codebase health: composition mass, v1 src burndown, file sprawl,
    abstraction density, boundary-test coverage

Composition mass ratchet — the dependency-boundary tests police edges
*between* crates but are blind to mass piling up *inside* one crate.
ironclaw_reborn_composition is charter-bound to assembly-only wiring yet is
now ~26.7% of all production crate code. This gate is that missing guard:
  - scripts/ci/composition-budget.toml — committed ceiling (enforce +
    tolerance), modeled on the existing coverage-floor ratchet
  - scripts/ci/check-composition-budget.sh — pure-bash gate; one-directional
    (fails only on growth past the ceiling), emits a down-ratchet nudge as
    carve-outs free up slack
  - scripts/ci/test-check-composition-budget.sh — 22 assertions / 10 fixture
    cases incl. a guard that the real tree passes the committed budget

Wiring:
  - CI: new composition-budget job in code_style.yml (runs the gate + self-
    tests it, registered in the aggregating code-style gate)
  - Local: pre-commit-safety.sh runs the gate when composition or the gate
    itself is staged; dev-setup.sh install message updated

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ci): address review — production-only metric, script hardening, dev-metrics tests

Review feedback on #6167 (gemini, ironloopai, coderabbit):

Blocking — gate counted test-only code despite its documented "tests
excluded" contract. Exclude test-only FILES (tests.rs/test_*.rs/*_tests.rs
and /tests/ dirs) from both numerator and denominator; rebaseline the
ceiling 2670 -> 2398 bp (26.70% -> 23.98%). Inline #[cfg(test)] modules
remain a documented, symmetric residual (a line-counter can't parse them).
Added a regression case proving test files are excluded.

check-composition-budget.sh: toml_get no longer aborts under set -e +
pipefail when a key is missing (|| true) so schema validation is reached;
added a missing-key regression case.

test-check-composition-budget.sh: set -euo pipefail (repo invariant);
SIGPIPE-safe capture + fixture generation; pure-bash asserts (no pipes).

dev_metrics.py: bound `gh` with a 30s timeout and treat non-JSON output as
unavailable; fix the trait-impl density regex to count `impl<T> ... for`
generics; harden find/grep/wc probes with pipefail + rc checks (no more
false-zero metrics); UTF-8 file writes; surface the gate-aligned production
share as the ratchet metric and relabel the byte-based trend as a distinct,
coarser measurement; extract a pure classify_commit helper.

New scripts/test_dev_metrics.py — caller-level unit tests for
classification, percentiles, change-failure bucketing, rendering, and the
test-file/impl regexes; wired into the composition-budget CI job.

pre-commit hook: trigger on any staged crates/**.rs change (the metric is a
ratio) and document the working-tree/CI-authoritative limitation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ci): harden PR classifier against transient GitHub API flakes

The classify job (#6167 CI) failed with `invalid character '<' looking
for beginning of value`: a transient API error returned an HTML page,
`gh --jq` aborted, and under `set -e` the whole labels-only job failed
and blocked the PR.

pr-labeler.sh now:
- routes every gh call through a `gh_retry` wrapper (retry + linear
  backoff), and
- treats each classifier as best-effort — a step that still can't fetch
  after retries only emits a `::warning::` and the script exits 0, so
  labeling never gates a merge.

Two bash traps fixed along the way, both caught by the new test:
- a bare `if cmd; then …; fi` resets `$?` to 0 after `fi`, so gh_retry's
  give-up looked like success — capture rc in the `else`;
- `set -e` is suppressed inside a function on the left of `||`, so the
  classifiers check their own fetches explicitly instead of relying on
  errexit.

Regression test: .github/scripts/test-pr-labeler.sh (retry/backoff,
give-up, and end-to-end non-fatal + happy-path via a fake `gh`), wired
into the code_style "Static-check self-tests" step and the has_code
path filter so it runs when the labeler or its test changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ci): add dispatch (Arc<dyn>) ratchet + dev-metrics dispatch signals

Companion to the mass ratchet for the "reduce traits & dispatch" goal
(#6168 / runtime-decomposition plan #4471).

check-composition-budget.sh now enforces TWO metrics: composition's share of
production crate code (existing) AND its Arc<dyn> dispatch count. The dispatch
count is scoped to composition production files EXCLUDING src/slack and
src/extension_host — those are owned by the separate channel/extension
refactor, so this gate must not govern or trip on their work. One-directional
like the mass ratchet: only trips on growth; nudges when slack accrues.

composition-budget.toml: arc_dyn_ceiling = 1093 (current governed count),
tolerance 15.

test harness: +6 dispatch cases (within / breach / dry-run / slack+extension
exclusion / missing-key schema error); budget() helper carries the dispatch
keys; count_arc_dyn tolerates no-match under set -e + pipefail. 36 cases pass.

dev_metrics.py: Tier-3 reports governed Arc<dyn> count and distinct dyn-trait
count (the dispatch-breadth trend), matching the ratchet scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 05:34:29 +01:00

198 lines
8.2 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Regression tests for pr-labeler.sh — the PR size/risk/contributor classifier.
#
# Standalone: bash .github/scripts/test-pr-labeler.sh
# Also run in CI (.github/workflows/code_style.yml, "Static-check self-tests")
# whenever the labeler or this test changes — guardrails are code
# (.claude/rules/review-discipline.md: "Checks and hooks need regression tests
# ... and must run when their own files change").
#
# Pins the #6167-class flake: a transient GitHub API error returns an HTML
# page, `gh --jq` aborts with `invalid character '<'`, and under `set -e` the
# whole classify job used to fail and block the PR over labels-only work.
# Covers (1) gh_retry's retry/backoff and give-up behavior, and (2) the
# end-to-end guarantee that a persistently failing API still exits 0.
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
labeler="${script_dir}/pr-labeler.sh"
PASS=0
FAIL=0
assert_rc() {
local name="$1" want="$2" got="$3"
if [ "${got}" -eq "${want}" ]; then PASS=$((PASS+1));
else FAIL=$((FAIL+1)); echo "FAIL: ${name} — expected exit ${want}, got ${got}"; fi
}
assert_rc_nonzero() {
local name="$1" got="$2"
if [ "${got}" -ne 0 ]; then PASS=$((PASS+1));
else FAIL=$((FAIL+1)); echo "FAIL: ${name} — expected non-zero exit, got 0"; fi
}
# Pure-bash substring match — no pipes, so immune to SIGPIPE under pipefail.
assert_contains() {
local name="$1" hay="$2" needle="$3"
if [[ "${hay}" == *"${needle}"* ]]; then PASS=$((PASS+1));
else FAIL=$((FAIL+1)); echo "FAIL: ${name} — output missing: ${needle}"; echo "----"; echo "${hay}"; echo "----"; fi
}
assert_not_contains() {
local name="$1" hay="$2" needle="$3"
if [[ "${hay}" != *"${needle}"* ]]; then PASS=$((PASS+1));
else FAIL=$((FAIL+1)); echo "FAIL: ${name} — output should NOT contain: ${needle}"; echo "----"; echo "${hay}"; echo "----"; fi
}
assert_eq() {
local name="$1" want="$2" got="$3"
if [ "${got}" = "${want}" ]; then PASS=$((PASS+1));
else FAIL=$((FAIL+1)); echo "FAIL: ${name} — expected '${want}', got '${got}'"; fi
}
tmp="$(mktemp -d)"
trap 'rm -rf "${tmp}"' EXIT
# ---------------------------------------------------------------------------
# Unit tests: gh_retry (source the labeler so main() does not run).
# ---------------------------------------------------------------------------
# shellcheck source=/dev/null
source "${labeler}"
# A command that fails (exit 1) until it has been called more than $2 times,
# tracking the count in file $1. On success it prints a recognizable value.
flaky="${tmp}/flaky.sh"
cat > "${flaky}" <<'EOF'
#!/usr/bin/env bash
count_file="$1"; fail_until="$2"
n=$(cat "${count_file}" 2>/dev/null || echo 0); n=$((n+1)); echo "${n}" > "${count_file}"
if (( n <= fail_until )); then echo "boom ${n}" >&2; exit 1; fi
echo "value-after-${n}"
EOF
chmod +x "${flaky}"
# 1. Immediate success — value forwarded, rc 0, exactly one call.
cf="${tmp}/c1"; : > "${cf}"
rc=0; out=$(GH_RETRY_SLEEP=0 GH_RETRY_ATTEMPTS=4 gh_retry "${flaky}" "${cf}" 0) || rc=$?
assert_rc "gh_retry immediate success rc" 0 "${rc}"
assert_contains "gh_retry immediate success value" "${out}" "value-after-1"
assert_eq "gh_retry immediate success call count" "1" "$(cat "${cf}")"
# 2. Transient failures then success — retries until it works.
cf="${tmp}/c2"; : > "${cf}"
rc=0; out=$(GH_RETRY_SLEEP=0 GH_RETRY_ATTEMPTS=4 gh_retry "${flaky}" "${cf}" 2) || rc=$?
assert_rc "gh_retry transient rc" 0 "${rc}"
assert_contains "gh_retry transient value" "${out}" "value-after-3"
assert_eq "gh_retry transient call count" "3" "$(cat "${cf}")"
# 3. Persistent failure — gives up after GH_RETRY_ATTEMPTS and returns non-zero.
cf="${tmp}/c3"; : > "${cf}"
rc=0; out=$(GH_RETRY_SLEEP=0 GH_RETRY_ATTEMPTS=3 gh_retry "${flaky}" "${cf}" 99) || rc=$?
assert_rc_nonzero "gh_retry give-up rc" "${rc}"
assert_eq "gh_retry give-up call count" "3" "$(cat "${cf}")"
# ---------------------------------------------------------------------------
# End-to-end tests: run the labeler as a subprocess with a fake `gh` on PATH.
# ---------------------------------------------------------------------------
fakebin="${tmp}/bin"
mkdir -p "${fakebin}"
cat > "${fakebin}/gh" <<'EOF'
#!/usr/bin/env bash
# Fake gh for pr-labeler tests. Env knobs:
# FAKE_GH_FAIL_UNTIL / FAKE_GH_COUNT_FILE : fail (HTML-ish, exit 1) for the
# first N total invocations, simulating a transient API outage.
# FAKE_GH_CHANGES : total changed lines (size endpoint)
# FAKE_GH_FILES : newline-separated changed paths (risk endpoint)
# FAKE_GH_AUTHOR : PR author login
# FAKE_GH_MERGED : merged-PR count
# FAKE_GH_EDIT_LOG : append add/remove-label actions here
args="$*"
if [[ -n "${FAKE_GH_FAIL_UNTIL:-}" ]]; then
n=$(cat "${FAKE_GH_COUNT_FILE}" 2>/dev/null || echo 0); n=$((n+1)); echo "${n}" > "${FAKE_GH_COUNT_FILE}"
if (( n <= FAKE_GH_FAIL_UNTIL )); then
echo "invalid character '<' looking for beginning of value" >&2
exit 1
fi
fi
case "${args}" in
*"pr view"*"--json labels"*)
cat "${FAKE_GH_LABELS_FILE:-/dev/null}" 2>/dev/null || true
;;
*"pulls/"*"/files"*)
if [[ "${args}" == *".changes"* ]]; then echo "${FAKE_GH_CHANGES:-0}";
else printf '%s\n' "${FAKE_GH_FILES:-}"; fi
;;
*"search/issues"*)
echo "${FAKE_GH_MERGED:-0}"
;;
*"pr edit"*"--add-label"*)
echo "add: ${args##*--add-label }" >> "${FAKE_GH_EDIT_LOG:-/dev/null}"
echo "https://github.com/o/r/pull/1"
;;
*"pr edit"*"--remove-label"*)
echo "remove: ${args##*--remove-label }" >> "${FAKE_GH_EDIT_LOG:-/dev/null}"
;;
*"api "*"pulls/"*)
echo "${FAKE_GH_AUTHOR:-octocat}"
;;
*)
echo "fake gh: unhandled args: ${args}" >&2; exit 2
;;
esac
EOF
chmod +x "${fakebin}/gh"
# 4. THE regression: a persistently failing API must NOT block the PR — the
# script warns and still exits 0.
rc=0
out=$(PATH="${fakebin}:${PATH}" GH_RETRY_SLEEP=0 GH_RETRY_ATTEMPTS=2 \
PR_NUMBER=1 REPO=o/r \
FAKE_GH_FAIL_UNTIL=9999 FAKE_GH_COUNT_FILE="${tmp}/e2e_fail_count" \
bash "${labeler}" 2>&1) || rc=$?
assert_rc "non-fatal: exits 0 on total API outage" 0 "${rc}"
assert_contains "non-fatal: warns on size step" "${out}" "size step failed"
assert_contains "non-fatal: does not block" "${out}" "Not blocking the PR"
assert_contains "non-fatal: reaches Done" "${out}" "Done."
# 5. Happy path: classification still works and labels get applied.
editlog="${tmp}/editlog"; : > "${editlog}"
rc=0
out=$(PATH="${fakebin}:${PATH}" GH_RETRY_SLEEP=0 GH_RETRY_ATTEMPTS=2 \
PR_NUMBER=1 REPO=o/r \
FAKE_GH_CHANGES=1089 FAKE_GH_FILES="src/agent/mod.rs" \
FAKE_GH_AUTHOR=ilblackdragon FAKE_GH_MERGED=369 \
FAKE_GH_EDIT_LOG="${editlog}" \
bash "${labeler}" 2>&1) || rc=$?
assert_rc "happy path: exits 0" 0 "${rc}"
assert_contains "happy path: size XL" "${out}" "size: XL"
assert_contains "happy path: risk medium" "${out}" "Risk: medium"
assert_contains "happy path: contributor core" "${out}" "contributor: core"
assert_contains "happy path: size label applied" "$(cat "${editlog}")" "add: size: XL"
assert_contains "happy path: contributor label applied" "$(cat "${editlog}")" "add: contributor: core"
# 6. Transient-then-recover end-to-end: fails the first few calls, then the
# retries carry it through to a full, correct classification (exit 0).
editlog2="${tmp}/editlog2"; : > "${editlog2}"
rc=0
out=$(PATH="${fakebin}:${PATH}" GH_RETRY_SLEEP=0 GH_RETRY_ATTEMPTS=5 \
PR_NUMBER=1 REPO=o/r \
FAKE_GH_FAIL_UNTIL=2 FAKE_GH_COUNT_FILE="${tmp}/e2e_recover_count" \
FAKE_GH_CHANGES=1089 FAKE_GH_FILES="src/agent/mod.rs" \
FAKE_GH_AUTHOR=ilblackdragon FAKE_GH_MERGED=369 \
FAKE_GH_EDIT_LOG="${editlog2}" \
bash "${labeler}" 2>&1) || rc=$?
assert_rc "transient recover: exits 0" 0 "${rc}"
assert_contains "transient recover: size XL" "${out}" "size: XL"
assert_contains "transient recover: contributor core" "${out}" "contributor: core"
assert_not_contains "transient recover: no step-failed warning" "${out}" "step failed"
# ---------------------------------------------------------------------------
echo ""
echo "pr-labeler tests: ${PASS} passed, ${FAIL} failed"
[ "${FAIL}" -eq 0 ]