diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-22 13:06:12 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-22 13:06:12 -0500 |
| commit | 1ceed704147a1cd6c2c9cd332d3aa33ea43bda83 (patch) | |
| tree | a76c66f491833e3403ba68b0d982c29a0cf78b48 /scripts/sync-language-bundle.sh | |
| parent | 79dad4659b8ac97ce65d958efdcd1deed66b3661 (diff) | |
| download | rulesets-1ceed704147a1cd6c2c9cd332d3aa33ea43bda83.tar.gz rulesets-1ceed704147a1cd6c2c9cd332d3aa33ea43bda83.zip | |
feat(startup): sync language bundles per project on session launch
Startup synced the .ai/ templates into the current project every session but never checked the language bundle (elisp, python) installed in .claude/. Bundle drift went unnoticed until someone re-ran make install-lang by hand: a generic rule added to claude-rules/ after the last install, or a changed validator hook.
scripts/sync-language-bundle.sh closes that gap. It fingerprints which bundle a project has by the presence of the language's own rule files (elisp.md, python-testing.md), then reconciles against the canonical source: auto-fix for rulesets-owned files (.claude/rules/*.md, .claude/hooks/*, githooks/*), surface-only for settings.json, which a project may have customized. CLAUDE.md is left alone. It's seed-only in install-lang and project-owned afterward, the same reason diff-lang skips it.
Startup Phase A step 12 calls it for the current project, guarded so older checkouts that lack the script still boot. It writes only under .claude/ and githooks/, disjoint from the .ai/ rsync paths, so the parallel batch stays safe. A script rather than a make target keeps the Makefile-parse layer off the boot path. The absolute rulesets path it depends on is the same one the rsyncs already carry.
Tested: 11 bats cases (no-bundle, clean, drifted rule/hook auto-fixed, surfaced settings.json asserted unmodified, absent CLAUDE.md not flagged, python detection, $PWD default, bad path). A smoke run against a copy of a real elisp project's .claude/ caught a perpetual "CLAUDE.md missing" alarm, which is what drove dropping CLAUDE.md from the surface set.
Diffstat (limited to 'scripts/sync-language-bundle.sh')
| -rwxr-xr-x | scripts/sync-language-bundle.sh | 142 |
1 files changed, 142 insertions, 0 deletions
diff --git a/scripts/sync-language-bundle.sh b/scripts/sync-language-bundle.sh new file mode 100755 index 0000000..8858f74 --- /dev/null +++ b/scripts/sync-language-bundle.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# Per-project language-bundle freshness check, designed to run at startup. +# +# Detects which language bundle(s) a project has by fingerprint (no marker +# file), then reconciles them against the canonical rulesets source: +# - AUTO-FIX (rulesets-owned, safe to overwrite): .claude/rules/*.md, +# .claude/hooks/*, githooks/* +# - SURFACE (project may customize — reported, never written): +# .claude/settings.json +# CLAUDE.md is intentionally not tracked: it is seed-only in install-lang +# and project-owned afterward (diff-lang skips it for the same reason). +# +# Usage: sync-language-bundle.sh [project-path] (default: $PWD) +# +# Exit: 0 no bundle, or clean / rules+hooks auto-fixed (resolved) +# 3 manual action recommended (settings.json / CLAUDE.md drift) +# 1 usage / path error +# +# Quiet when there is nothing to report. Resolves the canonical source +# relative to its own location, so it always reads the current checkout. + +set -u + +PROJECT="${1:-$PWD}" + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +LANG_ROOT="$REPO_ROOT/languages" +GENERIC_RULES="$REPO_ROOT/claude-rules" + +if [ ! -d "$PROJECT" ]; then + echo "ERROR: project path does not exist: $PROJECT" >&2 + exit 1 +fi +PROJECT="$(cd "$PROJECT" && pwd)" + +[ -d "$LANG_ROOT" ] || exit 0 # no bundles available — nothing to do + +OUT="" # buffered report, printed once at the end +FIXED=0 +MANUAL=0 +HEADER_DONE=0 +CUR_HEADER="" + +ensure_header() { + if [ "$HEADER_DONE" -eq 0 ]; then + OUT+="$CUR_HEADER"$'\n' + HEADER_DONE=1 + fi +} + +# fix SRC DST [exec] — overwrite DST from SRC if missing or differing. +fix() { + local src="$1" dst="$2" want_exec="${3:-}" + [ -f "$src" ] || return 0 + if [ ! -f "$dst" ] || ! diff -q "$src" "$dst" >/dev/null 2>&1; then + mkdir -p "$(dirname "$dst")" + cp "$src" "$dst" + [ "$want_exec" = exec ] && chmod +x "$dst" + ensure_header + OUT+=" fixed ${dst#"$PROJECT"/}"$'\n' + FIXED=$((FIXED + 1)) + fi +} + +# surface SRC DST — report DST drift without writing. +surface() { + local src="$1" dst="$2" reason="" + [ -f "$src" ] || return 0 + if [ ! -f "$dst" ]; then + reason="missing" + elif ! diff -q "$src" "$dst" >/dev/null 2>&1; then + reason="differs" + else + return 0 + fi + ensure_header + OUT+=" drift ${dst#"$PROJECT"/} ($reason — not auto-fixed)"$'\n' + MANUAL=$((MANUAL + 1)) +} + +for src_lang in "$LANG_ROOT"/*/; do + [ -d "$src_lang" ] || continue + src_lang="${src_lang%/}" + lang="$(basename "$src_lang")" + lang_rules="$src_lang/claude/rules" + [ -d "$lang_rules" ] || continue + + # Fingerprint: project has this bundle iff any of the language's own + # rule files is present in .claude/rules/. + detected=0 + for rf in "$lang_rules"/*.md; do + [ -f "$rf" ] || continue + if [ -f "$PROJECT/.claude/rules/$(basename "$rf")" ]; then + detected=1 + break + fi + done + [ "$detected" -eq 1 ] || continue + + CUR_HEADER="language bundle '$lang' — $PROJECT:" + HEADER_DONE=0 + manual_before=$MANUAL + + # AUTO-FIX: generic rules (shared) + language rules + for f in "$GENERIC_RULES"/*.md; do + [ -f "$f" ] || continue + fix "$f" "$PROJECT/.claude/rules/$(basename "$f")" + done + for f in "$lang_rules"/*.md; do + [ -f "$f" ] || continue + fix "$f" "$PROJECT/.claude/rules/$(basename "$f")" + done + + # AUTO-FIX: language hooks (executable) + if [ -d "$src_lang/claude/hooks" ]; then + while IFS= read -r f; do + rel="${f#"$src_lang"/claude/}" + fix "$f" "$PROJECT/.claude/$rel" exec + done < <(find "$src_lang/claude/hooks" -type f) + fi + + # AUTO-FIX: githooks (executable) + if [ -d "$src_lang/githooks" ]; then + while IFS= read -r f; do + rel="${f#"$src_lang"/githooks/}" + fix "$f" "$PROJECT/githooks/$rel" exec + done < <(find "$src_lang/githooks" -type f) + fi + + # SURFACE-ONLY: settings.json may carry project customization, so report + # rather than overwrite. (CLAUDE.md is intentionally untracked here — it's + # seed-only in install-lang and project-owned after, same as diff-lang skips it.) + surface "$src_lang/claude/settings.json" "$PROJECT/.claude/settings.json" + + if [ "$MANUAL" -gt "$manual_before" ]; then + OUT+=" → reconcile: make install-$lang PROJECT=."$'\n' + fi +done + +[ -n "$OUT" ] && printf '%s' "$OUT" +[ "$MANUAL" -gt 0 ] && exit 3 +exit 0 |
