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:
committed by
Edward Langley
parent
77b6f2dbba
commit
024543c72c
+12
-7
@@ -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
@@ -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=$?
|
||||||
|
|||||||
Reference in New Issue
Block a user