diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-15 17:18:55 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-15 17:18:55 -0500 |
| commit | 94782eea3df22289fb556481f9569a9284c7ac50 (patch) | |
| tree | 829e3aceed7f0923fe797417f44df76328767c51 /scripts | |
| parent | e0f60029ffe0f0b6a24e4b7d207b326a9affc985 (diff) | |
| download | rulesets-94782eea3df22289fb556481f9569a9284c7ac50.tar.gz rulesets-94782eea3df22289fb556481f9569a9284c7ac50.zip | |
feat(make): add audit target for cross-project .ai/ drift detection
scripts/audit.sh walks every .ai/-using project under ~/code/, ~/projects/, and ~/.emacs.d/, compares each .ai/ against the canonical source at claude-templates/.ai/, and reports drift per project. Default mode is report-only; APPLY=1 rsyncs detected drift into each project (no auto-commit). FORCE=1 also rsyncs into projects with uncommitted .ai/ changes (default: skip with a warning).
Uses diff -rq for content comparison rather than rsync --itemize-changes to avoid false positives on attribute-only drift (mtime, permissions). Skips the rulesets repo itself, the in-repo canonical source, and the legacy standalone ~/projects/claude-templates/ during the fold transition.
Output mirrors make doctor: per-project ok/drift/applied/skipped/FAIL lines, summary tally, exit 0 when all ok. Runs make doctor as the final check by default; NO_DOCTOR=1 skips.
Diffstat (limited to 'scripts')
| -rwxr-xr-x | scripts/audit.sh | 232 |
1 files changed, 232 insertions, 0 deletions
diff --git a/scripts/audit.sh b/scripts/audit.sh new file mode 100755 index 0000000..86eeb76 --- /dev/null +++ b/scripts/audit.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +# audit.sh — cross-project .ai/ drift detector. +# +# Walks every .ai/-using project on the machine, diffs the synced template +# files against the canonical source under rulesets/claude-templates/.ai/, +# and reports drift line-by-line: ok / drift / applied / skipped / FAIL. +# +# Read-only by default. Pass --apply to rsync drift into each project's +# working tree (no auto-commit). Pass --force to also rsync into projects +# with uncommitted .ai/ changes (default: skip those). +# +# Exit 0 when everything's ok. Exit 1 if anything drifted, was applied, +# was skipped, or failed. +# +# Run from the repo root via `make audit`. + +set -uo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +CANONICAL="$REPO/claude-templates/.ai" + +apply=0 +force=0 +run_doctor=1 +for arg in "$@"; do + case "$arg" in + --apply) apply=1 ;; + --force) force=1 ;; + --no-doctor) run_doctor=0 ;; + -h|--help) + cat <<EOF +Usage: $(basename "$0") [--apply] [--force] [--no-doctor] + + --apply rsync drift into each project's .ai/ (working tree only). + --force rsync even into projects with uncommitted .ai/ changes. + --no-doctor skip the final make doctor sub-invocation. + +Without --apply, reports drift only; no files are written. +EOF + exit 0 + ;; + *) + echo "ERROR: unknown flag: $arg (use --help for usage)" >&2 + exit 2 + ;; + esac +done + +ok_count=0 +applied_count=0 +drift_count=0 +skipped_count=0 +fail_count=0 + +print_ok() { printf ' ok %s\n' "$1"; ok_count=$((ok_count+1)); } +print_drift() { printf ' drift %s\n' "$1"; drift_count=$((drift_count+1)); } +print_applied() { printf ' applied %s\n' "$1"; applied_count=$((applied_count+1)); } +print_skipped() { printf ' skipped %s\n' "$1"; skipped_count=$((skipped_count+1)); } +print_fail() { printf ' FAIL %s\n' "$1"; fail_count=$((fail_count+1)); } + +# Count content-level differences between canonical and project paths. +# Uses diff -rq (content comparison) rather than rsync --itemize-changes +# because the latter counts attribute-only drift (mtime, permissions) which +# the audit doesn't care about. Output of diff -rq is one line per: +# - "Files X and Y differ" (content differs) +# - "Only in X: file" (file missing on the other side) +count_drift_items() { + local src="$1" dst="$2" + if [ ! -e "$src" ]; then + echo 0 + return + fi + if [ ! -e "$dst" ]; then + if [ -d "$src" ]; then + find "$src" -mindepth 1 2>/dev/null | wc -l + else + echo 1 + fi + return + fi + if [ -d "$src" ]; then + diff -rq "$src" "$dst" 2>/dev/null | wc -l + else + if cmp -s "$src" "$dst"; then echo 0; else echo 1; fi + fi +} + +# Total drift across all three sync paths for one project. +project_drift_count() { + local proj="$1" + local p w s + p=$(count_drift_items "$CANONICAL/protocols.org" "$proj/.ai/protocols.org") + w=$(count_drift_items "$CANONICAL/workflows/" "$proj/.ai/workflows/") + s=$(count_drift_items "$CANONICAL/scripts/" "$proj/.ai/scripts/") + echo $((p + w + s)) +} + +apply_rsync() { + local proj="$1" + rsync -a "$CANONICAL/protocols.org" "$proj/.ai/protocols.org" 2>/dev/null + rsync -a --delete "$CANONICAL/workflows/" "$proj/.ai/workflows/" 2>/dev/null + rsync -a --delete "$CANONICAL/scripts/" "$proj/.ai/scripts/" 2>/dev/null +} + +# ----- 1. Canonical source check ----- +echo "Canonical source:" +if [ ! -d "$CANONICAL" ]; then + echo " FAIL $CANONICAL not found (fold not applied?)" + echo + echo "Summary: 0 ok, 0 drift, 0 applied, 0 skipped, 1 failed" + exit 1 +fi +if [ -d "$REPO/.git" ]; then + upstream=$(cd "$REPO" && git rev-parse --abbrev-ref '@{u}' 2>/dev/null || true) + if [ -n "$upstream" ]; then + counts=$(cd "$REPO" && git rev-list --left-right --count "${upstream}...HEAD" 2>/dev/null || echo "0 0") + behind=$(echo "$counts" | cut -f1) + if [ "$behind" -gt 0 ]; then + printf ' WARN rulesets is %d commits behind %s — pull first for accurate drift\n' "$behind" "$upstream" + else + printf ' ok rulesets is current (%s)\n' "$upstream" + fi + else + echo " ok rulesets has no upstream (local-only)" + fi +else + echo " WARN rulesets is not a git checkout" +fi +echo + +# ----- 2. Project discovery + per-project audit ----- +echo "Per-project .ai/ drift:" + +# Find every .ai/ dir under the conventional roots (max depth 3, prune obvious dead ends). +mapfile -t projects < <( + find "$HOME/code" "$HOME/projects" "$HOME/.emacs.d" \ + -maxdepth 3 -type d -name .ai 2>/dev/null \ + | sed 's|/\.ai$||' \ + | sort +) + +for proj in "${projects[@]}"; do + # Skip: + # - the rulesets repo itself (canonical .ai/ lives at the repo root, not under a project) + # - the canonical-source subdir (rulesets/claude-templates/.ai/ is the source, not a target) + # - the legacy standalone claude-templates checkout (frozen during fold transition) + case "$proj" in + "$REPO") continue ;; + "$REPO/claude-templates") continue ;; + "$HOME/projects/claude-templates") continue ;; + esac + + # Display path: strip $HOME prefix to ~/, otherwise leave alone. + if [ "${proj#$HOME/}" != "$proj" ]; then + short="~/${proj#$HOME/}" + else + short="$proj" + fi + + # Step 1: .ai/ exists (find guarantees this, but check anyway). + if [ ! -d "$proj/.ai" ]; then + print_fail "$short .ai/ missing" + continue + fi + + # Step 2 + 3: tracking + dirty check (tracked projects only). + dirty=0 + if [ -d "$proj/.git" ]; then + if (cd "$proj" && ! git check-ignore .ai/ >/dev/null 2>&1); then + if [ -n "$(cd "$proj" && git status --porcelain .ai/ 2>/dev/null)" ]; then + dirty=1 + fi + fi + fi + + if [ "$dirty" -eq 1 ] && [ "$force" -eq 0 ]; then + print_skipped "$short uncommitted .ai/ (use --force)" + continue + fi + + # Step 4: drift detection. + drift=$(project_drift_count "$proj") + + if [ "$drift" -eq 0 ]; then + print_ok "$short" + continue + fi + + # Drift detected. + if [ "$apply" -eq 0 ]; then + print_drift "$short $drift items differ" + continue + fi + + # Step 5: apply rsync. + apply_rsync "$proj" + + # Step 6: verify convergence. + remaining=$(project_drift_count "$proj") + if [ "$remaining" -gt 0 ]; then + print_fail "$short rsync didn't converge ($remaining items still differ)" + continue + fi + + print_applied "$short $drift items changed" +done + +# ----- summary ----- +echo +if [ "$apply" -eq 1 ]; then + echo "Summary: $ok_count ok, $applied_count applied, $skipped_count skipped, $fail_count failed" +else + echo "Summary: $ok_count ok, $drift_count drift, $skipped_count skipped, $fail_count failed" +fi + +# ----- 3. Optional doctor sub-invocation ----- +if [ "$run_doctor" -eq 1 ]; then + echo + echo "===" + echo + bash "$REPO/scripts/doctor.sh" + doctor_exit=$? +else + doctor_exit=0 +fi + +# Exit code: 1 if anything non-ok in audit, OR doctor failed. +total_non_ok=$((applied_count + drift_count + skipped_count + fail_count)) +if [ "$total_non_ok" -gt 0 ] || [ "$doctor_exit" -ne 0 ]; then + exit 1 +fi +exit 0 |
