Merge remote-tracking branch 'gh/main' into adr5-steward-b2

This commit is contained in:
Claude (adr5-steward)
2026-10-08 20:41:13 -07:00
committed by Edward Langley
130 changed files with 12519 additions and 1731 deletions
+323
View File
@@ -0,0 +1,323 @@
#!/usr/bin/env bash
# Stack-agnostic agent bootstrap for this repository.
#
# This script prepares any coding agent (Claude Code, Cursor, etc.) to work
# on this repo. It handles:
# 1. Nix flake input seeding (so `nix develop` works in cloud environments)
# 2. bd (beads) installation and pin verification
# 3. Issue tracker sync from the Dolt remote
# 4. Optional cargo workspace warming (inside nix develop)
#
# Usage:
# scripts/agent-bootstrap.sh [--skip-cargo-warm] [--skip-nix-seed]
#
# The script is idempotent and safe to re-run. Nothing here writes to tracked
# files, stages anything, or creates a commit.
#
# IMPORTANT: This repo requires `nix develop` (or direnv) for builds/tests.
# Bare system cargo that fails edition2024 is a misconfigured session.
#
# See also: `.claude/hooks/session-start.sh` (Claude Code wrapper),
# `.cursor/environment.json` (Cursor cloud config)
set -uo pipefail
SKIP_CARGO_WARM=0
SKIP_NIX_SEED=0
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-cargo-warm) SKIP_CARGO_WARM=1; shift ;;
--skip-nix-seed) SKIP_NIX_SEED=1; shift ;;
*) shift ;;
esac
done
ROOT="${AGENT_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}}"
cd "$ROOT" || exit 0
log() { printf '[agent-bootstrap] %s\n' "$*"; }
nix_status=0
ensure_nix_daemon() {
command -v nix >/dev/null 2>&1 || return 1
# Single-user Nix does not need a daemon; multi-user/cloud installs do.
# If the store already answers, leave the environment alone.
if nix --extra-experimental-features 'nix-command' store ping >/dev/null 2>&1; then
return 0
fi
if ! command -v nix-daemon >/dev/null 2>&1; then
log "ERROR: Nix is installed, but the store is unavailable and nix-daemon is not on PATH"
return 1
fi
log "starting nix-daemon for bootstrap…"
nix-daemon >/tmp/nix-daemon.log 2>&1 &
# The daemon is started asynchronously; wait briefly for its socket before
# invoking the seed/build steps that depend on it.
for _ in {1..50}; do
if nix --extra-experimental-features 'nix-command' store ping --store daemon >/dev/null 2>&1; then
return 0
fi
sleep 0.1
done
log "ERROR: nix-daemon did not become ready; see /tmp/nix-daemon.log"
return 1
}
# Start the daemon during cloud-agent installation, before any required Nix
# operation. This is harmless for single-user Nix because nix-store --ping
# succeeds without starting a daemon.
if [ "$SKIP_NIX_SEED" -eq 0 ] || [ "$SKIP_CARGO_WARM" -eq 0 ]; then
if ! ensure_nix_daemon; then
nix_status=1
fi
fi
# ── 0. Seed nix flake inputs (required for cloud environments) ─────────────
# GitHub tarball endpoints are blocked in cloud containers. This seeds the
# flake inputs over git so `nix develop` can build the dev shell.
if [ "$SKIP_NIX_SEED" -eq 0 ]; then
if [ "$nix_status" -ne 0 ]; then
log "ERROR: cannot seed flake inputs because Nix is unavailable"
elif ! command -v nix >/dev/null 2>&1; then
log "ERROR: nix not available; required flake input seeding cannot run"
nix_status=1
else
# Set SSL cert for proxy environments
if [ -z "${NIX_SSL_CERT_FILE:-}" ] && [ -r /root/.ccr/ca-bundle.crt ]; then
export NIX_SSL_CERT_FILE=/root/.ccr/ca-bundle.crt
fi
if [ ! -f "$ROOT/scripts/nix-seed-flake-inputs.sh" ]; then
log "ERROR: required scripts/nix-seed-flake-inputs.sh is missing"
nix_status=1
else
log "seeding nix flake inputs (GitHub tarballs blocked in cloud)…"
if "$ROOT/scripts/nix-seed-flake-inputs.sh" >/tmp/nix-seed.log 2>&1; then
log "flake inputs seeded"
else
log "ERROR: nix-seed-flake-inputs.sh failed; see /tmp/nix-seed.log"
log " 'nix develop' is not validated for this session."
nix_status=1
fi
fi
fi
fi
# ── 1. The bd on PATH must be the bd flake.nix pins ────────────────────────
beads_pin() {
sed -n 's|.*beads\.url[[:space:]]*=[[:space:]]*"github:[^/]*/[^/]*/v\{0,1\}\([^"]*\)".*|\1|p' \
flake.nix 2>/dev/null | head -1
}
bd_running_version() {
bd version 2>/dev/null | sed -n 's/^bd version \([0-9][0-9.]*\).*/\1/p' | head -1
}
bd_pin_banner() {
{
printf '\n[agent-bootstrap] ===== BEADS VERSION SKEW =====\n'
printf '[agent-bootstrap] %s\n' "$@"
printf '[agent-bootstrap] =============================\n\n'
} >&2
}
assert_bd_pin() {
command -v bd >/dev/null 2>&1 || return 0
pinned=$(beads_pin)
running=$(bd_running_version)
case "$pinned" in
[0-9]*.[0-9]*.[0-9]*) ;;
*)
bd_pin_banner "cannot read the beads pin out of flake.nix" \
"read: '${pinned:-<nothing>}'" \
"The pin moved to a ref this hook cannot parse (a branch or a" \
"commit, perhaps). Teach beads_pin() to read it, or the check" \
"silently protects nothing."
return 1
;;
esac
if [ -z "$running" ]; then
bd_pin_banner "cannot read the version of the bd on PATH" \
"bd: $(command -v bd)" \
"'bd version' did not answer in the expected shape. Until it does," \
"nothing here can tell whether that build matches the pin."
return 1
fi
[ "$running" = "$pinned" ] && return 0
bd_pin_banner "the bd on PATH is NOT the bd this repo pins" \
"pinned (flake.nix): $pinned" \
"running: $running ($(command -v bd))" \
"" \
"Reading is safe. Do NOT run a mutating bd command — create, update," \
"close, dep, dolt push. Version skew between builds empties the" \
"dependency graph in .beads/issues.jsonl and reports success." \
"" \
"Fix now: nix develop (in this directory), then re-run the" \
" command; or 'direnv allow' here." \
"Fix for good: worktrees are created fresh per dispatch, so allowing" \
" them one at a time never catches the next one. Put the" \
" repo root under [whitelist] prefix in" \
" ~/.config/direnv/direnv.toml."
return 1
}
pin_status=0
assert_bd_pin || pin_status=2
# Install a verified database at .beads/embeddeddolt.
install_tracker() {
staged=$1
live=.beads/embeddeddolt
incoming=$live.incoming
previous=$live.previous
rm -rf "$incoming" "$previous" || return 1
cp -rf "$staged" "$incoming" || { rm -rf "$incoming"; return 1; }
if [ -d "$live" ] && ! mv -f "$live" "$previous"; then
rm -rf "$incoming"
return 1
fi
if mv -f "$incoming" "$live"; then
rm -rf "$previous"
return 0
fi
if [ -d "$previous" ] && ! mv -f "$previous" "$live"; then
log "ERROR: tracker left at $previous — move it back to $live by hand"
fi
rm -rf "$incoming"
return 1
}
# ── 2. bd on PATH ──────────────────────────────────────────────────────────
export PATH="$PATH:/root/go/bin:$HOME/go/bin"
BD_INSTALLER_REF=f45b249ce6b40ba62aecc03949e6371e8f7c79d8 # tag v1.3.0
BD_INSTALLER_URL="https://raw.githubusercontent.com/steveyegge/beads/$BD_INSTALLER_REF/scripts/install.sh"
if ! command -v bd >/dev/null 2>&1; then
log "installing bd (beads v1.3.0)…"
if ! curl -fsSL "$BD_INSTALLER_URL" -o /tmp/bd-install.sh 2>/tmp/bd-install.log; then
log "WARNING: could not download the bd installer; see /tmp/bd-install.log."
elif bash /tmp/bd-install.sh >>/tmp/bd-install.log 2>&1; then
log "bd installed"
else
log "WARNING: bd install failed; see /tmp/bd-install.log (the script that"
log " ran is at /tmp/bd-install.sh). Issue tracking unavailable."
fi
fi
# Export PATH for the calling environment if AGENT_ENV_FILE is set
if [ -n "${AGENT_ENV_FILE:-${CLAUDE_ENV_FILE:-}}" ]; then
env_file="${AGENT_ENV_FILE:-${CLAUDE_ENV_FILE}}"
echo 'export PATH="$PATH:/root/go/bin:$HOME/go/bin"' >> "$env_file"
fi
if [ "$pin_status" -eq 0 ]; then
assert_bd_pin || pin_status=2
fi
# Do not run any tracker/config mutation through a bd binary whose version does
# not match the repository pin.
if [ "$pin_status" -eq 0 ] && command -v bd >/dev/null 2>&1; then
bd metrics off >/dev/null 2>&1 || true
fi
# ── 3. Sync the issue tracker from the Dolt remote ─────────────────────────
if [ "$pin_status" -eq 0 ] && command -v bd >/dev/null 2>&1; then
DB_NAME="$(jq -r '.dolt_database // "improvise"' .beads/metadata.json 2>/dev/null || echo improvise)"
ORIGIN="$(git remote get-url origin 2>/dev/null || true)"
case "$ORIGIN" in
git@github.com:*)
ORIGIN="https://github.com/${ORIGIN#git@github.com:}"
git remote add gh "$ORIGIN"
;;
esac
ORIGIN="${ORIGIN%.git}"
if [ -n "$ORIGIN" ] && TMP=$(mktemp -d) && [ -d "$TMP" ]; then
log "syncing issue tracker from $ORIGIN …"
STAGE="$TMP/$DB_NAME"
mkdir -p "$STAGE"
sync_ok=0
if (
cd "$STAGE" && git init -q . &&
BD_NON_INTERACTIVE=1 bd init --non-interactive --skip-agents --skip-hooks -q \
--remote "git+$ORIGIN"
) >/tmp/bd-sync.log 2>&1 && [ -d "$STAGE/.beads/embeddeddolt" ]; then
listing="$( (cd "$STAGE" && bd list --all 2>/dev/null) || true )"
case "$listing" in
"" | "No issues found."*) ;;
*) sync_ok=1 ;;
esac
fi
if [ "$sync_ok" -eq 1 ] && install_tracker "$STAGE/.beads/embeddeddolt"; then
log "issue tracker synced — 'bd ready' now reflects the remote, not issues.jsonl"
elif [ -d .beads/embeddeddolt ]; then
log "WARNING: tracker sync failed (see /tmp/bd-sync.log); kept the previous database."
log " It may be out of date — verify any bead against the code."
else
log "WARNING: tracker sync failed; see /tmp/bd-sync.log."
log " .beads/issues.jsonl is a STALE export — verify any bead against the code."
fi
rm -rf "$TMP"
elif [ -n "$ORIGIN" ]; then
log "WARNING: could not create a temporary directory; skipped the tracker sync."
log " .beads/issues.jsonl is a STALE export — verify any bead against the code."
fi
fi
# ── 4. Warm the build inside nix develop (optional) ────────────────────────
# Uses nix develop to ensure the correct toolchain (rust from flake, wasm target, etc.)
if [ "$SKIP_CARGO_WARM" -eq 0 ]; then
if [ "$nix_status" -ne 0 ]; then
log "ERROR: skipping required cargo warm because Nix bootstrap already failed"
elif ! command -v nix >/dev/null 2>&1; then
log "ERROR: nix not available; required cargo warm cannot run"
nix_status=1
else
log "warming cargo build inside nix develop…"
NIX_FLAGS=(--extra-experimental-features 'nix-command flakes')
if nix "${NIX_FLAGS[@]}" develop --command cargo build --workspace --tests >/tmp/cargo-warm.log 2>&1; then
log "workspace built (nix develop toolchain)"
else
log "ERROR: cargo build failed; see /tmp/cargo-warm.log"
log " Required nix develop validation failed."
nix_status=1
fi
fi
fi
# ── 5. Environment notes ───────────────────────────────────────────────────
cat <<'NOTES'
[agent-bootstrap] Environment notes (see context/cloud-agents.md):
• This repo REQUIRES `nix develop` (or direnv) for builds and tests.
Bare system cargo that fails edition2024 is a misconfigured session.
• The bootstrap seeded flake inputs over git (GitHub tarballs are blocked
in cloud). If nix develop still fails, check /tmp/nix-seed.log.
• `bd dolt push` is blocked outbound (403). Bead status changes stay local.
• Never run a bare `bd init` in this repo: it rewrites AGENTS.md, CLAUDE.md
and .claude/settings.json, and commits. Use --skip-agents --skip-hooks.
• Use `scripts/kb` for knowledge base operations, not Prolog MCP.
• Run commands inside `nix develop` or prefix with `nix develop --command`.
NOTES
exit_status="$nix_status"
if [ "$pin_status" -ne 0 ]; then
exit_status="$pin_status"
fi
exit "$exit_status"
+2
View File
@@ -82,6 +82,8 @@ const SECTIONS = [
["context/test-evidence/cli.md", "test-evidence/cli", "The headless CLI"],
["context/test-evidence/gp.md", "test-evidence/gp", "gp-tree and improvise-gp"],
["context/test-evidence/tooling.md", "test-evidence/tooling", "The repository's own gates"],
["context/test-evidence/layout-cost.md", "test-evidence/layout-cost", "Layout and keypress cost"],
["context/test-evidence/scale-bisect.md", "test-evidence/scale-bisect", "Scale bisect"],
],
},
{
+2
View File
@@ -101,6 +101,8 @@ ci_suites() {
case "$1" in
build) echo assemble-stamp ;;
js) echo node-test ;;
# Needs improvise and code-maat, which only this job has (tooling.md section 7).
nix-build) echo repo-stats ;;
# check-harness-lock must never read "could not resolve" as "stale" (improvise-zs2r).
kb) echo kb checkers pr-watch release-preflight pr-reviews fmt-workspaces bd-audit-ids check-harness-lock agent-git ;;
*) return 1 ;;
+46 -9
View File
@@ -5,36 +5,47 @@ set -euo pipefail
usage() {
cat >&2 <<'EOF'
usage: pr-watch <pr-number> [--expect <sha>] [--timeout <sec>] [--interval <sec>]
usage: pr-watch <pr-number> [--expect <sha>] [--timeout <sec>] [--interval <sec>] [--reviews|--no-reviews]
--expect <sha> fail if the PR head is not this (prefix ok) at any poll
--timeout <sec> give up after this long (default 1800)
--interval <sec> seconds between polls (default 20)
--reviews after checks complete, print reviews/comments (default)
--no-reviews skip the reviews/comments printout
exit 0 every check COMPLETED, none failed, and GitHub says MERGEABLE with a
state of CLEAN, UNSTABLE or HAS_HOOKS
state of CLEAN, UNSTABLE or HAS_HOOKS (and reviews fetched if --reviews)
exit 1 a check failed, or the PR is not mergeable (any other known state)
exit 2 the head moved during the wait, or is not --expect
exit 3 timed out with checks still running, or mergeability still UNKNOWN,
or still BLOCKED with every registered check green
exit 4 usage or environment error
exit 5 the PR is MERGED or CLOSED, so there is nothing left to wait for
exit 6 checks green/mergeable, but pr-reviews failed (fetch error, not "no reviews")
After exit 0, reviews and comments are printed (unless --no-reviews). Reviews are
a separate API surface from comments; Codex/ChatGPT reviews arrive as *reviews*
and are easy to miss if you only read the comment thread.
EOF
exit 4
}
PR=""; EXPECT=""; TIMEOUT=1800; INTERVAL=20
PR=""; EXPECT=""; TIMEOUT=1800; INTERVAL=20; SHOW_REVIEWS=1
while [ $# -gt 0 ]; do
case "$1" in
--expect) EXPECT="${2:-}"; shift 2 ;;
--timeout) TIMEOUT="${2:-}"; shift 2 ;;
--interval) INTERVAL="${2:-}"; shift 2 ;;
-h|--help) usage ;;
*) [ -z "$PR" ] || usage; PR="$1"; shift ;;
--expect) EXPECT="${2:-}"; shift 2 ;;
--timeout) TIMEOUT="${2:-}"; shift 2 ;;
--interval) INTERVAL="${2:-}"; shift 2 ;;
--reviews) SHOW_REVIEWS=1; shift ;;
--no-reviews) SHOW_REVIEWS=0; shift ;;
-h|--help) usage ;;
*) [ -z "$PR" ] || usage; PR="$1"; shift ;;
esac
done
[ -n "$PR" ] || usage
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# `gh` is shell-aliased to a different tool in this repo's shells, so the name
# is never trusted: a real gh is located explicitly.
fetch() {
@@ -130,4 +141,30 @@ echo "pr-watch: head=$head $mergeable/$state $total checks, $failed failed"
# An allowlist, so a state GitHub adds later fails closed. DIRTY and BLOCKED
# can wear a MERGEABLE flag: context/code/tooling.md, "pr-watch and mergeability".
[ "$mergeable" = MERGEABLE ] || exit 1
case "$state" in CLEAN|UNSTABLE|HAS_HOOKS) exit 0 ;; *) exit 1 ;; esac
case "$state" in CLEAN|UNSTABLE|HAS_HOOKS) ;; *) exit 1 ;; esac
# ── Reviews and comments ───────────────────────────────────────────────────
# Checks are green and mergeable. Show reviews/comments so the agent sees any
# feedback that needs answering before merge. This does not change the exit
# code for success -- answering reviews is your responsibility. However, if
# pr-reviews itself fails (usage error, network, etc.), we warn and exit 6.
if [ "$SHOW_REVIEWS" -eq 1 ]; then
echo ""
echo "pr-watch: fetching reviews and comments (--no-reviews to skip)…"
reviews_exit=0
if [ -n "${PR_WATCH_FIXTURE_DIR:-}" ]; then
PR_REVIEWS_FIXTURE_DIR="$PR_WATCH_FIXTURE_DIR" "$SCRIPT_DIR/pr-reviews" "$PR" || reviews_exit=$?
else
"$SCRIPT_DIR/pr-reviews" "$PR" || reviews_exit=$?
fi
echo ""
if [ "$reviews_exit" -eq 0 ]; then
echo "pr-watch: reviews shown above. Answer substantive feedback before merging."
else
echo "pr-watch: WARNING: pr-reviews failed (exit $reviews_exit). Reviews may not be shown." >&2
echo "pr-watch: Run './scripts/pr-reviews $PR' manually to fetch reviews." >&2
exit 6
fi
fi
exit 0
+71
View File
@@ -20,8 +20,39 @@ run_case() {
[ "$got" = "$want" ] && ok "$name" || bad "$name" "$got" "$want"
}
# Like run_case but writes output to a caller-provided file.
# Caller must NOT use command substitution (subshell loses ok/bad counters).
# $1 name, $2 expected exit, $3 fixture dir, $4 output file, rest: extra args
run_case_capture() {
local name="$1" want="$2" dir="$3" outfile="$4"; shift 4
PR_WATCH_FIXTURE_DIR="$dir" "$WATCH" 1 --interval 0 --timeout 1 "$@" >"$outfile" 2>&1
local got=$?
[ "$got" = "$want" ] && ok "$name" || bad "$name" "$got" "$want"
}
mk() { mkdir -p "$1"; cat > "$1/$2"; }
# Create empty pr-reviews fixtures so pr-watch can call pr-reviews in fixture mode.
# $1 dir, $2 head (must match the pr-watch fixture's headRefOid)
mk_reviews_empty() {
local dir="$1" head="$2"
mkdir -p "$dir"
printf '{"headRefOid":"%s","reviews":[]}\n' "$head" > "$dir/last.pr.json"
echo '[]' > "$dir/last.inline.json"
echo '[]' > "$dir/last.issue.json"
}
# Create pr-reviews fixtures with a review. $1 dir, $2 head, $3 review commit
mk_reviews_with_review() {
local dir="$1" head="$2" review_commit="$3"
mkdir -p "$dir"
cat > "$dir/last.pr.json" <<EOF
{"headRefOid":"$head","reviews":[{"submittedAt":"2026-09-25T12:00:00Z","state":"COMMENTED","author":{"login":"copilot"},"commit":{"oid":"$review_commit"},"body":"Test review body"}]}
EOF
echo '[]' > "$dir/last.inline.json"
echo '[]' > "$dir/last.issue.json"
}
# The state a fresh push is in: the PR exists, no check has registered yet.
# A watcher keyed on "nothing pending" calls this finished and green.
mk "$work/none" last.json <<'EOF'
@@ -43,8 +74,43 @@ mk "$work/green" last.json <<'EOF'
{"name":"build","status":"COMPLETED","conclusion":"SUCCESS"},
{"name":"skipped","status":"COMPLETED","conclusion":"SKIPPED"}]}
EOF
mk_reviews_empty "$work/green" "aaaaaaaaaaaa"
run_case "all completed and green" 0 "$work/green"
# ── Reviews integration ────────────────────────────────────────────────────
# pr-watch now calls pr-reviews after checks are green. Test that it works.
mk "$work/green-reviews" last.json <<'EOF'
{"headRefOid":"aaaaaaaaaaaa","mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","statusCheckRollup":[
{"name":"build","status":"COMPLETED","conclusion":"SUCCESS"}]}
EOF
mk_reviews_with_review "$work/green-reviews" "aaaaaaaaaaaa" "aaaaaaaaaaaa"
run_case_capture "green with reviews shows review output" 0 "$work/green-reviews" "$work/out1.txt"
if grep -q "pr-reviews:" "$work/out1.txt"; then
ok "green with reviews prints pr-reviews output"
else
bad "green with reviews prints pr-reviews output" "no pr-reviews output" "pr-reviews: in output"
fi
# --no-reviews skips the review printout
run_case_capture "--no-reviews skips review output" 0 "$work/green-reviews" "$work/out2.txt" --no-reviews
if grep -q "pr-reviews:" "$work/out2.txt"; then
bad "--no-reviews skips review output" "pr-reviews in output" "no pr-reviews"
else
ok "--no-reviews actually skips review output"
fi
# pr-reviews failure: if pr-reviews fails, pr-watch exits 6 (not 0)
# Create a green fixture with MALFORMED review fixtures so pr-reviews fails
mk "$work/green-bad-reviews" last.json <<'EOF'
{"headRefOid":"aaaaaaaaaaaa","mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","statusCheckRollup":[
{"name":"build","status":"COMPLETED","conclusion":"SUCCESS"}]}
EOF
# Create malformed JSON that will cause jq to fail in pr-reviews
echo 'NOT VALID JSON' > "$work/green-bad-reviews/last.pr.json"
echo '[]' > "$work/green-bad-reviews/last.inline.json"
echo '[]' > "$work/green-bad-reviews/last.issue.json"
run_case "pr-reviews failure exits 6" 6 "$work/green-bad-reviews"
mk "$work/red" last.json <<'EOF'
{"headRefOid":"aaaaaaaaaaaa","mergeable":"MERGEABLE","mergeStateStatus":"UNSTABLE","statusCheckRollup":[
{"name":"build","status":"COMPLETED","conclusion":"FAILURE"}]}
@@ -112,6 +178,10 @@ for m in MERGEABLE CONFLICTING UNKNOWN; do
fi
sv="\"$s\""; [ "$s" = null ] && sv=null
state "$work/table-$m-$s" last.json "\"$m\"" "$sv" "$green_rollup"
# Add review fixtures for cases that exit 0 (pr-watch calls pr-reviews)
if [ "$want" = 0 ]; then
mk_reviews_empty "$work/table-$m-$s" "aaaaaaaaaaaa"
fi
run_case "table: $m/$s" "$want" "$work/table-$m-$s" --timeout 0
done
done
@@ -148,6 +218,7 @@ gap_state "$work/gap" 1.json MERGEABLE BLOCKED "$not_yet | $build_running"
gap_state "$work/gap" 2.json MERGEABLE BLOCKED "$not_yet"
gap_state "$work/gap" 3.json MERGEABLE BLOCKED "$harness_running"
gap_state "$work/gap" last.json MERGEABLE CLEAN '.'
mk_reviews_empty "$work/gap" "aaaaaaaaaaaa"
run_case "the needs-gap before the required check registers is not a failure" 0 "$work/gap" --timeout 60
gap_state "$work/gap-never" last.json MERGEABLE BLOCKED "$not_yet"
+321
View File
@@ -0,0 +1,321 @@
#!/usr/bin/env python3
"""Churn, coupling and doc-growth report over a window of history.
code-maat's analyses and this script's per-file churn go into one directory of
CSVs; improvise imports them into one model and computes the report's tables.
See context/code/tooling.md section 7.
"""
import argparse
import csv
import datetime
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/")),
("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", "other docs", "repo-map",
"agent docs and skills"}
CODE = {"rust src", "rust tests", "scripts/CI", "web and harness"}
AGENT_RULES = "agent docs and skills"
# One CSV per code-maat analysis, plus churn.csv. Each stem becomes an item of
# the model's File category, which the .improvise script pages on.
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__))
class ImproviseFailed(Exception):
"""improvise refused the pipeline's input: a product regression, not a statistic."""
def area_of(path):
for name, rx in AREAS:
if rx.search(path):
return name
return OTHER
def kind_of(area):
return "prose" if area in PROSE else "code" if area in CODE else "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 is_header(line):
return line.startswith("--") and line.count("--") >= 3
def churn_rows(log):
"""One row per (commit, file). A commit is written `g<sha>`, because improvise
reads an all-digit or exponent-shaped sha as a number (tooling.md section 7)."""
rows, commit, date = [], None, None
for line in log.split("\n"):
if is_header(line):
_, commit, date, _ = line.split("--", 3)
elif "\t" in line:
a, d, path = line.split("\t", 2)
year, week, _ = datetime.date.fromisoformat(date).isocalendar()
area = area_of(path)
rows.append({"commit": "g" + commit, "date": date,
"week": f"{year}-W{week:02d}", "entity": path, "area": area,
"kind": kind_of(area), "added": 0 if a == "-" else int(a),
"deleted": 0 if d == "-" else int(d)})
return rows
def write_csv(path, header, rows):
with open(path, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=header)
w.writeheader()
w.writerows(rows)
def read_csv(path):
with open(path, newline="") as f:
return list(csv.DictReader(f))
def code_maat(logfile, analysis, out, *extra):
subprocess.run(["code-maat", "-l", logfile, "-c", "git2", "-a", analysis,
"-o", out, *extra], capture_output=True, text=True, check=True)
def flag_coupling(path):
"""code-maat's coupling.csv, plus the two flags code-maat has no notion of."""
rows = read_csv(path)
for r in rows:
a, b = crate_of(r["entity"]), crate_of(r["coupled"])
r["crosses-crate"] = "yes" if a and b and a != b else "no"
both = area_of(r["entity"]) == area_of(r["coupled"]) == AGENT_RULES
r["agent-rules"] = "yes" if both else "no"
write_csv(path, ["entity", "coupled", "degree", "average-revs", "crosses-crate",
"agent-rules"], rows)
def build_dir(out, log, rows, min_revs, today):
"""The directory improvise imports: churn.csv and one CSV per analysis."""
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)]
extra = {"coupling": revs, "age": ["--age-time-now", today]}
for a in ANALYSES:
code_maat(f.name, a, os.path.join(out, f"{a}.csv"), *extra.get(a, []))
finally:
os.unlink(f.name)
flag_coupling(os.path.join(out, "coupling.csv"))
def improvise(out, *args):
exe = os.environ.get("IMPROVISE", "improvise")
p = subprocess.run([exe, *args], cwd=out, capture_output=True, text=True)
if p.returncode != 0 or "warning:" in p.stderr:
raise ImproviseFailed(f"improvise {' '.join(args)} exited {p.returncode}:\n"
f"{p.stderr}")
def run_improvise(out):
"""Import the directory into one model, a view per file included, then build
the report's views and export them."""
flags = [f"--category={c}" for c in CATEGORIES] + [f"--measure={m}" for m in MEASURES]
improvise(out, "import", ".", "--no-wizard", *flags, "--name=Repo stats",
"--formula=DeletionRatio = deleted / added FORMAT .3",
"-o", "repo-stats.improv")
os.makedirs(os.path.join(out, "report"), exist_ok=True)
improvise(out, "script", os.path.join(HERE, "repo-stats.improvise"),
"-f", "repo-stats.improv")
return {t: read_rows(os.path.join(out, "report", f"{t}.csv")) for t in TABLES}
def read_rows(path):
with open(path, newline="") as f:
return list(csv.reader(f))
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 report(repo, ref, since, min_revs, out):
name, sha = resolve_ref(repo, ref)
log = git2_log(repo, sha, since)
commits = sum(1 for line in log.split("\n") if is_header(line))
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": []}
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"))
return r
def table(rows):
"""A CSV improvise exported, as aligned text; the first column left-aligned."""
n = max(len(r) for r in rows)
widths = [max(len(r[i]) for r in rows if i < len(r)) for i in range(n)]
return [(" " + " ".join((c.ljust if i == 0 else c.rjust)(widths[i])
for i, c in enumerate(r))).rstrip() for r in rows]
TITLES = {
"by-area": "Churn by area (improvise)",
"by-kind": "Prose, code and other; ProseToCode = prose / code (improvise)",
"area-by-week": "Lines added by area and ISO week (improvise)",
"by-day": "Lines added per day, churn.csv beside code-maat's abs-churn (improvise)",
}
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])
out += ["", "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']}")
out += ["", f"Change coupling (code-maat; top {top})"]
for c in r["coupling"][:top]:
flags = [f for f, k in (("CROSSES-CRATE", "crosses-crate"),
("AGENT-RULES", "agent-rules")) if c[k] == "yes"]
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 r["soc"][:top]]
out += ["", f"Code age in months (code-maat; first {top})"]
out += [f" {a['age-months']:>4} {a['entity']}" for a in r["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("--out", default="repo-stats",
help="the directory for the CSVs, the model and its exports")
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()
os.makedirs(a.out, exist_ok=True)
ours = {f"{n}.csv" for n in ["churn"] + ANALYSES}
foreign = sorted(f for f in os.listdir(a.out) if f.lower().endswith(".csv") and f not in ours)
if foreign:
print(f"repo-stats: {a.out} holds CSVs this script did not write, and "
f"`improvise import` would merge them: {', '.join(foreign)}", file=sys.stderr)
return 2
try:
r = report(a.repo, a.ref, a.since, a.min_revs, os.path.abspath(a.out))
except ImproviseFailed as e:
print(f"repo-stats: {e}", file=sys.stderr)
return 3
print(json.dumps(r, indent=2) if a.json else text(r, a.top))
return 0
if __name__ == "__main__":
sys.exit(main())
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env bash
# check_the_checker for scripts/repo-stats (improvise-1xfb.5.1): the whole pipeline,
# code-maat and improvise included, over a fixture repo whose every number is known.
# 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)); }
# 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)"
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
# $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 table, $3 row label, $4 the row's cells after the label, as JSON
row() {
is "$1" ".tables[\"$2\"][]|select(.[0]==\"$3\")|.[1:]" "$4"
}
# $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 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 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 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
# One file in each remaining area. The repo map is 13 lines.
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.
---
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
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
# 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 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() {
local out="$1" text="$2"; shift 2
"$STATS" -C "$repo" --since 2026-06-01 --min-revs 2 --out "$out" "$@" --json \
> "$work/out.json" 2> "$work/err" \
|| { bad "repo-stats $* ran" "$(cat "$work/err")"; return 1; }
[ "$text" = text ] || return 0
"$STATS" -C "$repo" --since 2026-06-01 --min-revs 2 --out "$out" "$@" \
> "$work/out.txt" 2>&1 \
|| { bad "repo-stats $* ran (text)" "$(cat "$work/out.txt")"; return 1; }
}
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" '["45","25","0.556"]'
row "rust tests are the tests/ files" by-area "rust tests" '["8","4","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, 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, .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, 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 '["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 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"]]'
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 improvise's ratio row" "ProseToCode"
views="$(grep -c '^## View: ' "$work/stats/repo-stats.improv")"
[ "$views" = 10 ] && ok "the model keeps a view per file and the report's four" \
|| bad "the model keeps a view per file and the report's four" "$views views"
# Sparse, not padded: a padded merge has the same numbers and views, but a
# `-` item in every category and per-file views seated on the padding.
dashes="$(grep -c '|-|' "$work/stats/repo-stats.improv")"
[ "$dashes" = 0 ] && ok "the sparse model has no padding item" \
|| bad "the sparse model has no padding item" "$dashes lines name |-|"
churn_view="$(awk '/^## View: churn$/,/^$/' "$work/stats/repo-stats.improv")"
for seat in "date: row" "agent-rules: none" "coupled: none" "crosses-crate: none"; do
grep -qx -- "$seat" <<< "$churn_view" && ok "the churn view seats $seat" \
|| bad "the churn view seats $seat" "$(tr '\n' ' ' <<< "$churn_view")"
done
fi
# An --out holding a CSV this script did not write is refused, since the folder
# import would merge it. The script's own CSVs, from a rerun, are not foreign.
mkdir -p "$work/foreign"; echo "x,y" > "$work/foreign/other.csv"
"$STATS" -C "$repo" --since 2026-06-01 --out "$work/foreign" > /dev/null 2>&1
got=$?
[ "$got" = 2 ] && ok "a foreign CSV in --out is refused with exit 2" \
|| bad "a foreign CSV in --out is refused with exit 2" "exit $got"
# The window is scoped to the ref: at the tag, the shrink has not happened.
if run "$work/at-tag" json --ref before-shrink; then
row "--ref scopes the log" by-area "web and harness" '["11","0","0.000"]'
is "--ref names the ref it read" '.ref' '"before-shrink"'
fi
# 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"
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=$?
[ "$got" = 3 ] && ok "an improvise that $stub fails the pipeline with exit 3" \
|| bad "an improvise that $stub fails the pipeline with exit 3" "exit $got"
done
printf '\nrepo-stats: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" = 0 ]
+87
View File
@@ -0,0 +1,87 @@
# The views scripts/repo-stats builds over the model it imported, run by
# `improvise script` with the output directory as the working directory.
# Every `export` here is a table the text report prints. See
# context/code/tooling.md section 7.
# The prose-to-code ratio is a formula over the kind category's own items.
add-formula kind "ProseToCode = prose / code FORMAT .3"
# Churn by area, with the deletion ratio (a _Measure formula from the import).
create-view "By area"
switch-view "By area"
set-axis File page
set-page File churn
set-axis area row
set-axis _Measure column
set-axis kind none
set-axis week none
set-axis date none
set-axis commit none
set-axis entity none
set-axis coupled none
set-axis crosses-crate none
set-axis agent-rules none
toggle-prune-empty
set-format .0
export report/by-area.csv
# Prose, code and other, and the ratio of the first two.
create-view "By kind"
switch-view "By kind"
set-axis File page
set-page File churn
set-axis kind row
set-axis _Measure column
set-axis area none
set-axis week none
set-axis date none
set-axis commit none
set-axis entity none
set-axis coupled none
set-axis crosses-crate none
set-axis agent-rules none
toggle-prune-empty
set-format .0
export report/by-kind.csv
# Lines added per area per ISO week.
create-view "Area by week"
switch-view "Area by week"
set-axis File page
set-page File churn
set-axis area row
set-axis week column
set-axis _Measure page
set-page _Measure added
set-axis kind none
set-axis date none
set-axis commit none
set-axis entity none
set-axis coupled none
set-axis crosses-crate none
set-axis agent-rules none
toggle-prune-empty
set-format .0
export report/area-by-week.csv
# Lines added per day, from both sources side by side: this script's churn
# rows and code-maat's abs-churn. The two columns must agree.
create-view "By day"
switch-view "By day"
set-axis date row
set-axis File column
set-axis _Measure page
set-page _Measure added
set-axis area none
set-axis kind none
set-axis week none
set-axis commit none
set-axis entity none
set-axis coupled none
set-axis crosses-crate none
set-axis agent-rules none
toggle-prune-empty
set-format .0
export report/by-day.csv
switch-view "By area"