From c98fda556e9211573dab3e32eaa5c42c2dedbfcb Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 16 Jul 2026 13:42:53 -0500 Subject: fix(install-lang): refuse a colliding second bundle instead of clobbering Installing a second language bundle into a project silently replaced the first one's config. settings.json and githooks are copied with cp -rT (always overwrite), so installing bash over elisp rewired the validate hook to validate-bash.sh and dropped check-parens from pre-commit, leaving validate-el.sh orphaned on disk. The output said [ok] for both. A project could lose its paren check or secret scan and read the install as success. The guard detects which bundles a project already has, by the same rule fingerprint sync-language-bundle uses, and refuses when the incoming bundle would overwrite a file another one ships. It names each file at risk. FORCE=1 still overrides, and the message says that also re-seeds CLAUDE.md, which is destructive on a customized project. Only three of the five shared filenames actually collide. gitignore-add.txt is appended and deduped, and CLAUDE.md is seed-only, so both compose across bundles already. This doesn't decide whether polyglot projects are supported, and the guard shouldn't be read as "no". A non-overlapping pair still installs: bash ships settings.json and githooks with no coverage fragment, python ships only a coverage fragment, so the two compose today. The real line is overlap, not polyglot, and nothing chose it. The open question, along with the identical coverage target names both fragments define, is filed. home reported this after scaffolding clock-panel with python and typescript, which hit the coverage fragment. The settings.json and githooks cases are worse and hadn't been noticed. No project was damaged: the three bundles that collide aren't doubled up anywhere. --- scripts/tests/install-lang-collision.bats | 112 ++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 scripts/tests/install-lang-collision.bats (limited to 'scripts/tests') 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; } +} -- cgit v1.2.3