#!/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 [ -d "$REAL_REPO/languages/$lang/claude/scripts" ]; then mkdir -p "$proj/.claude/scripts" cp -r "$REAL_REPO/languages/$lang/claude/scripts/." "$proj/.claude/scripts/" 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 } # Mirror install-team.sh: copy only the team overlay's own rule files into a # synthetic project (no generic rules, hooks, githooks, or settings). install_team_overlay() { local team="$1" proj="$2" mkdir -p "$proj/.claude/rules" cp "$REAL_REPO/teams/$team/claude/rules/"*.md "$proj/.claude/rules/" } # --- 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" ] } # --- Auto-fix: .claude/scripts --- @test "sync: drifted bundle script is auto-fixed and restored" { install_bundle elisp "$PROJ" echo ";; junk drift" >> "$PROJ/.claude/scripts/coverage-summary.el" run bash "$SCRIPT" "$PROJ" [ "$status" -eq 0 ] [[ "$output" == *".claude/scripts/coverage-summary.el"* ]] matches_canonical ".claude/scripts/coverage-summary.el" "$REAL_REPO/languages/elisp/claude/scripts/coverage-summary.el" } @test "sync: missing bundle script is re-copied" { install_bundle elisp "$PROJ" rm "$PROJ/.claude/scripts/coverage-summary.el" run bash "$SCRIPT" "$PROJ" [ "$status" -eq 0 ] [[ "$output" == *".claude/scripts/coverage-summary.el"* ]] [ -f "$PROJ/.claude/scripts/coverage-summary.el" ] } # --- Project-owned: Makefile fragment via inbox --- @test "sync: coverage Makefile fragment is dropped into .ai/inbox when present" { install_bundle elisp "$PROJ" mkdir -p "$PROJ/.ai/inbox" run bash "$SCRIPT" "$PROJ" [ "$status" -eq 0 ] [[ "$output" == *"inbox"* ]] [ -f "$PROJ/.ai/inbox/from-rulesets-coverage-makefile.txt" ] matches_canonical ".ai/inbox/from-rulesets-coverage-makefile.txt" "$REAL_REPO/languages/elisp/coverage-makefile.txt" } @test "sync: no .ai/inbox means no fragment drop and no empty .ai/ created" { install_bundle elisp "$PROJ" run bash "$SCRIPT" "$PROJ" [ "$status" -eq 0 ] [[ "$output" != *"inbox"* ]] [ ! -d "$PROJ/.ai" ] } @test "sync: fragment already adopted at project root is not re-dropped" { install_bundle elisp "$PROJ" mkdir -p "$PROJ/.ai/inbox" cp "$REAL_REPO/languages/elisp/coverage-makefile.txt" "$PROJ/coverage-makefile.txt" run bash "$SCRIPT" "$PROJ" [ "$status" -eq 0 ] [ ! -f "$PROJ/.ai/inbox/from-rulesets-coverage-makefile.txt" ] } @test "sync: fragment already waiting in inbox is not duplicated" { install_bundle elisp "$PROJ" mkdir -p "$PROJ/.ai/inbox" cp "$REAL_REPO/languages/elisp/coverage-makefile.txt" "$PROJ/.ai/inbox/from-rulesets-coverage-makefile.txt" run bash "$SCRIPT" "$PROJ" [ "$status" -eq 0 ] [[ "$output" != *"inbox"* ]] } # --- 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"* ]] } # --- Team overlays --- @test "sync: clean team overlay is a quiet no-op (exit 0)" { install_team_overlay deepsat "$PROJ" run bash "$SCRIPT" "$PROJ" [ "$status" -eq 0 ] [ -z "$output" ] } @test "sync: drifted team-overlay rule is auto-fixed and restored" { install_team_overlay deepsat "$PROJ" echo "junk drift" >> "$PROJ/.claude/rules/publishing.md" run bash "$SCRIPT" "$PROJ" [ "$status" -eq 0 ] [[ "$output" == *"team bundle 'deepsat'"* ]] [[ "$output" == *"publishing.md"* ]] matches_canonical ".claude/rules/publishing.md" "$REAL_REPO/teams/deepsat/claude/rules/publishing.md" } @test "sync: team overlay does NOT pull in generic claude-rules" { install_team_overlay deepsat "$PROJ" # only publishing.md present, no commits.md echo "junk drift" >> "$PROJ/.claude/rules/publishing.md" run bash "$SCRIPT" "$PROJ" [ "$status" -eq 0 ] # A team overlay owns only its own rule; the sync must not copy generic # rules (commits.md, testing.md, ...) into the project the way a language # bundle does. [ ! -f "$PROJ/.claude/rules/commits.md" ] [ ! -f "$PROJ/.claude/rules/testing.md" ] }