diff --git a/scripts/repo-stats b/scripts/repo-stats index 35a45a0e..a166b962 100755 --- a/scripts/repo-stats +++ b/scripts/repo-stats @@ -25,14 +25,15 @@ AREAS = [ ("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/)")), + ("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$)")), ("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" -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"} CODE = {"rust src", "rust tests", "scripts/CI", "web and harness"} AGENT_RULES = "agent docs and skills" @@ -42,6 +43,7 @@ AGENT_RULES = "agent docs and skills" ANALYSES = ["coupling", "abs-churn", "age", "soc"] CATEGORIES = ["commit", "date", "week", "entity", "area", "kind", "coupled", "crosses-crate", "agent-rules"] +CHURN_HEADER = ["commit", "date", "week", "entity", "area", "kind", "added", "deleted"] MEASURES = ["added", "deleted", "commits", "degree", "average-revs", "age-months", "soc"] TABLES = ["by-area", "by-kind", "area-by-week", "by-day"] HERE = os.path.dirname(os.path.abspath(__file__)) @@ -146,10 +148,8 @@ def flag_coupling(path): "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.""" - 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: f.write(log + "\n") try: @@ -263,8 +263,11 @@ def report(repo, ref, since, min_revs, out): today = git(repo, "show", "-s", "--date=short", "--format=%cd", sha).strip() r = {"ref": name, "sha": sha, "since": since, "commits": commits, "out": out, "docs": doc_sizes(repo, sha), "tables": {}, "coupling": [], "soc": [], "age": []} - if commits: - build_dir(out, log, min_revs, today) + rows = churn_rows(log) + 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) for a in ("coupling", "soc", "age"): r[a] = read_csv(os.path.join(out, f"{a}.csv")) @@ -290,6 +293,8 @@ TITLES = { def text(r, top): out = [f"repo-stats: {r['ref']} ({r['sha'][:10]}), since {r['since']}, " 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: if r["tables"].get(t): out += ["", TITLES[t]] + table(r["tables"][t]) diff --git a/scripts/repo-stats-test.sh b/scripts/repo-stats-test.sh index ee69e055..5a7378b7 100755 --- a/scripts/repo-stats-test.sh +++ b/scripts/repo-stats-test.sh @@ -13,12 +13,15 @@ pass=0; fail=0 ok() { printf ' ok %s\n' "$1"; pass=$((pass+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 IMPROVISE="$(command -v improvise)" -else - cargo build --quiet --manifest-path "$here/../Cargo.toml" --bin improvise || exit 1 +elif [ -x "$here/../target/debug/improvise" ]; then IMPROVISE="$here/../target/debug/improvise" +else + echo "repo-stats-test: no improvise; run \`cargo build --bin improvise\` first" >&2 + exit 1 fi export IMPROVISE @@ -57,13 +60,14 @@ 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. +# 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 -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 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 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 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 Cargo.lock 11 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 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 \ 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. -put web/app.js 3 x4; commit 2026-06-09 +# A shrink in the next ISO week, so one area deletes and one row spans two weeks, +# 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. run() { @@ -121,34 +130,37 @@ run() { if run "$work/stats" text; 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' - 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 "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/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 "other context" by-area "other context" '["2","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 "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 "prose is the context areas and agent docs" by-kind prose '["40","6","0.150"]' - row "code is rust, scripts/CI and web" by-kind code '["65","33","0.508"]' - row "prose to code is prose over code" by-kind ProseToCode '["0.615","0.182",""]' + 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 '["82","45","0.549"]' + 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"]' 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" \ '[.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 "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 "coupling finds the ten co-changed pairs" '.coupling|length' '10' - is "two crates' files cross a crate boundary" \ - '[.coupling[]|select(.["crosses-crate"]=="yes")|[.entity,.coupled]|sort]' \ - '[["crates/a/src/lib.rs","crates/b/src/lib.rs"],["crates/a/tests/t.rs","crates/b/src/lib.rs"]]' + is "coupling finds the 15 pairs of six files, and web/app.js with scripts/foo.sh" \ + '.coupling|length' '16' + is "files of two crates, the root crate's included, cross a boundary" \ + '[.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" \ '[.coupling[]|select(.["agent-rules"]=="yes")|[.entity,.coupled]|sort]' \ '[[".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"' 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" 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' \ > "$work/bin/warns" -chmod +x "$work/bin/refuses" "$work/bin/warns" -for stub in refuses warns; do +printf '#!/usr/bin/env bash\n[ "$1" = script ] || exit 0\necho "Parse error: no" >&2\nexit 1\n' \ + > "$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" \ > /dev/null 2>&1 got=$?