diff options
Diffstat (limited to 'scripts')
| -rwxr-xr-x | scripts/install-lang.sh | 69 | ||||
| -rw-r--r-- | scripts/tests/install-lang-collision.bats | 112 |
2 files changed, 181 insertions, 0 deletions
diff --git a/scripts/install-lang.sh b/scripts/install-lang.sh index 2f38fcd..6e8d806 100755 --- a/scripts/install-lang.sh +++ b/scripts/install-lang.sh @@ -33,6 +33,75 @@ fi # Resolve to absolute path PROJECT="$(cd "$PROJECT" && pwd)" +# 0. Cross-bundle collision guard. +# +# Several bundles ship a file at the same path. gitignore-add.txt is appended +# and deduped, and CLAUDE.md is seed-only, so both compose across bundles. Three +# do not: claude/settings.json and githooks/* are installed with `cp -rT` +# (always overwrite), and coverage-makefile.txt is seeded under a fixed name. So +# installing a second bundle replaced the first's hook wiring and pre-commit +# while printing [ok], so a project could lose its paren check or secret scan +# and read the output as success. Refuse instead, naming what would go. +# +# Detection is by rule fingerprint, matching sync-language-bundle.sh: a project +# has bundle X iff one of X's own rule files is in .claude/rules/. No marker +# file, so this works on installs that predate the guard. +project_has_bundle() { + local b="$1" rf + for rf in "$REPO_ROOT/languages/$b/claude/rules"/*.md; do + [ -f "$rf" ] || continue + [ -f "$PROJECT/.claude/rules/$(basename "$rf")" ] && return 0 + done + return 1 +} + +# Files $1's bundle and $LANG both ship, and that install would overwrite. +shared_overwritten_files() { + local other="$1" rel + for rel in claude/settings.json coverage-makefile.txt; do + [ -f "$SRC/$rel" ] && [ -f "$REPO_ROOT/languages/$other/$rel" ] \ + && echo " $(printf '%s' "$rel" | sed 's|^claude/|.claude/|')" + done + if [ -d "$SRC/githooks" ] && [ -d "$REPO_ROOT/languages/$other/githooks" ]; then + for rel in "$SRC/githooks"/*; do + [ -f "$rel" ] || continue + [ -f "$REPO_ROOT/languages/$other/githooks/$(basename "$rel")" ] \ + && echo " githooks/$(basename "$rel")" + done + fi +} + +if [ "$FORCE" != "1" ]; then + collisions="" + for other_dir in "$REPO_ROOT/languages"/*/; do + other="$(basename "$other_dir")" + [ "$other" = "$LANG" ] && continue + [ -d "$other_dir/claude/rules" ] || continue + project_has_bundle "$other" || continue + files="$(shared_overwritten_files "$other")" + [ -n "$files" ] && collisions="${collisions}The '$other' bundle is already installed here. Installing '$LANG' would replace: +${files} +" + done + + if [ -n "$collisions" ]; then + { + echo "ERROR: bundle collision. Refusing to install '$LANG' into $PROJECT" + echo + printf '%s' "$collisions" + echo "Both bundles ship these files, and installing overwrites rather than merges," + echo "so the bundle already here would silently lose them." + echo + echo "Whether a project can carry two bundles at once is an open question." + echo "Until it's settled, install one bundle per project." + echo + echo "To override: re-run with FORCE=1. That also re-seeds CLAUDE.md from the" + echo "bundle template, which overwrites any project-specific edits to it." + } >&2 + exit 1 + fi +fi + echo "Installing '$LANG' ruleset into $PROJECT" # 1. Generic rules from claude-rules/ (shared across all languages) diff --git a/scripts/tests/install-lang-collision.bats b/scripts/tests/install-lang-collision.bats new file mode 100644 index 0000000..36abb5b --- /dev/null +++ b/scripts/tests/install-lang-collision.bats @@ -0,0 +1,112 @@ +#!/usr/bin/env bats +# Tests for install-lang's cross-bundle collision guard. +# +# Several bundles ship files at the same path. gitignore-add.txt merges +# (appended, deduped) and CLAUDE.md is seed-only, so both compose across +# bundles. Three do not: +# +# claude/settings.json elisp, bash, go — cp -rT, silently overwritten +# githooks/pre-commit elisp, bash, go — cp -rT, silently overwritten +# coverage-makefile.txt 4 bundles — [skip]ped, fragment dropped +# +# Installing a second bundle used to replace the first's settings.json and +# pre-commit while printing [ok], so a project could lose its paren check or +# secret scan and read the output as success. The guard refuses instead, naming +# what would be replaced. FORCE=1 still overrides. + +INSTALL="${BATS_TEST_DIRNAME}/../install-lang.sh" + +setup() { + PROJ="$(mktemp -d)" + git init -q "$PROJ" +} + +teardown() { + [ -n "${PROJ:-}" ] && rm -rf "$PROJ" +} + +# ---- Normal: single-bundle installs are unaffected ---- + +@test "install-lang: a fresh single-bundle install succeeds" { + run bash "$INSTALL" elisp "$PROJ" + [ "$status" -eq 0 ] + [ -f "$PROJ/.claude/settings.json" ] + grep -q 'validate-el.sh' "$PROJ/.claude/settings.json" +} + +@test "install-lang: reinstalling the SAME bundle is idempotent, not a collision" { + bash "$INSTALL" elisp "$PROJ" + run bash "$INSTALL" elisp "$PROJ" + [ "$status" -eq 0 ] + [[ "$output" != *"collision"* ]] + grep -q 'validate-el.sh' "$PROJ/.claude/settings.json" +} + +# ---- The guard: a second, different bundle must not silently replace ---- + +@test "install-lang: a second bundle sharing settings.json and githooks is refused" { + bash "$INSTALL" elisp "$PROJ" + run bash "$INSTALL" bash "$PROJ" + [ "$status" -ne 0 ] || { echo "second bundle installed without refusal"; return 1; } + [[ "$output" == *"elisp"* ]] || { echo "refusal does not name the existing bundle"; return 1; } +} + +@test "install-lang: the refusal names each file that would be replaced" { + bash "$INSTALL" elisp "$PROJ" + run bash "$INSTALL" bash "$PROJ" + [[ "$output" == *"settings.json"* ]] || { echo "refusal omits settings.json"; return 1; } + [[ "$output" == *"pre-commit"* ]] || { echo "refusal omits githooks/pre-commit"; return 1; } +} + +@test "install-lang: a refused install leaves the first bundle intact" { + bash "$INSTALL" elisp "$PROJ" + bash "$INSTALL" bash "$PROJ" || true + grep -q 'validate-el.sh' "$PROJ/.claude/settings.json" \ + || { echo "elisp settings.json was replaced despite refusal"; return 1; } + grep -q 'check-parens' "$PROJ/githooks/pre-commit" \ + || { echo "elisp pre-commit was replaced despite refusal"; return 1; } +} + +@test "install-lang: bundles colliding only on coverage-makefile.txt are refused too" { + # python and typescript ship no settings.json or githooks, but both ship a + # coverage fragment. The second one's used to be silently dropped. + bash "$INSTALL" python "$PROJ" + run bash "$INSTALL" typescript "$PROJ" + [ "$status" -ne 0 ] || { echo "typescript installed over python's coverage fragment"; return 1; } + [[ "$output" == *"coverage-makefile.txt"* ]] +} + +@test "install-lang: two bundles that share no overwritten file install together" { + # bash ships settings.json + githooks and no coverage fragment; python ships + # only the coverage fragment. Nothing overlaps, so the guard must stay out of + # the way. This is the path where a false refusal would be easiest to + # introduce: the bundle IS detected, and only the empty file-list stops it. + bash "$INSTALL" bash "$PROJ" + run bash "$INSTALL" python "$PROJ" + [ "$status" -eq 0 ] || { echo "guard falsely refused a non-colliding pair: $output"; return 1; } + [ -f "$PROJ/coverage-makefile.txt" ] || { echo "python's coverage fragment did not land"; return 1; } + # bash's config survives untouched. + grep -q 'validate-bash.sh' "$PROJ/.claude/settings.json" + [ -f "$PROJ/.claude/rules/bash.md" ] && [ -f "$PROJ/.claude/rules/python-testing.md" ] +} + +# ---- The escape hatch ---- + +@test "install-lang: FORCE=1 overrides the collision guard" { + bash "$INSTALL" elisp "$PROJ" + run bash "$INSTALL" bash "$PROJ" 1 + [ "$status" -eq 0 ] || { echo "FORCE=1 did not override: $output"; return 1; } + grep -q 'validate-bash.sh' "$PROJ/.claude/settings.json" +} + +@test "install-lang: the refusal points at FORCE=1 and warns it re-seeds CLAUDE.md" { + bash "$INSTALL" elisp "$PROJ" + run bash "$INSTALL" bash "$PROJ" + # Assert the refusal fired first: the pre-existing "[skip] CLAUDE.md already + # exists (use FORCE=1 to overwrite)" line mentions both strings on its own, so + # without this the test passes against an unguarded install. + [ "$status" -ne 0 ] || { echo "no refusal fired"; return 1; } + refusal="$(printf '%s\n' "$output" | grep -v '^ \[skip\]')" + [[ "$refusal" == *"FORCE=1"* ]] || { echo "refusal does not name the override"; return 1; } + [[ "$refusal" == *"CLAUDE.md"* ]] || { echo "refusal does not warn about the CLAUDE.md re-seed"; return 1; } +} |
