diff --git a/scripts/repo-stats b/scripts/repo-stats new file mode 100755 index 00000000..7a8cecb5 --- /dev/null +++ b/scripts/repo-stats @@ -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() diff --git a/scripts/repo-stats-test.sh b/scripts/repo-stats-test.sh new file mode 100755 index 00000000..4b82901b --- /dev/null +++ b/scripts/repo-stats-test.sh @@ -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 ]