Merge remote-tracking branch 'gh/main' into adr5-steward-b2
This commit is contained in:
Executable
+323
@@ -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"
|
||||
@@ -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"],
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Executable
+321
@@ -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())
|
||||
Executable
+233
@@ -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 ]
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user