aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-15 17:18:55 -0500
committerCraig Jennings <c@cjennings.net>2026-05-15 17:18:55 -0500
commit94782eea3df22289fb556481f9569a9284c7ac50 (patch)
tree829e3aceed7f0923fe797417f44df76328767c51 /scripts
parente0f60029ffe0f0b6a24e4b7d207b326a9affc985 (diff)
downloadrulesets-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-xscripts/audit.sh232
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