chore(tooling): scripts/repo-stats and its fixture suite (improvise-1xfb.5.1)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Claude (agent-stats)
2026-09-24 20:28:12 -07:00
committed by Edward Langley
parent 3ae7650d8a
commit 73a8556937
2 changed files with 399 additions and 0 deletions
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env python3
"""Churn, coupling and doc-growth report over a window of history.
code-maat does coupling, age, abs-churn and soc; this adds the by-area split,
the prose-to-code and deletion ratios, doc sizes and coupling flags. Report
only: it gates nothing. See context/code/tooling.md section 7.
"""
import argparse
import csv
import io
import json
import os
import re
import subprocess
import sys
import tempfile
# First match wins, so the order is part of the definition.
AREAS = [
("generated", re.compile(r"^(\.beads/|site/|Cargo\.nix$|roadmap\.org$)"
r"|\.(lock|cast)$|(^|/)package-lock\.json$")),
("repo-map", re.compile(r"^context/repo-map\.md$")),
("kb.pl", re.compile(r"^prolog-sessions/kb\.pl$")),
("context/code", re.compile(r"^context/code/")),
("context/test-evidence", re.compile(r"^context/test-evidence/")),
("other context", re.compile(r"^context/")),
("agent docs and skills", re.compile(r"^(AGENTS\.md|CLAUDE\.md|\.agents/|\.claude/)")),
("scripts/CI", re.compile(r"^(scripts/|\.github/|nix/|flake\.nix$)")),
("web and harness", re.compile(r"^(web|harness)/")),
("rust tests", re.compile(r"(^|/)(tests|benches)/.*\.rs$|(^|/|_)tests?\.rs$")),
("rust src", re.compile(r"\.(rs|pest)$")),
]
OTHER = "other"
AREA_NAMES = [a for a, _ in AREAS] + [OTHER]
PROSE = {"context/code", "context/test-evidence", "other context", "repo-map",
"agent docs and skills"}
CODE = {"rust src", "rust tests", "scripts/CI", "web and harness"}
AGENT_RULES = "agent docs and skills"
def area_of(path):
for name, rx in AREAS:
if rx.search(path):
return name
return OTHER
def crate_of(path):
"""The crate a path belongs to, or None for a path in no crate."""
m = re.match(r"^crates/([^/]+)/", path)
if m:
return m.group(1)
if path.startswith("harness/opfs-contract/"):
return "opfs-contract"
if re.match(r"^(src|tests|benches|examples)/", path) or path == "Cargo.toml":
return "improvise"
return None
def git(repo, *args):
return subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True,
check=True).stdout
def resolve_ref(repo, ref):
"""The ref asked for; the default gh/main falls back to HEAD where it is absent."""
for r in ([ref] if ref else ["gh/main", "HEAD"]):
out = subprocess.run(["git", "-C", repo, "rev-parse", "--verify", "--quiet",
r + "^{commit}"], capture_output=True, text=True)
if out.returncode == 0:
return r, out.stdout.strip()
sys.exit(f"repo-stats: cannot resolve {ref or 'gh/main or HEAD'}")
def git2_log(repo, sha, since):
"""The log in code-maat's git2 format: the ref's history, without merges."""
return git(repo, "log", sha, "--no-merges", f"--since={since}", "--numstat",
"--date=short", "--pretty=format:--%h--%ad--%aN", "--no-renames")
def parse_log(text):
"""(commits, [(path, added, deleted)]). A binary file's '-' counts as 0."""
commits, rows = 0, []
for line in text.split("\n"):
if line.startswith("--") and line.count("--") >= 3:
commits += 1
elif "\t" in line:
a, d, path = line.split("\t", 2)
rows.append((path, int(a) if a != "-" else 0, int(d) if d != "-" else 0))
return commits, rows
def ratio(num, den):
return round(num / den, 3) if den else None
def churn_by_area(rows):
areas = {n: {"added": 0, "deleted": 0} for n in AREA_NAMES}
for path, a, d in rows:
areas[area_of(path)]["added"] += a
areas[area_of(path)]["deleted"] += d
for v in areas.values():
v["deletion_ratio"] = ratio(v["deleted"], v["added"])
return areas
def prose_to_code(areas):
prose = sum(areas[a]["added"] for a in PROSE)
code = sum(areas[a]["added"] for a in CODE)
return {"prose_added": prose, "code_added": code, "ratio": ratio(prose, code)}
def gotchas(text):
"""(number, lines, chars) for each numbered item of repo-map's Gotchas section."""
out, cur, inside = [], None, False
for line in text.split("\n"):
if line.startswith("## "):
inside = line.startswith("## Gotchas")
continue
if inside and line.startswith("---"):
inside = False
if not inside:
continue
m = re.match(r"^(\d+)\. ", line)
if m:
cur = [int(m.group(1)), 0, 0]
out.append(cur)
if cur is not None and line.strip():
cur[1] += 1
cur[2] += len(line)
return [tuple(g) for g in out]
def doc_sizes(repo, sha):
files = [p for p in git(repo, "ls-tree", "-r", "--name-only", sha, "--",
"context").split("\n") if p.endswith(".md")]
show = lambda p: git(repo, "show", f"{sha}:{p}")
lines = {p: len(show(p).splitlines()) for p in sorted(files)}
gs = gotchas(show("context/repo-map.md")) if "context/repo-map.md" in files else []
longest = max(gs, key=lambda g: g[2], default=None)
evidence = {p: sum(1 for l in show(p).split("\n") if re.match(r"^#{3,4} ", l))
for p in sorted(files) if p.startswith("context/test-evidence/")}
return {
"context_lines": lines,
"repo_map": {"gotchas": len(gs), "longest": longest and
{"number": longest[0], "lines": longest[1], "chars": longest[2]}},
"test_evidence": {"entries": sum(evidence.values()), "per_file": evidence},
}
def code_maat(logfile, analysis, *extra):
out = subprocess.run(["code-maat", "-l", logfile, "-c", "git2", "-a", analysis,
*extra], capture_output=True, text=True, check=True).stdout
return list(csv.DictReader(io.StringIO(out)))
def flag_coupling(rows):
for r in rows:
a, b = crate_of(r["entity"]), crate_of(r["coupled"])
r["crosses_crate"] = bool(a and b and a != b)
r["agent_rules"] = area_of(r["entity"]) == area_of(r["coupled"]) == AGENT_RULES
return rows
def maat(log, min_revs, today):
if not log.strip():
return {"coupling": [], "abs_churn": [], "age": [], "soc": []}
with tempfile.NamedTemporaryFile("w", suffix=".log", delete=False) as f:
f.write(log + "\n")
try:
revs = ["--min-revs", str(min_revs), "--min-shared-revs", str(min_revs)]
return {
"coupling": flag_coupling(code_maat(f.name, "coupling", *revs)),
"abs_churn": code_maat(f.name, "abs-churn"),
"age": code_maat(f.name, "age", "--age-time-now", today),
"soc": code_maat(f.name, "soc"),
}
finally:
os.unlink(f.name)
def report(repo, ref, since, min_revs):
name, sha = resolve_ref(repo, ref)
log = git2_log(repo, sha, since)
commits, rows = parse_log(log)
areas = churn_by_area(rows)
today = git(repo, "show", "-s", "--date=short", "--format=%cd", sha).strip()
return {
"ref": name, "sha": sha, "since": since, "commits": commits,
"areas": areas, "prose_to_code": prose_to_code(areas),
"docs": doc_sizes(repo, sha), "code_maat": maat(log, min_revs, today),
}
def fmt_ratio(r):
return "-" if r is None else f"{r:.3f}"
def text(r, top):
out = [f"repo-stats: {r['ref']} ({r['sha'][:10]}), since {r['since']}, "
f"{r['commits']} non-merge commits", "", "Churn by area"]
out.append(f" {'area':<24}{'added':>9}{'deleted':>9} del/add")
for n, v in r["areas"].items():
out.append(f" {n:<24}{v['added']:>9}{v['deleted']:>9} "
f"{fmt_ratio(v['deletion_ratio'])}")
p = r["prose_to_code"]
out += ["", f"Prose to code (added lines): {p['prose_added']} / {p['code_added']}"
f" = {fmt_ratio(p['ratio'])}", "", "Doc sizes (lines)"]
out += [f" {n:>6} {path}" for path, n in r["docs"]["context_lines"].items()]
rm, te = r["docs"]["repo_map"], r["docs"]["test_evidence"]
lg = rm["longest"]
out.append(f" repo-map gotchas: {rm['gotchas']}" + (
f"; longest is {lg['number']} ({lg['lines']} lines, {lg['chars']} chars)"
if lg else ""))
out.append(f" test-evidence entries: {te['entries']}")
m = r["code_maat"]
out += ["", f"Change coupling (code-maat; top {top})"]
for c in m["coupling"][:top]:
flags = [f for f, on in (("CROSSES-CRATE", c["crosses_crate"]),
("AGENT-RULES", c["agent_rules"])) if on]
out.append(f" {c['degree']:>4}% {c['average-revs']:>4} revs {c['entity']}"
f" <-> {c['coupled']}" + (f" [{', '.join(flags)}]" if flags else ""))
out += ["", f"Sum of coupling (code-maat; top {top})"]
out += [f" {s['soc']:>6} {s['entity']}" for s in m["soc"][:top]]
out += ["", "Absolute churn per day (code-maat)"]
out += [f" {c['date']} +{c['added']} -{c['deleted']} {c['commits']} commits"
for c in m["abs_churn"]]
out += ["", f"Code age in months (code-maat; oldest {top})"]
out += [f" {a['age-months']:>4} {a['entity']}" for a in m["age"][:top]]
return "\n".join(out)
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--ref", help="the ref to report on (default gh/main, else HEAD)")
ap.add_argument("--since", default="7 days ago", help="git --since (default 7 days ago)")
ap.add_argument("--min-revs", type=int, default=5,
help="code-maat --min-revs and --min-shared-revs (default 5)")
ap.add_argument("--top", type=int, default=15, help="rows per ranked table")
ap.add_argument("--json", action="store_true", help="emit JSON instead of text")
ap.add_argument("-C", dest="repo", default=".", help="the repository (default .)")
a = ap.parse_args()
r = report(a.repo, a.ref, a.since, a.min_revs)
print(json.dumps(r, indent=2) if a.json else text(r, a.top))
if __name__ == "__main__":
main()
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# check_the_checker for scripts/repo-stats (improvise-1xfb.5.1), against a fixture
# repo whose every number is known by construction. See context/test-evidence/tooling.md.
set -uo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STATS="$here/repo-stats"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
pass=0; fail=0
ok() { printf ' ok %s\n' "$1"; pass=$((pass+1)); }
bad() { printf ' FAIL %s (%s)\n' "$1" "$2"; fail=$((fail+1)); }
# $1 name, $2 jq expression over $work/out.json, $3 the value it must print
is() {
local got
got="$(jq -c "$2" "$work/out.json" 2>&1)"
[ "$got" = "$3" ] && ok "$1" || bad "$1" "got $got, wanted $3"
}
# $1 name, $2 fixed-string the text report must contain
says() {
grep -Fq -- "$2" "$work/out.txt" && ok "$1" || bad "$1" "no '$2' in the text report"
}
export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null
export GIT_AUTHOR_NAME=fixture GIT_AUTHOR_EMAIL=f@x GIT_COMMITTER_NAME=fixture GIT_COMMITTER_EMAIL=f@x
repo="$work/repo"
g() { git -C "$repo" "$@" >/dev/null; }
# $1 path, $2 line count, $3 tag. Rewriting with a new tag replaces every line.
put() { mkdir -p "$(dirname "$repo/$1")"; seq -f "$3 %g" 1 "$2" > "$repo/$1"; }
# $1 date; commits everything staged or not.
commit() {
g add -A
GIT_AUTHOR_DATE="$1T12:00:00Z" GIT_COMMITTER_DATE="$1T12:00:00Z" g commit -q -m "at $1"
}
git init -q -b main "$repo"
# Before the window: must count nowhere.
put README.md 50 old; commit 2026-01-01
# Two commits co-change five files, so each pair couples at 100% over 2 revs.
for f in crates/a/src/lib.rs crates/b/src/lib.rs; do put "$f" 10 x1; done
put crates/a/tests/t.rs 4 x1
put AGENTS.md 3 x1; put .agents/skills/wave/agent-rules.md 3 x1
commit 2026-06-02
for f in crates/a/src/lib.rs crates/b/src/lib.rs; do put "$f" 6 x2; done
put crates/a/tests/t.rs 4 x2
put AGENTS.md 3 x2; put .agents/skills/wave/agent-rules.md 3 x2
commit 2026-06-03
# One file in each remaining area.
put context/code/x.md 8 x3
mkdir -p "$repo/context/test-evidence"
printf '### t_one\ntext\n#### t_two\n## not an entry\n### t_three\n' \
> "$repo/context/test-evidence/y.md"
cat > "$repo/context/repo-map.md" <<'EOF'
# Map
## Gotchas (read before editing)
1. **One.** short.
2. **Two.** a much longer gotcha, with plenty of words in it to be the longest one
and a second line.
3. **Three.** middling length.
---
## File Inventory
4. not a gotcha, because the section has ended
EOF
put context/plan.md 2 x3
put prolog-sessions/kb.pl 5 x3
put scripts/foo.sh 7 x3; put .github/workflows/ci.yml 1 x3
put web/app.js 9 x3; put harness/x.js 2 x3
put Cargo.lock 11 x3
put crates/a/src/grammar.pest 3 x3
commit 2026-06-04
g tag before-shrink 2>/dev/null || true
# A side branch merged with a merge commit: the side commit counts, the merge does not.
g checkout -q -b side
put notes.txt 4 x5; commit 2026-06-05
g checkout -q main
GIT_AUTHOR_DATE=2026-06-06T12:00:00Z GIT_COMMITTER_DATE=2026-06-06T12:00:00Z \
g merge -q --no-ff -m merge side
# A shrink, so one area deletes more than it adds in a commit.
put web/app.js 3 x4; commit 2026-06-07
run() {
"$STATS" -C "$repo" --since 2026-06-01 --min-revs 2 "$@" --json > "$work/out.json" 2> "$work/err" \
|| { bad "repo-stats $* ran" "$(cat "$work/err")"; return 1; }
"$STATS" -C "$repo" --since 2026-06-01 --min-revs 2 "$@" > "$work/out.txt" 2>&1 \
|| { bad "repo-stats $* ran (text)" "$(cat "$work/out.txt")"; return 1; }
}
if run; then
is "no gh/main in the fixture, so the ref is HEAD" '.ref' '"HEAD"'
is "the merge commit is not counted, the side commit is" '.commits' '5'
is "rust src is .rs and .pest outside tests" '.areas["rust src"]|[.added,.deleted]' '[35,20]'
is "rust tests are the tests/ files" '.areas["rust tests"]|[.added,.deleted]' '[8,4]'
is "agent docs and skills" '.areas["agent docs and skills"]|[.added,.deleted]' '[12,6]'
is "context/code" '.areas["context/code"].added' '8'
is "context/test-evidence" '.areas["context/test-evidence"].added' '5'
is "repo-map is its own area" '.areas["repo-map"].added' '15'
is "other context" '.areas["other context"].added' '2'
is "kb.pl" '.areas["kb.pl"].added' '5'
is "scripts/CI takes .github" '.areas["scripts/CI"].added' '8'
is "web and harness" '.areas["web and harness"]|[.added,.deleted]' '[14,9]'
is "a lock file is generated" '.areas.generated.added' '11'
is "the pre-window README counts nowhere" '.areas.other|[.added,.deleted]' '[4,0]'
is "deletion ratio is deleted over added" '.areas["rust src"].deletion_ratio' '0.571'
is "an area that deletes nothing has a deletion ratio of 0" \
'.areas["kb.pl"].deletion_ratio' '0.0'
is "prose to code is prose added over code added" \
'.prose_to_code|[.prose_added,.code_added,.ratio]' '[42,65,0.646]'
is "gotchas end with their section" '.docs.repo_map.gotchas' '3'
is "the longest gotcha is measured over its lines" \
'.docs.repo_map.longest|[.number,.lines]' '[2,2]'
is "test-evidence entries are ### and #### headings" '.docs.test_evidence.entries' '3'
is "context doc lines" '.docs.context_lines["context/repo-map.md"]' '15'
is "coupling finds the ten co-changed pairs" '.code_maat.coupling|length' '10'
is "two crates' files cross a crate boundary" \
'[.code_maat.coupling[]|select(.crosses_crate)|[.entity,.coupled]|sort]' \
'[["crates/a/src/lib.rs","crates/b/src/lib.rs"],["crates/a/tests/t.rs","crates/b/src/lib.rs"]]'
is "two agent-rule docs are flagged" \
'[.code_maat.coupling[]|select(.agent_rules)|[.entity,.coupled]|sort]' \
'[[".agents/skills/wave/agent-rules.md","AGENTS.md"]]'
is "abs-churn has one row per day" '.code_maat.abs_churn|length' '5'
is "soc sums a file's co-changes over its commits" \
'.code_maat.soc[]|select(.entity=="crates/a/src/lib.rs")|.soc' '"8"'
says "the text report flags a crate crossing" "[CROSSES-CRATE]"
says "the text report flags agent-rule docs" "[AGENT-RULES]"
says "the text report prints the ratio" "Prose to code (added lines): 42 / 65 = 0.646"
fi
# The window is scoped to the ref: at the tag, the shrink has not happened.
if run --ref before-shrink; then
is "--ref scopes the log" '.areas["web and harness"]|[.added,.deleted]' '[11,0]'
is "--ref names the ref it read" '.ref' '"before-shrink"'
fi
printf '\nrepo-stats: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" = 0 ]