Files
ironclaw/scripts/dev_metrics.py
Benjamin Kurrek ce1490dd28 ci(gates): WS10 path-keyed gate rewrites — tree-shape-agnostic discovery, fail-closed (#6946)
* ci(gates): WS10 path-keyed gate rewrites — tree-shape-agnostic discovery, fail-closed

Five CI/dev gates resolved their scope from the literal `crates/ironclaw_*`
tree shape. Each one fails SILENTLY the moment the target-architecture
restructure nests crates in family directories (`crates/<family>/ironclaw_*`,
PROPOSAL §5): the pattern stops matching, the gate scans nothing, and it
reports success. Coverage goes dark, the Reborn suite is skipped, the panic
baseline shrinks, a metric renders 0% of a 0-line codebase — all green.

Each gate now discovers crates from the tree itself and refuses to report
success on an empty scan:

- scripts/ci/reborn-coverage-merge-lcov.sh — filter derived from the crate
  inventory; an empty merged tracefile is now an error, not an exit-0 no-op.
- scripts/check_no_panics.py — shipping-closure crates are matched at any
  depth under crates/; a shipping workspace member outside the crate tree or
  without a source root is an error.
- scripts/ci/classify-test-scope.sh — crate paths are normalized to
  `crates/<crate>/...` before the case arms; an unattributable crates/ path
  is refused instead of silently bucketed legacy-only.
- scripts/dev_metrics.py — composition/architecture/denominator paths resolve
  through the crate tree instead of hardcoded globs.
- .github/workflows/reborn-e2e.yml — both scope filters are depth-independent,
  and scripts/ci/ws12_workflow_contracts.py replays a nested crate path
  through the `changes`-job regex so re-narrowing it fails loudly.

Zero behavior change on today's tree: the panic gate's stdout, discovered
roots, production/test file sets and 51 violations are unchanged (no baseline
re-keying needed); the classifier's per-path verdict is identical across all
4179 tracked files; the lcov merge is byte-identical on a 3-lane fixture;
dev_metrics tier3 is identical; the e2e filters gain 3 crates-root markdown
files and lose nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(target-architecture): tick the WS10 path-keyed-gate row

Records what #6946 landed and, on the WS0 blocking-prerequisite row, the five
same-class gates the sweep found outside the WS10 list. That box stays open:
its five named gates are done, but `code_style.yml`'s dist-build regex,
`platform-and-compat.yml`'s WASM-ABI regex, `ironclaw-stress.yml`'s push
filter, `regression-test-check.py`'s HIGH_RISK_PATTERNS, and
`build-wasm-extensions.sh`'s assets glob all still go silently green under
nested crate directories — ticking it would tell the next slot the first
family `git mv` is safe when it is not.

The same sweep turned up a live pre-existing bug, filed as #6947:
classify-test-scope.sh's `crates/ironclaw_product_*/*` arm requires a literal
`_` after "product" and so can never match the merged `crates/ironclaw_product/`
(#6583 folded the four `ironclaw_product_*` crates into it without repointing
the glob). A product-only diff therefore reports has_reborn_tests=false and the
reborn-tests roll-up passes fast having skipped all eight Reborn lanes,
`cargo test -p ironclaw_product` included. Left unfixed in #6946 because that
PR is behavior-free by mandate and this fix changes CI behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(gates): address CodeRabbit review on the WS10 path-gate rewrites

Six of nine comments taken, three declined. Each was verified against the code
before acting; nothing was accepted on the reviewer's word.

* classify-test-scope.sh — the deleted-path fallback stripped exactly one
  family segment, so a two-level layout stayed un-canonical and fell through to
  legacy-only, the misbucketing this rewrite exists to stop. Now anchored on the
  FIRST `ironclaw_` segment. The suggested `${tail##*/ironclaw_}` was not used:
  it matches greedily and folds
  `crates/f/ironclaw_events/src/ironclaw_helper.rs` down to
  `crates/ironclaw_helper.rs`. The arm is unreachable on today's flat tree, and
  the per-path differential over all 4192 tracked files is unchanged.

* test-classify-test-scope.sh — the inventory parity check re-implemented the
  classifier's `find` inline, so it compared a third copy against Python and
  would have stayed green while the classifier's own expression drifted. It now
  sources the classifier and reads its actual `crate_dirs`, and asserts the two
  discovery floors equal. Sabotage-verified: changing the classifier's prune now
  fails the test.

* ws12_workflow_contracts.py — E2E_SCOPE_REGEX only matched a one-line
  `grep -Eq`, so a guard split across an escaped-newline continuation was
  reported as missing entirely. .claude/rules/review-discipline.md requires
  guardrails to handle multiline syntax; fixed with a red-then-green regression.

* crate_tree.py — `owning_crate_directory` had zero callers and no test. Deleted
  rather than shipped as speculative public surface.

* dev_metrics.py — inventory memoized (tier3 walked the crate tree four times)
  and every shell interpolation quoted with `shlex.quote`; those paths are
  filesystem-derived now, not literal constants, and `_exact_lines` had no
  quoting at all. Tier-3 output still identical to origin/main.

* CHECKLIST WS10 row — the base-vs-after results read as one list, so the old
  silent outcomes could be mistaken for the rewrite's. Split explicitly, with
  the enforcing command or test named for each post-change claim.

Declined: the `rglob`→`os.walk` prune (the memoization above removes the
repeated walks, and the traversal is pinned by a differential test), and two
duplicate analysis-chain notes.

Follow-up tracking for the eight gates this PR does not rewrite is now #6963,
linked from the WS0 blocking-prerequisite row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:54:20 -04:00

601 lines
24 KiB
Python

#!/usr/bin/env python3
"""IronClaw development metrics — three tiers.
Tier 1 Flow / speed (DORA-style: lead time, PR size, merge cadence)
Tier 2 Quality / stability (change-failure proxy, rework, test share)
Tier 3 Codebase health (leading indicators: composition mass, v1 burndown,
file sprawl, abstraction density, boundary coverage)
All numbers are derived from git history + the GitHub API (via `gh`) + the
working tree. No external services beyond those. Safe to run read-only.
Usage:
python3 scripts/dev_metrics.py [--pr-limit N] [--out FILE]
Everything is a *trend* tool: watch direction, not absolutes.
"""
from __future__ import annotations
import argparse
import functools
import json
import os
import pathlib
import shlex
import statistics
import subprocess
import sys
from datetime import datetime, timezone, timedelta
# ----------------------------------------------------------------------------
# shell helpers
# ----------------------------------------------------------------------------
def git(*args: str) -> str:
return subprocess.run(
["git", *args], capture_output=True, text=True, check=True
).stdout
def try_gh(*args: str) -> str | None:
try:
r = subprocess.run(
["gh", *args],
capture_output=True,
text=True,
check=False,
timeout=30, # gh can block on network/auth failures; treat as unavailable
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
if r.returncode != 0:
return None
return r.stdout
def sh(cmd: str, ok: tuple[int, ...] = (0,)) -> str:
"""Run a bash pipeline with pipefail; warn (never silently zero) on a
non-ok exit so a failed find/grep/cat surfaces instead of faking a metric."""
r = subprocess.run(
["bash", "-c", f"set -o pipefail; {cmd}"],
capture_output=True, text=True,
)
if r.returncode not in ok:
print(f"[warn] metric probe rc={r.returncode}: {cmd[:70]}", file=sys.stderr)
return r.stdout
def pct(values: list[float], p: float) -> float:
if not values:
return 0.0
s = sorted(values)
k = (len(s) - 1) * p
lo, hi = int(k), min(int(k) + 1, len(s) - 1)
return s[lo] + (s[hi] - s[lo]) * (k - lo)
# ----------------------------------------------------------------------------
# commit ingestion (whole history)
# ----------------------------------------------------------------------------
CONV_TYPES = {
"feat", "fix", "test", "docs", "refactor", "perf", "chore",
"ci", "build", "style", "revert",
}
# types that represent *product change* (denominator for change-failure rate)
PRODUCT_CHANGE = {"feat", "fix", "refactor", "perf"}
def classify_commit(subj: str) -> dict:
"""Pure classification of a commit subject (no git) — unit-testable."""
# strip scope + breaking marker: feat(reborn)! -> feat
head = subj.split(":", 1)[0] if ":" in subj else ""
base = head.split("(", 1)[0].rstrip("!").strip().lower()
typ = base if base in CONV_TYPES else None
is_pr = "(#" in subj and subj.rstrip().endswith(")")
# rework signals: explicit revert, or a fix that undoes/reverts a prior PR
low = subj.lower()
is_revert = (
typ == "revert"
or low.startswith("revert ")
or "undoes #" in low
or "reverts #" in low
)
return {"type": typ, "is_pr": is_pr, "is_revert": is_revert}
def load_commits() -> list[dict]:
# unit-separator delimited: hash \x1f iso-date \x1f subject
raw = git("log", "--no-merges", "--pretty=format:%H\x1f%cI\x1f%s")
out = []
for line in raw.splitlines():
parts = line.split("\x1f")
if len(parts) != 3:
continue
h, iso, subj = parts
date = datetime.fromisoformat(iso).astimezone(timezone.utc)
out.append({"hash": h, "date": date, "subj": subj, **classify_commit(subj)})
return out
def bucket_by_period(commits: list[dict], now: datetime, days: int) -> dict:
"""Group commits into consecutive `days`-wide windows going back in time."""
buckets: dict[int, list[dict]] = {}
for c in commits:
age = (now - c["date"]).days
if age < 0:
age = 0
buckets.setdefault(age // days, []).append(c)
return buckets
# ----------------------------------------------------------------------------
# Tier 1 — flow / speed
# ----------------------------------------------------------------------------
def tier1(commits: list[dict], now: datetime, pr_limit: int) -> dict:
res: dict = {}
# merge cadence from git (squash-merged PRs carry (#N))
pr_commits = [c for c in commits if c["is_pr"]]
for label, d in (("7d", 7), ("30d", 30), ("60d", 60), ("90d", 90)):
n = sum(1 for c in pr_commits if (now - c["date"]).days < d)
res[f"prs_merged_{label}"] = n
res["prs_total_history"] = len(pr_commits)
# lead time + PR size from GitHub (real open->merge)
gh_json = try_gh(
"pr", "list", "--state", "merged", "--limit", str(pr_limit),
"--json", "number,createdAt,mergedAt,additions,deletions,changedFiles",
)
if gh_json:
try:
prs = json.loads(gh_json)
except json.JSONDecodeError:
prs = [] # gh emitted non-JSON (warning/rate-limit) — treat as no sample
lead_hours, sizes, files = [], [], []
for p in prs:
try:
c = datetime.fromisoformat(p["createdAt"].replace("Z", "+00:00"))
m = datetime.fromisoformat(p["mergedAt"].replace("Z", "+00:00"))
except (KeyError, ValueError):
continue
lead_hours.append((m - c).total_seconds() / 3600.0)
sizes.append((p.get("additions") or 0) + (p.get("deletions") or 0))
files.append(p.get("changedFiles") or 0)
res["gh_pr_sample"] = len(prs)
res["lead_time_h_p50"] = round(pct(lead_hours, 0.5), 1)
res["lead_time_h_p90"] = round(pct(lead_hours, 0.9), 1)
res["lead_time_h_max"] = round(max(lead_hours), 1) if lead_hours else 0
res["pr_lines_p50"] = int(pct(sizes, 0.5))
res["pr_lines_p90"] = int(pct(sizes, 0.9))
res["pr_files_p50"] = int(pct(files, 0.5))
res["pr_files_p90"] = int(pct(files, 0.9))
# small-PR share — a strong healthy-flow signal
res["pr_small_share"] = round(
100 * sum(1 for s in sizes if s <= 200) / len(sizes), 1
) if sizes else 0.0
else:
res["gh_pr_sample"] = None # gh unavailable; git cadence still valid
return res
# ----------------------------------------------------------------------------
# Tier 2 — quality / stability
# ----------------------------------------------------------------------------
def tier2(commits: list[dict], now: datetime) -> dict:
res: dict = {}
buckets = bucket_by_period(commits, now, 30)
per_period = []
for idx in sorted(buckets):
cs = buckets[idx]
counts = {t: 0 for t in CONV_TYPES}
for c in cs:
if c["type"]:
counts[c["type"]] += 1
product = sum(counts[t] for t in PRODUCT_CHANGE)
reverts = sum(1 for c in cs if c["is_revert"])
cfr = round(100 * counts["fix"] / product, 1) if product else 0.0
fix_feat = round(counts["fix"] / counts["feat"], 2) if counts["feat"] else 0.0
test_share = round(
100 * counts["test"] / len(cs), 1
) if cs else 0.0
per_period.append({
"period": f"-{idx*30}..-{(idx+1)*30}d",
"commits": len(cs),
"feat": counts["feat"], "fix": counts["fix"],
"refactor": counts["refactor"], "test": counts["test"],
"reverts": reverts,
"change_failure_pct": cfr, # fix / (feat+fix+refactor+perf)
"fix_per_feat": fix_feat,
"test_commit_pct": test_share,
})
res["by_period_30d"] = per_period
# whole-history rollup
all_counts = {t: 0 for t in CONV_TYPES}
for c in commits:
if c["type"]:
all_counts[c["type"]] += 1
prod = sum(all_counts[t] for t in PRODUCT_CHANGE)
res["overall_change_failure_pct"] = round(100 * all_counts["fix"] / prod, 1) if prod else 0.0
res["overall_fix_per_feat"] = round(all_counts["fix"] / all_counts["feat"], 2) if all_counts["feat"] else 0.0
res["overall_reverts"] = sum(1 for c in commits if c["is_revert"])
res["type_totals"] = all_counts
return res
# ----------------------------------------------------------------------------
# Tier 3 — codebase health
# ----------------------------------------------------------------------------
V1_SRC = "src"
CRATES_SRC = "crates"
COMPOSITION_CRATE = "ironclaw_reborn_composition"
ARCHITECTURE_CRATE = "ironclaw_architecture"
# Tier 3's crate-shaped probes resolve their paths through the crate tree
# instead of hardcoding `crates/<crate>/src` and `crates/*/src`. Those globs
# match nothing once crates move into family directories
# (`crates/<family>/ironclaw_*`, PROPOSAL §5): `find` finds no files, every
# probe returns 0, and the report renders composition at 0% of a 0-line
# codebase — a perfect score for a tool that measured nothing.
# See docs/reborn/target-architecture/CHECKLIST.md WS10.
sys.path.insert(
0,
str(pathlib.Path(__file__).resolve().parent / "ci" / "lib"),
)
from crate_tree import crate_directories, crate_directory # noqa: E402
@functools.lru_cache(maxsize=None)
def _crate_dirs() -> tuple[str, ...]:
"""Crate inventory, walked once per process.
`tier3` needs it four times (composition root, denominator, crate count,
architecture crate) and the walk is not free on a developer machine with
local build outputs under `crates/`.
"""
return tuple(crate_directories("."))
@functools.lru_cache(maxsize=None)
def _crate_dir(name: str) -> str:
return crate_directory(name, ".")
def _crate_src_dirs() -> list[str]:
"""Every existing `<crate>/src` tree — the denominator's exact membership.
Matches `crates/*/src` on the flat tree (a shell glob only expands to
directories that exist, so non-existent `src/` trees are skipped here too).
"""
return [
f"{directory}/src"
for directory in _crate_dirs()
if os.path.isdir(f"{directory}/src")
]
def composition_src() -> str:
return f"{_crate_dir(COMPOSITION_CRATE)}/src"
def tree_bytes(commit: str, path: str, suffix: str = ".rs") -> int:
"""Sum of blob sizes for files under `path` at `commit` (one git call)."""
try:
out = git("ls-tree", "-r", "-l", commit, "--", path)
except subprocess.CalledProcessError:
return 0
total = 0
for line in out.splitlines():
# <mode> <type> <sha> <size>\t<path>
meta, _, fpath = line.partition("\t")
if not fpath.endswith(suffix):
continue
cols = meta.split()
if len(cols) >= 4 and cols[3].isdigit():
total += int(cols[3])
return total
def wc_lines(path_glob_cmd: list[str]) -> int:
out = subprocess.run(path_glob_cmd, capture_output=True, text=True).stdout
return sum(1 for _ in out.splitlines())
def count_matches(pattern: str, path: str) -> int:
r = subprocess.run(
["grep", "-rEho", pattern, path, "--include=*.rs"],
capture_output=True, text=True,
)
# grep: 0 = matches, 1 = no matches (valid 0), >1 = real error.
if r.returncode > 1:
print(f"[warn] grep rc={r.returncode} for pattern {pattern[:40]!r}", file=sys.stderr)
return len([l for l in r.stdout.splitlines() if l.strip()])
def tier3(now: datetime) -> dict:
res: dict = {}
# Resolved once, loudly: a missing composition crate raises here rather
# than quietly measuring an empty directory.
composition = composition_src()
# ---- historical size trend (byte-size index, calibrated to current LOC) ----
# sample the last commit before each month boundary, back to history start.
samples = []
for months_ago in range(0, 7):
target = now - timedelta(days=30 * months_ago)
sha = git("rev-list", "-1", f"--before={target.date().isoformat()}", "HEAD").strip()
if not sha:
continue
cdate = git("show", "-s", "--format=%cd", "--date=short", sha).strip()
samples.append({
"months_ago": months_ago,
"date": cdate,
"composition_bytes": tree_bytes(sha, composition),
"v1_src_bytes": tree_bytes(sha, V1_SRC),
"crates_bytes": tree_bytes(sha, CRATES_SRC),
})
samples.sort(key=lambda s: s["date"])
# calibrate bytes->LOC using the current working tree (exact)
cur_comp_lines = _exact_lines(composition)
cur_comp_bytes = tree_bytes("HEAD", composition) or 1
bytes_per_line = cur_comp_bytes / cur_comp_lines if cur_comp_lines else 30.0
for s in samples:
s["composition_kloc"] = round(s["composition_bytes"] / bytes_per_line / 1000, 1)
s["v1_src_kloc"] = round(s["v1_src_bytes"] / bytes_per_line / 1000, 1)
s["crates_kloc"] = round(s["crates_bytes"] / bytes_per_line / 1000, 1)
# Byte-share of ALL crate code INCLUDING tests — a coarse historical
# gauge, NOT the ratchet metric (which is production-LOC over crates/*/src
# with test files excluded; see composition_share_gate_pct).
s["composition_byte_share_pct"] = round(
100 * s["composition_bytes"] / s["crates_bytes"], 1
) if s["crates_bytes"] else 0.0
res["size_trend"] = samples
# ---- current-state snapshot ----
res["crate_count"] = len(_crate_dirs())
res["composition_kloc_now"] = round(cur_comp_lines / 1000, 1)
res["v1_src_kloc_now"] = round(_exact_lines(V1_SRC) / 1000, 1)
res["crates_kloc_now"] = round(_exact_lines(CRATES_SRC) / 1000, 1)
# Gate-aligned current share — matches scripts/ci/check-composition-budget.sh
# EXACTLY (production LOC, test-only files excluded, every crate's src/ tree
# as the denominator). This is the number the ratchet enforces; the
# byte-based size_trend below is a DIFFERENT, coarser measurement (see its
# label).
comp_prod = _prod_lines(composition)
all_prod = _prod_lines(*_crate_src_dirs())
res["composition_share_gate_pct"] = (
round(100 * comp_prod / all_prod, 2) if all_prod else 0.0
)
traits = count_matches(r"^\s*(pub |pub\(crate\) )?(unsafe )?trait [A-Za-z0-9_]+", "crates")
# Trait impls incl. generics: `impl Trait for T` AND `impl<T> Trait for T`
# (optional generic clause with NO required space before `<`).
impls = count_matches(r"^[[:space:]]*impl(<[^>]*>)?[[:space:]]+[A-Za-z0-9_:<>]+ for ", "crates")
res["trait_defs"] = traits
res["trait_impls_for"] = impls
res["impls_per_trait"] = round(impls / traits, 2) if traits else 0.0
# Dispatch signals — the "reduce traits & dispatch" companion to the mass
# metric (#6168 / #4471). Governed scope = composition production code
# excluding slack/ and extension_host/, matching the dispatch ratchet.
res["composition_arc_dyn"] = _governed_arc_dyn()
res["composition_dyn_types"] = _governed_dyn_types()
# file sprawl vs the 1500 / 3000 rule
big = _files_over(1500)
res["files_over_1500"] = len(big)
res["files_over_3000"] = len([f for f in big if f[0] > 3000])
res["biggest_files"] = big[:8]
# boundary-enforcement coverage
res["boundary_test_count"] = _boundary_tests()
res["arch_exempt_allows"] = count_matches(r"arch-exempt", "crates")
return res
# Test-only file pattern — kept in sync with TEST_FILE_RE in
# scripts/ci/check-composition-budget.sh so the gate-aligned share matches.
TEST_FILE_RE = r"(^|/)(tests?\.rs|test_[^/]*\.rs|[^/]*_tests?\.rs)$|/tests/"
def _prod_lines(*paths: str) -> int:
"""Production LOC of *.rs under `paths`, excluding test-only files —
the gate's exact numerator/denominator definition."""
if not paths:
return 0
roots = " ".join(shlex.quote(path) for path in paths)
out = sh(
f"find {roots} -name '*.rs' -type f 2>/dev/null "
f"| {{ grep -vE \"{TEST_FILE_RE}\" || true; }} "
f"| tr '\\n' '\\0' | xargs -0 cat 2>/dev/null | wc -l"
)
try:
return int(out.strip().split()[0])
except (ValueError, IndexError):
return 0
def _governed_pipeline(inner: str) -> str:
"""find composition production files (excl slack/extension_host — the separate
workstream) piped through `inner` — the exact scope the dispatch ratchet uses."""
return (
f"find {shlex.quote(composition_src())} -name '*.rs' -type f 2>/dev/null "
f"| {{ grep -vE \"{TEST_FILE_RE}\" || true; }} "
f"| {{ grep -vE '/(slack|extension_host)/' || true; }} "
f"| tr '\\n' '\\0' | {{ xargs -0 {inner} 2>/dev/null || true; }}"
)
def _governed_arc_dyn() -> int:
out = sh(_governed_pipeline("grep -ho 'Arc<dyn'") + " | wc -l")
try:
return int(out.strip().split()[0])
except (ValueError, IndexError):
return 0
def _governed_dyn_types() -> int:
out = sh(_governed_pipeline("grep -hoE 'dyn [A-Za-z_:][A-Za-z0-9_:]*'") + " | sort -u | wc -l")
try:
return int(out.strip().split()[0])
except (ValueError, IndexError):
return 0
def _exact_lines(path: str) -> int:
out = sh(
f"find {shlex.quote(path)} -name '*.rs' -type f -print0 "
"| xargs -0 cat 2>/dev/null | wc -l"
)
try:
return int(out.strip().split()[0])
except (ValueError, IndexError):
return 0
def _files_over(threshold: int) -> list:
out = sh("find crates -name '*.rs' -type f -exec wc -l {} + "
"| awk '$2!=\"total\"{print $1\" \"$2}' | sort -rn")
result = []
for line in out.splitlines():
parts = line.split(None, 1)
if len(parts) == 2 and parts[0].isdigit() and int(parts[0]) > threshold:
result.append((int(parts[0]), parts[1]))
return result
def _boundary_tests() -> int:
f = f"{_crate_dir(ARCHITECTURE_CRATE)}/tests/reborn_dependency_boundaries.rs"
# grep -c exits 1 when zero matches — that is a valid 0, not a probe failure.
out = sh(f"grep -cE '#\\[test\\]' {shlex.quote(f)} 2>/dev/null", ok=(0, 1))
try:
return int(out.strip() or 0)
except ValueError:
return 0
# ----------------------------------------------------------------------------
# render
# ----------------------------------------------------------------------------
def render_md(t1, t2, t3, now) -> str:
L = []
L.append(f"# IronClaw Development Metrics — {now.date().isoformat()}\n")
L.append("_Trend tool: read direction, not absolutes. Derived from git + GitHub + working tree._\n")
# Tier 1
L.append("## Tier 1 — Flow / Speed\n")
L.append("| metric | value |")
L.append("|---|---|")
L.append(f"| PRs merged (7d / 30d / 60d / 90d) | {t1['prs_merged_7d']} / {t1['prs_merged_30d']} / {t1['prs_merged_60d']} / {t1['prs_merged_90d']} |")
L.append(f"| PRs merged (all history) | {t1['prs_total_history']} |")
if t1.get("gh_pr_sample"):
L.append(f"| Lead time open→merge p50 / p90 / max (h) | {t1['lead_time_h_p50']} / {t1['lead_time_h_p90']} / {t1['lead_time_h_max']} |")
L.append(f"| PR size lines p50 / p90 | {t1['pr_lines_p50']} / {t1['pr_lines_p90']} |")
L.append(f"| PR files changed p50 / p90 | {t1['pr_files_p50']} / {t1['pr_files_p90']} |")
L.append(f"| Small-PR share (≤200 lines) | {t1['pr_small_share']}% |")
L.append(f"| _(GitHub sample size)_ | {t1['gh_pr_sample']} PRs |")
else:
L.append("| Lead time / PR size | _gh unavailable — skipped_ |")
L.append("")
# Tier 2
L.append("## Tier 2 — Quality / Stability\n")
L.append(f"- **Overall change-failure proxy** (fix / feat+fix+refactor+perf): **{t2['overall_change_failure_pct']}%**")
L.append(f"- **Overall fix : feat ratio**: **{t2['overall_fix_per_feat']}**")
L.append(f"- **Reverts / undo-PRs (rework)**: **{t2['overall_reverts']}**")
tt = t2["type_totals"]
L.append(f"- Commit-type totals: feat {tt['feat']}, fix {tt['fix']}, refactor {tt['refactor']}, test {tt['test']}, perf {tt['perf']}, docs {tt['docs']}, chore {tt['chore']}\n")
L.append("### By 30-day period (newest first)\n")
L.append("| window | commits | feat | fix | refactor | test | reverts | change-fail % | fix/feat | test-commit % |")
L.append("|---|---|---|---|---|---|---|---|---|---|")
for p in t2["by_period_30d"]:
L.append(f"| {p['period']} | {p['commits']} | {p['feat']} | {p['fix']} | {p['refactor']} | {p['test']} | {p['reverts']} | {p['change_failure_pct']} | {p['fix_per_feat']} | {p['test_commit_pct']} |")
L.append("")
# Tier 3
L.append("## Tier 3 — Codebase Health (leading indicators)\n")
L.append("| metric | value |")
L.append("|---|---|")
L.append(f"| Workspace crates | {t3['crate_count']} |")
L.append(f"| **composition share (ratchet metric)** | **{t3['composition_share_gate_pct']}%** |")
L.append(f"| composition KLOC (now) | {t3['composition_kloc_now']} |")
L.append(f"| v1 `src/` KLOC (now) | {t3['v1_src_kloc_now']} |")
L.append(f"| all `crates/` KLOC (now) | {t3['crates_kloc_now']} |")
L.append(f"| trait defs / impls / impls-per-trait | {t3['trait_defs']} / {t3['trait_impls_for']} / {t3['impls_per_trait']} |")
L.append(f"| **composition Arc<dyn> (governed, ratchet)** | **{t3.get('composition_arc_dyn', '-')}** |")
L.append(f"| composition distinct dyn traits (governed) | {t3.get('composition_dyn_types', '-')} |")
L.append(f"| files > 1500 / > 3000 lines | {t3['files_over_1500']} / {t3['files_over_3000']} |")
L.append(f"| boundary tests | {t3['boundary_test_count']} |")
L.append(f"| arch-exempt allows | {t3['arch_exempt_allows']} |")
L.append("")
L.append("### Size trend — composition mass & v1 burndown (calibrated KLOC est.)\n")
L.append("_Byte-based over ALL crate code incl. tests — a coarse historical gauge, "
"NOT the ratchet metric above (which is production-LOC, test files excluded)._\n")
L.append("| date | composition KLOC | comp byte-% (incl tests) | v1 src KLOC | all crates KLOC |")
L.append("|---|---|---|---|---|")
for s in t3["size_trend"]:
L.append(f"| {s['date']} | {s['composition_kloc']} | {s['composition_byte_share_pct']}% | {s['v1_src_kloc']} | {s['crates_kloc']} |")
L.append("")
L.append("### Biggest files (sprawl watch)\n")
for n, f in t3["biggest_files"]:
L.append(f"- {n} — `{f}`")
L.append("")
return "\n".join(L)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--pr-limit", type=int, default=500,
help="how many recent merged PRs to sample from GitHub")
ap.add_argument("--out", default=None, help="write markdown report to FILE")
ap.add_argument("--json", dest="json_out", default=None,
help="write raw metrics JSON to FILE")
args = ap.parse_args()
now = datetime.now(timezone.utc)
commits = load_commits()
if not commits:
print("no commits found", file=sys.stderr)
return 1
print(f"analyzing {len(commits)} commits since {commits[-1]['date'].date()} ...",
file=sys.stderr)
t1 = tier1(commits, now, args.pr_limit)
t2 = tier2(commits, now)
print("computing codebase-health trend (git history samples) ...", file=sys.stderr)
t3 = tier3(now)
md = render_md(t1, t2, t3, now)
print(md)
if args.out:
with open(args.out, "w", encoding="utf-8") as f:
f.write(md)
print(f"\n[written] {args.out}", file=sys.stderr)
if args.json_out:
with open(args.json_out, "w", encoding="utf-8") as f:
json.dump({"tier1": t1, "tier2": t2, "tier3": t3,
"generated": now.isoformat()}, f, indent=2)
print(f"[written] {args.json_out}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())