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:
committed by
Edward Langley
parent
3ae7650d8a
commit
73a8556937
Executable
+249
@@ -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()
|
||||
Executable
+150
@@ -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 ]
|
||||
Reference in New Issue
Block a user