mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-02 23:56:24 +08:00
* 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>
155 lines
6.0 KiB
Python
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())
|