aboutsummaryrefslogtreecommitdiff
path: root/scripts/tests/sync-language-bundle.bats
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-22 13:06:12 -0500
committerCraig Jennings <c@cjennings.net>2026-05-22 13:06:12 -0500
commit1ceed704147a1cd6c2c9cd332d3aa33ea43bda83 (patch)
treea76c66f491833e3403ba68b0d982c29a0cf78b48 /scripts/tests/sync-language-bundle.bats
parent79dad4659b8ac97ce65d958efdcd1deed66b3661 (diff)
downloadrulesets-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/tests/sync-language-bundle.bats')
-rw-r--r--scripts/tests/sync-language-bundle.bats153
1 files changed, 153 insertions, 0 deletions
diff --git a/scripts/tests/sync-language-bundle.bats b/scripts/tests/sync-language-bundle.bats
new file mode 100644
index 0000000..9fd1108
--- /dev/null
+++ b/scripts/tests/sync-language-bundle.bats
@@ -0,0 +1,153 @@
+#!/usr/bin/env bats
+#
+# Tests for scripts/sync-language-bundle.sh — per-project language-bundle
+# freshness check for startup.
+#
+# Strategy: scaffold a synthetic project in a temp dir with a clean bundle
+# (mirroring how install-lang.sh leaves .claude/), then perturb files and
+# run the real script against it. Canonical source stays the real one (the
+# script resolves languages/ + claude-rules/ relative to its own location).
+
+REAL_REPO="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
+SCRIPT="$REAL_REPO/scripts/sync-language-bundle.sh"
+
+setup() {
+ PROJ="$(mktemp -d -t synclang-bats.XXXXXX)"
+}
+
+teardown() {
+ rm -rf "$PROJ"
+}
+
+# Mirror install-lang.sh: copy the bundle's files into a synthetic project.
+install_bundle() {
+ local lang="$1" proj="$2"
+ mkdir -p "$proj/.claude/rules"
+ cp "$REAL_REPO/claude-rules"/*.md "$proj/.claude/rules/"
+ cp "$REAL_REPO/languages/$lang/claude/rules/"*.md "$proj/.claude/rules/" 2>/dev/null || true
+ if [ -d "$REAL_REPO/languages/$lang/claude/hooks" ]; then
+ mkdir -p "$proj/.claude/hooks"
+ cp -r "$REAL_REPO/languages/$lang/claude/hooks/." "$proj/.claude/hooks/"
+ fi
+ if [ -f "$REAL_REPO/languages/$lang/claude/settings.json" ]; then
+ cp "$REAL_REPO/languages/$lang/claude/settings.json" "$proj/.claude/settings.json"
+ fi
+ if [ -d "$REAL_REPO/languages/$lang/githooks" ]; then
+ mkdir -p "$proj/githooks"
+ cp -r "$REAL_REPO/languages/$lang/githooks/." "$proj/githooks/"
+ fi
+ # CLAUDE.md deliberately not seeded — the sync must treat its absence as
+ # fine (it's seed-only / project-owned, not bundle-tracked).
+}
+
+matches_canonical() { # project-relative-path canonical-abs-path
+ diff -q "$PROJ/$1" "$2" >/dev/null 2>&1
+}
+
+# --- Normal: no bundle / clean ---
+
+@test "sync: project with no bundle is a quiet no-op (exit 0)" {
+ mkdir -p "$PROJ/.claude/rules" # generic rules only, no language fingerprint
+ cp "$REAL_REPO/claude-rules"/commits.md "$PROJ/.claude/rules/"
+ run bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "sync: clean elisp bundle is a quiet no-op (exit 0)" {
+ install_bundle elisp "$PROJ"
+ run bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "sync: absent CLAUDE.md is not flagged as drift (seed-only/project-owned)" {
+ install_bundle elisp "$PROJ" # helper never seeds CLAUDE.md
+ [ ! -f "$PROJ/CLAUDE.md" ]
+ run bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+ [[ "$output" != *"CLAUDE.md"* ]]
+}
+
+# --- Auto-fix: rules ---
+
+@test "sync: drifted language rule is auto-fixed and restored" {
+ install_bundle elisp "$PROJ"
+ echo ";; junk drift" >> "$PROJ/.claude/rules/elisp.md"
+ run bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"fixed"* ]]
+ [[ "$output" == *".claude/rules/elisp.md"* ]]
+ matches_canonical ".claude/rules/elisp.md" "$REAL_REPO/languages/elisp/claude/rules/elisp.md"
+}
+
+@test "sync: drifted generic rule is auto-fixed and restored" {
+ install_bundle elisp "$PROJ"
+ echo "junk" >> "$PROJ/.claude/rules/commits.md"
+ run bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *".claude/rules/commits.md"* ]]
+ matches_canonical ".claude/rules/commits.md" "$REAL_REPO/claude-rules/commits.md"
+}
+
+@test "sync: missing rule is re-copied" {
+ install_bundle elisp "$PROJ"
+ rm "$PROJ/.claude/rules/elisp-testing.md"
+ run bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 0 ]
+ [ -f "$PROJ/.claude/rules/elisp-testing.md" ]
+ matches_canonical ".claude/rules/elisp-testing.md" "$REAL_REPO/languages/elisp/claude/rules/elisp-testing.md"
+}
+
+# --- Auto-fix: hooks ---
+
+@test "sync: drifted hook is auto-fixed and stays executable" {
+ install_bundle elisp "$PROJ"
+ echo "# junk" >> "$PROJ/.claude/hooks/validate-el.sh"
+ run bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"validate-el.sh"* ]]
+ matches_canonical ".claude/hooks/validate-el.sh" "$REAL_REPO/languages/elisp/claude/hooks/validate-el.sh"
+ [ -x "$PROJ/.claude/hooks/validate-el.sh" ]
+}
+
+# --- Surface-only: settings.json ---
+
+@test "sync: drifted settings.json is surfaced, NOT modified, exit 3" {
+ install_bundle elisp "$PROJ"
+ echo '{"_drift": true}' > "$PROJ/.claude/settings.json"
+ run bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 3 ]
+ [[ "$output" == *"settings.json"* ]]
+ [[ "$output" == *"not auto-fixed"* ]]
+ # file left untouched — still carries the perturbation
+ grep -q "_drift" "$PROJ/.claude/settings.json"
+}
+
+# --- Other language ---
+
+@test "sync: python bundle detected via python-testing.md and fixed" {
+ install_bundle python "$PROJ"
+ echo "junk" >> "$PROJ/.claude/rules/python-testing.md"
+ run bash "$SCRIPT" "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"python-testing.md"* ]]
+ matches_canonical ".claude/rules/python-testing.md" "$REAL_REPO/languages/python/claude/rules/python-testing.md"
+}
+
+# --- Boundary / Error ---
+
+@test "sync: defaults to \$PWD when no path given" {
+ install_bundle elisp "$PROJ"
+ echo "junk" >> "$PROJ/.claude/rules/elisp.md"
+ run bash -c "cd '$PROJ' && bash '$SCRIPT'"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"elisp.md"* ]]
+}
+
+@test "sync: nonexistent project path errors with exit 1" {
+ run bash "$SCRIPT" "/no/such/path/here"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"does not exist"* ]]
+}