fix(tooling): repo-stats survives a window of empty commits, and the fixture covers renames, the root crate and a refused script (improvise-1xfb.5.1)

churn.csv now has a fixed header. When no file changed in the window,
no model is built and the report says so. docs/*.md, README.md and
RELEASING.md count as prose, and .codex/ as agent docs. The suite no
longer builds improvise itself, because checkers.sh scrubs the linker's
environment.

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 21:56:54 -07:00
committed by Edward Langley
parent 77b6f2dbba
commit 024543c72c
2 changed files with 66 additions and 30 deletions
+12 -7
View File
@@ -25,14 +25,15 @@ AREAS = [
("context/code", re.compile(r"^context/code/")), ("context/code", re.compile(r"^context/code/")),
("context/test-evidence", re.compile(r"^context/test-evidence/")), ("context/test-evidence", re.compile(r"^context/test-evidence/")),
("other context", re.compile(r"^context/")), ("other context", re.compile(r"^context/")),
("agent docs and skills", re.compile(r"^(AGENTS\.md|CLAUDE\.md|\.agents/|\.claude/)")), ("other docs", re.compile(r"^(docs/.*\.md|README\.md|RELEASING\.md)$")),
("agent docs and skills", re.compile(r"^(AGENTS\.md|CLAUDE\.md|\.agents/|\.claude/|\.codex/)")),
("scripts/CI", re.compile(r"^(scripts/|\.github/|nix/|flake\.nix$)")), ("scripts/CI", re.compile(r"^(scripts/|\.github/|nix/|flake\.nix$)")),
("web and harness", re.compile(r"^(web|harness)/")), ("web and harness", re.compile(r"^(web|harness)/")),
("rust tests", re.compile(r"(^|/)(tests|benches)/.*\.rs$|(^|/|_)tests?\.rs$")), ("rust tests", re.compile(r"(^|/)(tests|benches)/.*\.rs$|(^|/|_)tests?\.rs$")),
("rust src", re.compile(r"\.(rs|pest)$")), ("rust src", re.compile(r"\.(rs|pest)$")),
] ]
OTHER = "other" OTHER = "other"
PROSE = {"context/code", "context/test-evidence", "other context", "repo-map", PROSE = {"context/code", "context/test-evidence", "other context", "other docs", "repo-map",
"agent docs and skills"} "agent docs and skills"}
CODE = {"rust src", "rust tests", "scripts/CI", "web and harness"} CODE = {"rust src", "rust tests", "scripts/CI", "web and harness"}
AGENT_RULES = "agent docs and skills" AGENT_RULES = "agent docs and skills"
@@ -42,6 +43,7 @@ AGENT_RULES = "agent docs and skills"
ANALYSES = ["coupling", "abs-churn", "age", "soc"] ANALYSES = ["coupling", "abs-churn", "age", "soc"]
CATEGORIES = ["commit", "date", "week", "entity", "area", "kind", "coupled", CATEGORIES = ["commit", "date", "week", "entity", "area", "kind", "coupled",
"crosses-crate", "agent-rules"] "crosses-crate", "agent-rules"]
CHURN_HEADER = ["commit", "date", "week", "entity", "area", "kind", "added", "deleted"]
MEASURES = ["added", "deleted", "commits", "degree", "average-revs", "age-months", "soc"] MEASURES = ["added", "deleted", "commits", "degree", "average-revs", "age-months", "soc"]
TABLES = ["by-area", "by-kind", "area-by-week", "by-day"] TABLES = ["by-area", "by-kind", "area-by-week", "by-day"]
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
@@ -146,10 +148,8 @@ def flag_coupling(path):
"agent-rules"], rows) "agent-rules"], rows)
def build_dir(out, log, min_revs, today): def build_dir(out, log, rows, min_revs, today):
"""The directory improvise imports: churn.csv and one CSV per analysis.""" """The directory improvise imports: churn.csv and one CSV per analysis."""
rows = churn_rows(log)
write_csv(os.path.join(out, "churn.csv"), list(rows[0]), rows)
with tempfile.NamedTemporaryFile("w", suffix=".log", delete=False) as f: with tempfile.NamedTemporaryFile("w", suffix=".log", delete=False) as f:
f.write(log + "\n") f.write(log + "\n")
try: try:
@@ -263,8 +263,11 @@ def report(repo, ref, since, min_revs, out):
today = git(repo, "show", "-s", "--date=short", "--format=%cd", sha).strip() today = git(repo, "show", "-s", "--date=short", "--format=%cd", sha).strip()
r = {"ref": name, "sha": sha, "since": since, "commits": commits, "out": out, r = {"ref": name, "sha": sha, "since": since, "commits": commits, "out": out,
"docs": doc_sizes(repo, sha), "tables": {}, "coupling": [], "soc": [], "age": []} "docs": doc_sizes(repo, sha), "tables": {}, "coupling": [], "soc": [], "age": []}
if commits: rows = churn_rows(log)
build_dir(out, log, min_revs, today) write_csv(os.path.join(out, "churn.csv"), CHURN_HEADER, rows)
# Empty commits alone change no file: nothing to analyse, so no model is built.
if rows:
build_dir(out, log, rows, min_revs, today)
r["tables"] = run_improvise(out) r["tables"] = run_improvise(out)
for a in ("coupling", "soc", "age"): for a in ("coupling", "soc", "age"):
r[a] = read_csv(os.path.join(out, f"{a}.csv")) r[a] = read_csv(os.path.join(out, f"{a}.csv"))
@@ -290,6 +293,8 @@ TITLES = {
def text(r, top): def text(r, top):
out = [f"repo-stats: {r['ref']} ({r['sha'][:10]}), since {r['since']}, " out = [f"repo-stats: {r['ref']} ({r['sha'][:10]}), since {r['since']}, "
f"{r['commits']} non-merge commits"] f"{r['commits']} non-merge commits"]
if not r["tables"]:
out += ["", "No file changed in the window, so no model was built."]
for t in TABLES: for t in TABLES:
if r["tables"].get(t): if r["tables"].get(t):
out += ["", TITLES[t]] + table(r["tables"][t]) out += ["", TITLES[t]] + table(r["tables"][t])
+54 -23
View File
@@ -13,12 +13,15 @@ pass=0; fail=0
ok() { printf ' ok %s\n' "$1"; pass=$((pass+1)); } ok() { printf ' ok %s\n' "$1"; pass=$((pass+1)); }
bad() { printf ' FAIL %s (%s)\n' "$1" "$2"; fail=$((fail+1)); } bad() { printf ' FAIL %s (%s)\n' "$1" "$2"; fail=$((fail+1)); }
# The improvise under test: on PATH in CI (`nix shell .#improvise`), else this tree's. # The improvise under test: on PATH in CI, else this tree's debug build. No build
# here: checkers.sh scrubs the linker's environment (context/code/tooling.md §7).
if command -v improvise >/dev/null; then if command -v improvise >/dev/null; then
IMPROVISE="$(command -v improvise)" IMPROVISE="$(command -v improvise)"
else elif [ -x "$here/../target/debug/improvise" ]; then
cargo build --quiet --manifest-path "$here/../Cargo.toml" --bin improvise || exit 1
IMPROVISE="$here/../target/debug/improvise" IMPROVISE="$here/../target/debug/improvise"
else
echo "repo-stats-test: no improvise; run \`cargo build --bin improvise\` first" >&2
exit 1
fi fi
export IMPROVISE export IMPROVISE
@@ -57,13 +60,14 @@ git init -q -b main "$repo"
# Before the window: must count nowhere. # Before the window: must count nowhere.
put README.md 50 old; commit 2026-01-01 put README.md 50 old; commit 2026-01-01
# Two commits co-change five files, so each pair couples at 100% over 2 revs. # Two commits co-change six files, so each pair couples at 100% over 2 revs.
# src/ is the root crate's, so it crosses a boundary with both crates/.
for f in crates/a/src/lib.rs crates/b/src/lib.rs; do put "$f" 10 x1; done 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 src/main.rs 5 x1; put crates/a/tests/t.rs 4 x1
put AGENTS.md 3 x1; put .agents/skills/wave/agent-rules.md 3 x1 put AGENTS.md 3 x1; put .agents/skills/wave/agent-rules.md 3 x1
commit 2026-06-02 commit 2026-06-02
for f in crates/a/src/lib.rs crates/b/src/lib.rs; do put "$f" 6 x2; done 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 src/main.rs 5 x2; put crates/a/tests/t.rs 4 x2
put AGENTS.md 3 x2; put .agents/skills/wave/agent-rules.md 3 x2 put AGENTS.md 3 x2; put .agents/skills/wave/agent-rules.md 3 x2
commit 2026-06-03 commit 2026-06-03
@@ -93,6 +97,8 @@ 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 web/app.js 9 x3; put harness/x.js 2 x3
put Cargo.lock 11 x3 put Cargo.lock 11 x3
put crates/a/src/grammar.pest 3 x3 put crates/a/src/grammar.pest 3 x3
put .beads/issues.jsonl 2 x3; put site/index.html 1 x3
put docs/guide.md 2 x3; put .codex/config.toml 1 x3
commit 2026-06-04 commit 2026-06-04
g tag before-shrink g tag before-shrink
@@ -103,8 +109,11 @@ g checkout -q main
GIT_AUTHOR_DATE=2026-06-06T12:00:00Z GIT_COMMITTER_DATE=2026-06-06T12:00:00Z \ GIT_AUTHOR_DATE=2026-06-06T12:00:00Z GIT_COMMITTER_DATE=2026-06-06T12:00:00Z \
g merge -q --no-ff -m merge side g merge -q --no-ff -m merge side
# A shrink in the next ISO week, so one area deletes and one row spans two weeks. # A shrink in the next ISO week, so one area deletes and one row spans two weeks,
put web/app.js 3 x4; commit 2026-06-09 # and a rename, which --no-renames counts as a delete and an add.
put web/app.js 3 x4
g mv scripts/foo.sh scripts/bar.sh
commit 2026-06-09
# $1 output dir, $2 "text" to also write the text report, rest: extra args. # $1 output dir, $2 "text" to also write the text report, rest: extra args.
run() { run() {
@@ -121,34 +130,37 @@ run() {
if run "$work/stats" text; then if run "$work/stats" text; then
is "no gh/main in the fixture, so the ref is HEAD" '.ref' '"HEAD"' 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 "the merge commit is not counted, the side commit is" '.commits' '5'
row "rust src is .rs and .pest outside tests" by-area "rust src" '["35","20","0.571"]' row "rust src is .rs and .pest outside tests" by-area "rust src" '["45","25","0.556"]'
row "rust tests are the tests/ files" by-area "rust tests" '["8","4","0.500"]' row "rust tests are the tests/ files" by-area "rust tests" '["8","4","0.500"]'
row "agent docs and skills" by-area "agent docs and skills" '["12","6","0.500"]' row "agent docs and skills take .codex" by-area "agent docs and skills" '["13","6","0.462"]'
row "docs/*.md is other docs" by-area "other docs" '["2","0","0.000"]'
row "context/code" by-area "context/code" '["8","0","0.000"]' row "context/code" by-area "context/code" '["8","0","0.000"]'
row "context/test-evidence" by-area "context/test-evidence" '["5","0","0.000"]' row "context/test-evidence" by-area "context/test-evidence" '["5","0","0.000"]'
row "repo-map is its own area" by-area "repo-map" '["13","0","0.000"]' row "repo-map is its own area" by-area "repo-map" '["13","0","0.000"]'
row "other context" by-area "other context" '["2","0","0.000"]' row "other context" by-area "other context" '["2","0","0.000"]'
row "kb.pl" by-area "kb.pl" '["5","0","0.000"]' row "kb.pl" by-area "kb.pl" '["5","0","0.000"]'
row "scripts/CI takes .github" by-area "scripts/CI" '["8","0","0.000"]' row "scripts/CI takes .github, and a rename is a delete and an add" \
by-area "scripts/CI" '["15","7","0.467"]'
row "web and harness, and its deletion ratio" by-area "web and harness" '["14","9","0.643"]' row "web and harness, and its deletion ratio" by-area "web and harness" '["14","9","0.643"]'
row "a lock file is generated" by-area "generated" '["11","0","0.000"]' row "a lock file, .beads/ and site/ are generated" by-area "generated" '["14","0","0.000"]'
row "the pre-window README counts nowhere" by-area "other" '["4","0","0.000"]' row "the pre-window README counts nowhere" by-area "other" '["4","0","0.000"]'
row "prose is the context areas and agent docs" by-kind prose '["40","6","0.150"]' row "prose is the context areas, other docs and agent docs" by-kind prose '["43","6","0.140"]'
row "code is rust, scripts/CI and web" by-kind code '["65","33","0.508"]' row "code is rust, scripts/CI and web" by-kind code '["82","45","0.549"]'
row "prose to code is prose over code" by-kind ProseToCode '["0.615","0.182",""]' row "prose to code is prose over code" by-kind ProseToCode '["0.524","0.133",""]'
is "weeks are columns, first-seen first" '.tables["area-by-week"][0][1:]' '["2026-W24","2026-W23"]' is "weeks are columns, first-seen first" '.tables["area-by-week"][0][1:]' '["2026-W24","2026-W23"]'
row "an area is split by ISO week" area-by-week "web and harness" '["3","11"]' row "an area is split by ISO week" area-by-week "web and harness" '["3","11"]'
is "every day's churn.csv total equals code-maat's abs-churn" \ is "every day's churn.csv total equals code-maat's abs-churn" \
'[.tables["by-day"][1:][]|.[1]==.[2]]|all' 'true' '[.tables["by-day"][1:][]|.[1]==.[2]]|all' 'true'
row "a day's total is every file changed that day" by-day 2026-06-04 '["66","66"]' row "a day's total is every file changed that day" by-day 2026-06-04 '["72","72"]'
is "gotchas end with their section" '.docs.repo_map.gotchas' '3' is "gotchas end with their section" '.docs.repo_map.gotchas' '3'
is "the longest gotcha is measured over its lines" \ is "the longest gotcha is measured over its lines" \
'.docs.repo_map.longest|[.number,.lines]' '[2,2]' '.docs.repo_map.longest|[.number,.lines]' '[2,2]'
is "test-evidence entries are ### and #### headings" '.docs.test_evidence.entries' '3' is "test-evidence entries are ### and #### headings" '.docs.test_evidence.entries' '3'
is "coupling finds the ten co-changed pairs" '.coupling|length' '10' is "coupling finds the 15 pairs of six files, and web/app.js with scripts/foo.sh" \
is "two crates' files cross a crate boundary" \ '.coupling|length' '16'
'[.coupling[]|select(.["crosses-crate"]=="yes")|[.entity,.coupled]|sort]' \ is "files of two crates, the root crate's included, cross a boundary" \
'[["crates/a/src/lib.rs","crates/b/src/lib.rs"],["crates/a/tests/t.rs","crates/b/src/lib.rs"]]' '[.coupling[]|select(.["crosses-crate"]=="yes")|[.entity,.coupled]|sort]|sort' \
'[["crates/a/src/lib.rs","crates/b/src/lib.rs"],["crates/a/src/lib.rs","src/main.rs"],["crates/a/tests/t.rs","crates/b/src/lib.rs"],["crates/a/tests/t.rs","src/main.rs"],["crates/b/src/lib.rs","src/main.rs"]]'
is "two agent-rule docs are flagged" \ is "two agent-rule docs are flagged" \
'[.coupling[]|select(.["agent-rules"]=="yes")|[.entity,.coupled]|sort]' \ '[.coupling[]|select(.["agent-rules"]=="yes")|[.entity,.coupled]|sort]' \
'[[".agents/skills/wave/agent-rules.md","AGENTS.md"]]' '[[".agents/skills/wave/agent-rules.md","AGENTS.md"]]'
@@ -166,13 +178,32 @@ if run "$work/at-tag" json --ref before-shrink; then
is "--ref names the ref it read" '.ref' '"before-shrink"' is "--ref names the ref it read" '.ref' '"before-shrink"'
fi fi
# improvise refusing the pipeline is exit 3, whatever the numbers were. # A window whose only commits are empty changes no file: exit 0, and no model.
empty="$work/empty"
git init -q -b main "$empty"
repo="$empty"; put README.md 5 old; commit 2026-01-01
GIT_AUTHOR_DATE=2026-06-02T12:00:00Z GIT_COMMITTER_DATE=2026-06-02T12:00:00Z \
g commit -q --allow-empty -m empty
if run "$work/empty-out" text; then
is "an empty commit is counted" '.commits' '1'
is "and builds no tables" '.tables' '{}'
lines="$(wc -l < "$work/empty-out/churn.csv" | tr -d ' ')"
[ "$lines" = 1 ] && ok "churn.csv is written with its header alone" \
|| bad "churn.csv is written with its header alone" "$lines lines"
says "the text report says why there are no tables" "No file changed in the window"
fi
repo="$work/repo"
# improvise refusing the pipeline is exit 3, whatever the numbers were. The
# third stub imports cleanly and refuses only the scripts.
mkdir -p "$work/bin" mkdir -p "$work/bin"
printf '#!/usr/bin/env bash\necho "Parse error: no" >&2\nexit 1\n' > "$work/bin/refuses" printf '#!/usr/bin/env bash\necho "Parse error: no" >&2\nexit 1\n' > "$work/bin/refuses"
printf '#!/usr/bin/env bash\necho "warning: Skipped 3 of 3 records" >&2\nexit 0\n' \ printf '#!/usr/bin/env bash\necho "warning: Skipped 3 of 3 records" >&2\nexit 0\n' \
> "$work/bin/warns" > "$work/bin/warns"
chmod +x "$work/bin/refuses" "$work/bin/warns" printf '#!/usr/bin/env bash\n[ "$1" = script ] || exit 0\necho "Parse error: no" >&2\nexit 1\n' \
for stub in refuses warns; do > "$work/bin/refuses-a-script"
chmod +x "$work/bin/refuses" "$work/bin/warns" "$work/bin/refuses-a-script"
for stub in refuses warns refuses-a-script; do
IMPROVISE="$work/bin/$stub" "$STATS" -C "$repo" --since 2026-06-01 --out "$work/$stub" \ IMPROVISE="$work/bin/$stub" "$STATS" -C "$repo" --since 2026-06-01 --out "$work/$stub" \
> /dev/null 2>&1 > /dev/null 2>&1
got=$? got=$?