diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-27 13:13:09 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-27 13:15:14 -0500 |
| commit | 7ea1d7b1402eb68a13479f8073b84819c1d59ec8 (patch) | |
| tree | aef0ae30760f04db3d99bc2305b46f52d3c295a5 /scripts | |
| parent | 0adcb1afcf64a4638f110a3ab0c616c48d86ba4f (diff) | |
| download | rulesets-7ea1d7b1402eb68a13479f8073b84819c1d59ec8.tar.gz rulesets-7ea1d7b1402eb68a13479f8073b84819c1d59ec8.zip | |
fix(rules): stop shipping generic rules into every project
Generic rules install once at ~/.claude/rules/ and load in every session on the machine. The language bundles copied all twenty into each project as well, so work and .emacs.d loaded them twice. Project rules outrank user-level ones, which makes it worse than waste. A stale project copy overrides the fresh global rule until the next startup heals it, and that is what happened to work's interaction.md this morning for the hours between the commit and its next session.
I stopped install-lang copying them. sync-language-bundle now sweeps the copies earlier installs left, but only where the global rule exists to take over. A machine that hasn't run make install keeps the only copy it has. I swept 20 files each from work and .emacs.d, leaving their language rules and work's publishing overlay untouched.
I updated three existing tests that encoded the old contract. The generic-rule drift test is the interesting one: I made a drifted copy get swept rather than repaired, which is the stronger fix, because that copy outranked the global rule for as long as it existed.
I also gitignored the live session anchor. This repo tracks .ai/, so the anchor read as untracked for a whole session. git-worktree-gate reported rulesets sync-blocked, and every other project skipped its rulesets pull until wrap. The anchor is ephemeral and gets archived under a different name, so nothing needs it tracked.
Diffstat (limited to 'scripts')
| -rwxr-xr-x | scripts/install-lang.sh | 16 | ||||
| -rwxr-xr-x | scripts/sync-language-bundle.sh | 23 | ||||
| -rw-r--r-- | scripts/tests/sync-language-bundle.bats | 75 |
3 files changed, 98 insertions, 16 deletions
diff --git a/scripts/install-lang.sh b/scripts/install-lang.sh index 653747d..3aaa76e 100755 --- a/scripts/install-lang.sh +++ b/scripts/install-lang.sh @@ -104,12 +104,16 @@ fi echo "Installing '$LANG' ruleset into $PROJECT" -# 1. Generic rules from claude-rules/ (shared across all languages) -if [ -d "$REPO_ROOT/claude-rules" ]; then - mkdir -p "$PROJECT/.claude/rules" - cp "$REPO_ROOT/claude-rules"/*.md "$PROJECT/.claude/rules/" 2>/dev/null || true - count=$(ls -1 "$REPO_ROOT/claude-rules"/*.md 2>/dev/null | wc -l) - echo " [ok] .claude/rules/ — $count generic rule(s) from claude-rules/" +# 1. Generic rules are NOT copied here. They install once at ~/.claude/rules/ +# via `make install` and load in every session on the machine. Copying them per +# project loaded them twice, and project rules outrank user-level ones — so a +# stale project copy quietly overrode the fresh global rule. The bundle owns its +# own language rules only; sync-language-bundle.sh sweeps copies left by earlier +# installs. A machine that wants the generic rules runs `make install`. +mkdir -p "$PROJECT/.claude/rules" +if [ ! -d "$HOME/.claude/rules" ]; then + echo " [!!] ~/.claude/rules/ is missing — run 'make install' so the generic" + echo " rules are available; this bundle installs language rules only." fi # 2. .claude/ — language-specific rules, hooks, settings (authoritative, always overwrite) diff --git a/scripts/sync-language-bundle.sh b/scripts/sync-language-bundle.sh index 45f8259..fe922af 100755 --- a/scripts/sync-language-bundle.sh +++ b/scripts/sync-language-bundle.sh @@ -105,7 +105,7 @@ inbox_drop() { # shared generic rules, its hooks/githooks, and surfaces settings.json. process_bundle() { local kind="$1" src="${2%/}" - local name rules rf f rel manual_before + local name rules rf f rel manual_before base name="$(basename "$src")" rules="$src/claude/rules" [ -d "$rules" ] || return 0 @@ -126,12 +126,27 @@ process_bundle() { HEADER_DONE=0 manual_before=$MANUAL - # AUTO-FIX: language bundles carry the shared generic rules; team overlays - # carry only their own rule(s). + # SWEEP: generic rules are installed once at ~/.claude/rules/ by `make + # install` and load in every session. Language bundles used to copy them into + # each project too, which made Claude Code load them twice — and project rules + # take priority over user-level ones, so a stale project copy silently + # overrode the fresh global rule until the next startup healed it. The bundle + # now owns only its own language rules, and sweeps the duplicates it shipped + # before. + # + # The sweep only fires when the global rule is actually present to take over. + # On a machine mid-bootstrap, or one where `make install` has not run, the + # project copy is the only copy and removing it would leave no rule at all. if [ "$kind" = language ]; then for f in "$GENERIC_RULES"/*.md; do [ -f "$f" ] || continue - fix "$f" "$PROJECT/.claude/rules/$(basename "$f")" + base="$(basename "$f")" + [ -f "$PROJECT/.claude/rules/$base" ] || continue + [ -f "$HOME/.claude/rules/$base" ] || continue + rm -f "$PROJECT/.claude/rules/$base" + ensure_header + OUT+=" swept .claude/rules/$base (duplicate of ~/.claude/rules/)"$'\n' + FIXED=$((FIXED + 1)) done fi for f in "$rules"/*.md; do diff --git a/scripts/tests/sync-language-bundle.bats b/scripts/tests/sync-language-bundle.bats index 1871444..0eb3ae2 100644 --- a/scripts/tests/sync-language-bundle.bats +++ b/scripts/tests/sync-language-bundle.bats @@ -19,7 +19,20 @@ teardown() { rm -rf "$PROJ" } -# Mirror install-lang.sh: copy the bundle's files into a synthetic project. +# Mirror what a CURRENT install-lang.sh leaves: language rules only, no copies +# of the generic rules (those live once at ~/.claude/rules/). +install_bundle_current() { + install_bundle "$1" "$2" + local f + for f in "$REAL_REPO/claude-rules"/*.md; do + [ -f "$f" ] || continue + rm -f "$2/.claude/rules/$(basename "$f")" + done +} + +# Mirror what an OLDER install-lang.sh left behind: language rules PLUS copies +# of every generic rule. This is the state the sweep exists to clean up, and +# real projects are still in it until their next startup. install_bundle() { local lang="$1" proj="$2" mkdir -p "$proj/.claude/rules" @@ -67,14 +80,14 @@ install_team_overlay() { } @test "sync: clean elisp bundle is a quiet no-op (exit 0)" { - install_bundle elisp "$PROJ" + install_bundle_current 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 + install_bundle_current elisp "$PROJ" # helper never seeds CLAUDE.md [ ! -f "$PROJ/CLAUDE.md" ] run bash "$SCRIPT" "$PROJ" [ "$status" -eq 0 ] @@ -94,13 +107,17 @@ install_team_overlay() { matches_canonical ".claude/rules/elisp.md" "$REAL_REPO/languages/elisp/claude/rules/elisp.md" } -@test "sync: drifted generic rule is auto-fixed and restored" { +# Generic rules are no longer auto-fixed in place: they are swept, because the +# global copy at ~/.claude/rules/ is the one that loads. A drifted project copy +# is not repaired, it is removed — which is the stronger fix, since the drifted +# copy outranked the global rule while it existed. +@test "sync: a drifted generic rule copy is swept, not repaired" { 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" + [[ "$output" == *"swept"* ]] + [ ! -f "$PROJ/.claude/rules/commits.md" ] } @test "sync: missing rule is re-copied" { @@ -276,3 +293,49 @@ install_team_overlay() { [ ! -f "$PROJ/.claude/rules/commits.md" ] [ ! -f "$PROJ/.claude/rules/testing.md" ] } + +# --- generic-rule de-duplication ------------------------------------------- +# +# Generic rules live at ~/.claude/rules/ (symlinked by `make install`) and load +# in every session. Copying them into each project as well made Claude Code +# load them twice, and project copies take priority — so a stale project copy +# silently overrode the fresh global one. The bundle now ships only its own +# language rules and sweeps the duplicates it previously installed. + +@test "sync: sweeps generic rule copies that duplicate the global set" { + install_bundle python "$PROJ" + [ -f "$PROJ/.claude/rules/commits.md" ] + HOME_RULES="$(mktemp -d)" ; mkdir -p "$HOME_RULES/.claude/rules" + cp "$REAL_REPO/claude-rules"/*.md "$HOME_RULES/.claude/rules/" + run env HOME="$HOME_RULES" bash "$SCRIPT" "$PROJ" + [ "$status" -eq 0 ] + [ ! -f "$PROJ/.claude/rules/commits.md" ] + [ ! -f "$PROJ/.claude/rules/todo-format.md" ] +} + +@test "sync: keeps the language bundle's own rules while sweeping generics" { + install_bundle python "$PROJ" + HOME_RULES="$(mktemp -d)" ; mkdir -p "$HOME_RULES/.claude/rules" + cp "$REAL_REPO/claude-rules"/*.md "$HOME_RULES/.claude/rules/" + run env HOME="$HOME_RULES" bash "$SCRIPT" "$PROJ" + [ "$status" -eq 0 ] + [ -f "$PROJ/.claude/rules/python-testing.md" ] +} + +@test "sync: keeps a project-owned overlay rule the bundle does not own" { + install_bundle python "$PROJ" + printf '# Publishing\n\nApplies to: `**/*`\n' > "$PROJ/.claude/rules/publishing.md" + HOME_RULES="$(mktemp -d)" ; mkdir -p "$HOME_RULES/.claude/rules" + cp "$REAL_REPO/claude-rules"/*.md "$HOME_RULES/.claude/rules/" + run env HOME="$HOME_RULES" bash "$SCRIPT" "$PROJ" + [ "$status" -eq 0 ] + [ -f "$PROJ/.claude/rules/publishing.md" ] +} + +@test "sync: does NOT sweep when the global rule is absent (nothing takes over)" { + install_bundle python "$PROJ" + HOME_RULES="$(mktemp -d)" # no ~/.claude/rules/ at all + run env HOME="$HOME_RULES" bash "$SCRIPT" "$PROJ" + [ "$status" -eq 0 ] + [ -f "$PROJ/.claude/rules/commits.md" ] +} |
