aboutsummaryrefslogtreecommitdiff
path: root/scripts/audit.sh
blob: 86eeb76c11486989669bf4f6cfbaa02f377e2651 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
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