Files
ironclaw/scripts/test_dev_metrics.py
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

155 lines
6.0 KiB
Python

#!/usr/bin/env python3
"""Unit tests for the pure logic in dev_metrics.py.
Covers the parsing/classification/aggregation/rendering that carried the
review-flagged bugs (conventional-commit classification, percentile math,
change-failure bucketing, Markdown rendering, and the test-file regex kept in
sync with the ratchet gate). Runs with no git/network — imports the module and
exercises functions directly.
python3 scripts/test_dev_metrics.py # standalone
pytest scripts/test_dev_metrics.py # or via pytest
"""
import os
import re
import sys
from datetime import datetime, timezone, timedelta
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import dev_metrics as dm # noqa: E402
def test_classify_commit_types():
assert dm.classify_commit("feat(reborn): x (#10)")["type"] == "feat"
assert dm.classify_commit("fix: y")["type"] == "fix"
assert dm.classify_commit("feat(reborn)!: breaking (#11)")["type"] == "feat"
assert dm.classify_commit("chore(ci): z")["type"] == "chore"
assert dm.classify_commit("random prose commit")["type"] is None
def test_classify_commit_pr_detection():
assert dm.classify_commit("fix: y (#6130)")["is_pr"] is True
assert dm.classify_commit("fix: y")["is_pr"] is False
# trailing text after the paren means it is not a squash-merge subject
assert dm.classify_commit("fix: y (#6130) follow")["is_pr"] is False
def test_classify_commit_revert_signals():
assert dm.classify_commit("revert: bad change")["is_revert"] is True
assert dm.classify_commit("Revert \"feat: x\"")["is_revert"] is True
assert dm.classify_commit("fix: undoes #5902 loop")["is_revert"] is True
assert dm.classify_commit("fix: reverts #10")["is_revert"] is True
assert dm.classify_commit("feat: normal")["is_revert"] is False
def test_pct_percentiles():
vals = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
assert dm.pct([], 0.5) == 0.0
assert dm.pct([42], 0.9) == 42
assert abs(dm.pct(vals, 0.5) - 5.5) < 1e-9
assert dm.pct(vals, 1.0) == 10
assert dm.pct(vals, 0.0) == 1
def _commit(subj, days_ago, now):
return {"hash": "h", "date": now - timedelta(days=days_ago),
"subj": subj, **dm.classify_commit(subj)}
def test_tier2_change_failure_math():
now = datetime(2026, 7, 16, tzinfo=timezone.utc)
commits = [
_commit("feat: a (#1)", 1, now),
_commit("feat: b (#2)", 2, now),
_commit("fix: c (#3)", 3, now),
_commit("fix: d (#4)", 4, now),
_commit("fix: e (#5)", 5, now),
_commit("refactor: f (#6)", 6, now),
]
t2 = dm.tier2(commits, now)
# fix / (feat+fix+refactor+perf) = 3 / (2+3+1+0) = 50.0%
assert t2["overall_change_failure_pct"] == 50.0
assert t2["overall_fix_per_feat"] == 1.5
assert t2["type_totals"]["fix"] == 3
# all within one 30-day window
assert t2["by_period_30d"][0]["change_failure_pct"] == 50.0
def test_tier2_revert_count():
now = datetime(2026, 7, 16, tzinfo=timezone.utc)
commits = [
_commit("fix: undoes #99", 1, now),
_commit("revert: nope", 2, now),
_commit("feat: fine", 3, now),
]
assert dm.tier2(commits, now)["overall_reverts"] == 2
def test_render_md_smoke():
now = datetime(2026, 7, 16, tzinfo=timezone.utc)
t1 = {"prs_merged_7d": 1, "prs_merged_30d": 2, "prs_merged_60d": 3,
"prs_merged_90d": 4, "prs_total_history": 5, "gh_pr_sample": None}
t2 = dm.tier2([_commit("fix: x (#1)", 1, now)], now)
t3 = {"crate_count": 69, "composition_share_gate_pct": 23.98,
"composition_kloc_now": 156.4, "v1_src_kloc_now": 290.0,
"crates_kloc_now": 652.0, "trait_defs": 369, "trait_impls_for": 2882,
"impls_per_trait": 7.81, "composition_arc_dyn": 1093,
"composition_dyn_types": 259, "files_over_1500": 164, "files_over_3000": 56,
"boundary_test_count": 34, "arch_exempt_allows": 101,
"size_trend": [{"date": "2026-07-16", "composition_kloc": 156.4,
"composition_byte_share_pct": 20.2, "v1_src_kloc": 268.5,
"crates_kloc": 973.2}],
"biggest_files": [(17371, "a.rs")]}
md = dm.render_md(t1, t2, t3, now)
assert "Tier 1 — Flow / Speed" in md
assert "composition share (ratchet metric)" in md
assert "23.98%" in md
# dispatch signal must render
assert "composition Arc<dyn> (governed, ratchet)" in md
assert "1093" in md
# the byte trend must be explicitly disclaimed as not the ratchet metric
assert "NOT the ratchet metric" in md
def test_test_file_regex_matches_gate():
# Kept in sync with TEST_FILE_RE in check-composition-budget.sh.
rx = re.compile(dm.TEST_FILE_RE)
for p in ["crates/x/src/tests.rs", "crates/x/src/foo_tests.rs",
"crates/x/src/foo_test.rs", "crates/x/src/test_support.rs",
"crates/x/src/runtime/tests/a.rs"]:
assert rx.search(p), f"should match test file: {p}"
for p in ["crates/x/src/lib.rs", "crates/x/src/runtime.rs",
"crates/x/src/greatest.rs"]:
assert not rx.search(p), f"should NOT match prod file: {p}"
def test_impl_regex_counts_generics():
# The tier3 impl-density regex must count both plain and generic impls.
rx = re.compile(r"^[[:space:]]*impl(<[^>]*>)?[[:space:]]+[A-Za-z0-9_:<>]+ for ".replace(
"[[:space:]]", r"\s"))
assert rx.search("impl Foo for Bar {")
assert rx.search("impl<T> Foo for Bar<T> {")
assert rx.search(" impl<T: Clone> Foo for Bar {")
def main() -> int:
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
passed = failed = 0
for t in tests:
try:
t()
passed += 1
except AssertionError as e:
failed += 1
print(f"FAIL: {t.__name__}: {e}")
except Exception as e: # noqa: BLE001
failed += 1
print(f"ERROR: {t.__name__}: {type(e).__name__}: {e}")
print(f"\ndev_metrics unit tests: {passed} passed, {failed} failed")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())