#!/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 <&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