diff --git a/CHANGELOG.md b/CHANGELOG.md index 76005ee..c173b62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [0.77.0] - 2026-08-31 + +### Fixed +- **非対話更新で kit の hook エントリが重複しなくなった(#163)**: `_merge_arrays_3way` は配列要素を**完全一致**で比較していた。キット側がエントリを変更すると(`matcher` の変更、`async` / `asyncTimeout` の追加)、live 側の旧世代が snapshot のどの要素とも一致しなくなり「ユーザーが追加した要素」に分類され、`$n + $ua` の**無条件連結**でキットの新エントリと並べて残されていた。結果として同じ hook が二重に登録される(コマンド自体の改名は identity ごと変わる別クラスで、従来どおり `_strip_retired_hook_entries` が対象コマンドを掃除する) + - **一度発生すると解消しなかった**: `_update_phase_snapshot` は settings.json の snapshot に「マージ結果ではなくキットが生成した版」を保存する(利用者の変更を黙って上書きしないための意図的な設計)。stale エントリは snapshot に入らないため、以降の更新でも毎回「ユーザー追加」と分類され続けていた + - **判定を identity ベースにした**: hook エントリは `hooks[].command` の集合を論理的な登録単位とみなす。この identity が snapshot と新キットの両方に存在する要素は「キットが変更した既存エントリ」であり、ユーザー追加ではないと判断してキット版を採用する。kit-removed 側にも同じ判定を適用し、identity が新キットに残っている snapshot 要素(=キットが「変更」したエントリ)を「キットが削除したエントリ」として復活させない。identity が snapshot に無い要素(利用者独自の hook)は従来どおり保持され、identity が新キットからも消えた要素は従来どおり kit-removed として扱われる + - **既存の壊れた環境は次回の更新で自動的に解消される**。手作業での `settings.json` 編集は不要。3-way マージは snapshot・現行・新キットがすべて異なるときにしか走らないため、マージだけでは「キットが該当配列を次に変更する更新」まで重複が残る。これを埋めるため、更新のたびにマージ後の settings.json をキット生成版と突き合わせ、キットの現行エントリと並んで残っている旧世代(同じ command 集合で内容が異なり、かつキット現行版が同じ配列に存在するもの)を取り除く `_strip_superseded_kit_hook_generations` を追加した。対話・非対話のどちらの更新経路でも実行される + - 重複が**発生**するのは `_MERGE_INTERACTIVE` が false の経路(auto-update hook 経由の更新、`--non-interactive`、`install.sh` の `--update`)のみ。`/update-kit` と対話的な再実行は配列まるごと置換の経路を通るため該当しない +- **配列マージがキットの並び順を保持するようになった**: 重複排除に使っていた `unique` は配列を**ソート**する。`.hooks.PreToolUse` は配列順に実行され、`lib/features.sh` は safety-net が先頭であることを要求している(`lib/deploy.sh` がビルド時に FATAL で検査)。しかしこの検査はビルド時の `_FEATURE_ORDER` のみを見ており、更新時のマージは通らない。実測で、コマンド文字列が `cc-safety-net` より辞書順で前に来るユーザー hook(例: `!` 始まり)が index 0 を奪うことを確認した。順序を保つ重複排除に置き換えた + +### Changed +- **`permissions.allow` / `deny` などの文字列配列の並び順が変わる**: 上記の重複排除の変更により、非対話更新後の配列はアルファベット順ではなく「キットの順序 → ユーザー追加分」の順になる。要素の集合は変わらず、権限判定の挙動にも影響はないが、更新後の `settings.json` の差分としては見える +- **キット所有の hook エントリを利用者が直接編集していた場合、キットが該当エントリを変更する更新の時点でキット版に戻るようになった**: 従来は新旧が両方残って同じ hook が二重に登録されていた。単一の登録として扱う以上どちらかを選ぶ必要があり、キット管理下のエントリはキット版を正とする。キット版を残したまま同じコマンド(`hooks[].command` の集合が一致するエントリ)を別の matcher で**追加登録**していた場合は、更新のたびに世代掃除の対象となりキット版 1 件に統合される。利用者独自のコマンドを持つ hook は影響を受けない + ## [0.76.2] - 2026-08-31 ### Fixed diff --git a/README.en.md b/README.en.md index fcec842..e8394da 100644 --- a/README.en.md +++ b/README.en.md @@ -255,7 +255,7 @@ Automatically checks for new starter kit releases on GitHub on both `SessionStar - **Every-session checks**: Checks at both session start and session end - **Background execution**: Hooks run asynchronously, so the session is not blocked - **Lock-based deduplication**: A running auto-update prevents duplicate concurrent runs -- **Settings preserved**: 3-way merge keeps your customizations intact +- **Settings preserved**: 3-way merge keeps settings and hooks that use your own commands. Kit-shipped hook entries are kit-owned: an entry you edited in place, or a kit command you re-registered under another matcher, is consolidated back to the kit's single version on update - **One-liner installs only**: Only works when the kit is installed at `~/.claude-starter-kit/` - **SessionEnd is best-effort**: abrupt termination may skip the end-of-session check - **Compatibility**: Verified on Claude Code `2.1.89`. When an older Claude Code is detected, the kit falls back to the legacy `SessionStart` + 24h cache hook diff --git a/README.md b/README.md index 2e4fc4f..c15f2b8 100644 --- a/README.md +++ b/README.md @@ -526,7 +526,7 @@ SessionStart / SessionEnd フック発火 - **毎セッション確認**: 起動直後と終了時の両方で最新版を確認します - **lock で重複防止**: すでに自動更新が動いている間は二重実行しません -- ユーザー設定は 3-way merge で保持されます(手動カスタマイズが消えることはありません) +- ユーザー設定は 3-way merge で保持されます(自分のコマンドを使う設定・hook が消えることはありません)。ただし kit が配布している hook エントリは kit 版が正です: エントリ自体を書き換えていた場合や、kit のコマンドを別の matcher で追加登録していた場合は、更新時に kit 版 1 件へ統合されます - ワンライナーインストール(`~/.claude-starter-kit/`)の場合のみ動作します - **SessionEnd は best-effort**: 異常終了時は start 側のチェックだけに依存します - **dirty check**: kit リポジトリにローカル変更がある場合は更新をスキップし、`git stash` を案内します diff --git a/lib/merge.sh b/lib/merge.sh index 1ccfe29..7545712 100644 --- a/lib/merge.sh +++ b/lib/merge.sh @@ -167,7 +167,22 @@ _merge_settings_mdm_documents() { # - Items in snapshot but not newkit, still in current # → kit removed → ask (interactive) # keep (non-interactive) -# - Deduplicate via jq 'unique' +# - Items whose IDENTITY the kit shipped before and still ships +# → kit modified → kit's version wins +# - Deduplicate while preserving order +# +# Identity exists because elements are compared by exact equality. A kit-side +# EDIT of a hook entry (matcher, async, asyncTimeout) makes the live copy +# unequal to every snapshot element, so it used to be classified as a user +# addition and re-appended NEXT TO the kit's new version — leaving the same +# hook registered twice, permanently (the snapshot stores the kit build, so +# the stale copy is re-classified as a user addition on every later update). +# Hook entries carry a "hooks" array of commands; that command set is the +# logical registration, independent of the matcher it is bound to. A renamed +# command changes the identity itself and is out of scope here — retired and +# renamed commands are swept by _strip_retired_hook_entries instead. Any +# other element (permissions strings, plain scalars) is its own identity, so +# an identity match is impossible for them and their behaviour is unchanged. # --------------------------------------------------------------------------- _merge_arrays_3way() { local s_val="$1" # snapshot array JSON @@ -175,33 +190,61 @@ _merge_arrays_3way() { local n_val="$3" # new-kit array JSON # Determine items to keep/remove through set operations via jq - # user_added = current \ snapshot (user introduced these) + # user_added = current \ snapshot, minus superseded kit generations # kit_removed = snapshot \ newkit, still in current (kit deleted these) local user_added kit_removed user_added="$(jq -n \ --argjson s "$s_val" \ --argjson c "$c_val" \ - '[$c[] | select(. as $item | $s | map(. == $item) | any | not)]')" || return 1 + --argjson n "$n_val" \ + 'def ident: if type == "object" and ((.hooks? | type) == "array") + then [.hooks[]?.command?] else . end; + ($s | map(ident)) as $sids | + ($n | map(ident)) as $nids | + [ $c[] + | . as $item + | ($item | ident) as $id + | select(($s | any(. == $item)) | not) + # Superseded kit generation, not a user addition: the kit shipped this + # identity before and still ships it, so the live copy is an older or + # locally edited generation of an entry the kit owns. Drop it and let + # the kit version in $n stand alone. + | select((($sids | any(. == $id)) and ($nids | any(. == $id))) | not) + ]')" || return 1 kit_removed="$(jq -n \ --argjson s "$s_val" \ --argjson c "$c_val" \ --argjson n "$n_val" \ - '[ + 'def ident: if type == "object" and ((.hooks? | type) == "array") + then [.hooks[]?.command?] else . end; + ($n | map(ident)) as $nids | + [ $s[] | . as $item | select( ($n | map(. == $item) | any | not) and - ($c | map(. == $item) | any) + ($c | map(. == $item) | any) and + # Identity still shipped by the new kit → the kit MODIFIED this entry + # rather than removing it. The updated version in $n replaces this + # generation; re-adding the snapshot copy would register the same + # hook twice — the exact failure this merge exists to prevent. + (($nids | any(. == ($item | ident))) | not) ) ]')" || return 1 # Base result: newkit array (authoritative — already includes kit_added items) # plus user additions (items the user added that the kit never had) + # + # Dedup preserves order. `unique` sorts, and .hooks.* arrays are ordered: + # PreToolUse runs in array order and safety-net is required to be first + # (lib/features.sh, asserted for the build in lib/deploy.sh). Sorting let a + # user hook whose command sorts earlier take index 0 away from safety-net. local merged merged="$(jq -n \ --argjson n "$n_val" \ --argjson ua "$user_added" \ - '$n + $ua | unique')" || return 1 + 'reduce ($n + $ua)[] as $x ([]; + if any(.[]; . == $x) then . else . + [$x] end)')" || return 1 # Handle kit-removed items that still exist in current local removed_count @@ -213,7 +256,8 @@ _merge_arrays_3way() { merged="$(jq -n \ --argjson m "$merged" \ --argjson kr "$kit_removed" \ - '$m + $kr | unique')" || return 1 + 'reduce ($m + $kr)[] as $x ([]; + if any(.[]; . == $x) then . else . + [$x] end)')" || return 1 else # Interactive: ask for each removed item local i=0 @@ -238,7 +282,8 @@ _merge_arrays_3way() { merged="$(jq -n \ --argjson m "$merged" \ --argjson item "$item" \ - '$m + [$item] | unique')" || return 1 + 'reduce ($m + [$item])[] as $x ([]; + if any(.[]; . == $x) then . else . + [$x] end)')" || return 1 ;; esac i=$((i + 1)) diff --git a/lib/update.sh b/lib/update.sh index 7b16e02..cff6d8a 100644 --- a/lib/update.sh +++ b/lib/update.sh @@ -1374,6 +1374,74 @@ _strip_retired_hook_entries() { fi } +# --------------------------------------------------------------------------- +# _strip_superseded_kit_hook_generations - Drop stale generations of kit hooks +# +# Usage: _strip_superseded_kit_hook_generations +# +# A hooks array that carries BOTH the kit's current entry and an older kit +# generation of it (same command set, different matcher/async/etc — the +# pre-identity-merge failure mode, #163) runs the same hook twice. The 3-way +# merge only executes when snapshot, current and new kit all differ, so an +# already-duplicated array would otherwise persist until the kit next edits +# that very array. This sweep runs on every update, after the merge, against +# the freshly built kit settings. +# +# An entry is dropped ONLY when all three hold: it differs from every kit +# entry, its command set matches a kit entry's, and that kit entry is itself +# present in the array. A kit entry the user edited IN PLACE (kit's exact +# version absent) is left alone — removing it would deregister the hook. +# User hooks with their own commands never match a kit identity. +# --------------------------------------------------------------------------- +_strip_superseded_kit_hook_generations() { + local settings_file="$1" + local kit_file="$2" + [[ -f "$settings_file" ]] || return 1 + [[ -f "$kit_file" ]] || return 0 + local filter=' + def ident: if type == "object" and ((.hooks? | type) == "array") + then [.hooks[]?.command?] else null end; + def stale($K; $L): + . as $e | + (($K | any(. == $e)) | not) and + (($e | ident) != null) and + ($K | any(ident == ($e | ident))) and + ([$K[] | select(ident == ($e | ident))] | any(. as $k | $L | any(. == $k))); + ($kit[0].hooks // {}) as $KH' + local probe_rc=0 + jq -e --slurpfile kit "$kit_file" "$filter"' | + any((.hooks // {}) | to_entries[]; + ($KH[.key] // []) as $K | .value as $L | + ($L | type) == "array" and any($L[]; stale($K; $L))) + ' "$settings_file" >/dev/null 2>&1 || probe_rc=$? + case "$probe_rc" in + 0) ;; + 1) return 0 ;; + *) return 0 ;; # unreadable kit build or settings — nothing safe to do + esac + local tmp + tmp="$(mktemp)" || return 1 + _SETUP_TMP_FILES+=("$tmp") + if jq --slurpfile kit "$kit_file" "$filter"' | + if .hooks then + .hooks |= (to_entries + | map(($KH[.key] // []) as $K + | .value as $L + | .value |= (if type == "array" + then map(select(stale($K; $L) | not)) + else . end)) + | from_entries) + else . end + ' "$settings_file" > "$tmp" 2>/dev/null; then + mv "$tmp" "$settings_file" || return 1 + ok "Removed superseded kit hook generations from settings.json" + else + rm -f "$tmp" 2>/dev/null || true + warn "Could not strip superseded kit hook generations from settings.json" + return 1 + fi +} + _retired_relative_path_is_safe() { local rel="$1" [[ -n "$rel" && "$rel" != /* && ! "$rel" =~ [[:cntrl:]] ]] || return 1 @@ -1688,6 +1756,12 @@ _update_phase_settings() { # which would leave commands pointing at scripts the retired-file sweep # deletes. Strip them explicitly. _strip_retired_hook_entries "$current_settings" || return 1 + + # Heal arrays that already carry a stale kit hook generation next to the + # kit's current entry (#163). The 3-way merge above only runs when snapshot, + # current and new kit all differ, so without this sweep an already-duplicated + # array persists until the kit next edits that very array. + _strip_superseded_kit_hook_generations "$current_settings" "$new_settings" || return 1 } # _claude_md_user_section_has_content - Returns 0 when the user section of a diff --git a/tests/unit/test-merge.sh b/tests/unit/test-merge.sh index 7db3db2..bf5fcb9 100644 --- a/tests/unit/test-merge.sh +++ b/tests/unit/test-merge.sh @@ -212,7 +212,7 @@ _large_merge_doc() { run_func _merge_arrays_3way "$snapshot" "$current" "$new_kit" - # Result should contain: a, b, kit-added, user-added (deduplicated, sorted by unique) + # Result should contain: a, b, kit-added, user-added (deduplicated, kit order first) result="$_RF_STDOUT" has_user="$(printf '%s' "$result" | jq 'map(select(. == "user-added")) | length')" has_kit="$(printf '%s' "$result" | jq 'map(select(. == "kit-added")) | length')" @@ -889,3 +889,175 @@ _large_merge_doc() { fi unset s_val c_val n_val merged key_bytes } + +# --------------------------------------------------------------------------- +# 31. _merge_arrays_3way: a kit-side EDIT of a hook entry is not a user addition +# +# Elements are compared by exact equality, so changing a hook entry's matcher +# (as v0.55.0 did for "*" -> "startup") made the live copy unequal to every +# snapshot element. It was then re-appended next to the kit's new version and +# the same hook stayed registered twice. The snapshot stores the kit build, so +# the stale copy was re-classified as a user addition on every later update and +# never went away. +# --------------------------------------------------------------------------- +{ + test_name="arrays_3way: a kit-side matcher change does not duplicate the hook" + snapshot='[{"matcher":"startup","hooks":[{"type":"command","command":"A.sh"}]},{"matcher":"startup","hooks":[{"type":"command","command":"B.sh"}]}]' + current='[{"matcher":"*","hooks":[{"type":"command","command":"A.sh"}]},{"matcher":"*","hooks":[{"type":"command","command":"B.sh"}]}]' + new_kit="$snapshot" + + run_func _merge_arrays_3way "$snapshot" "$current" "$new_kit" + + if [[ "$_RF_RC" -eq 0 ]] \ + && printf '%s' "$_RF_STDOUT" | jq -e ' + length == 2 and all(.[]; .matcher == "startup")' >/dev/null; then + pass "$test_name" + else + fail "$test_name (got '$_RF_STDOUT')" + fi +} + +# An install already carrying both generations must converge on the next run. +{ + test_name="arrays_3way: an already-duplicated hooks array heals" + snapshot='[{"matcher":"startup","hooks":[{"type":"command","command":"A.sh"}]},{"matcher":"startup","hooks":[{"type":"command","command":"B.sh"}]}]' + current='[{"matcher":"startup","hooks":[{"type":"command","command":"A.sh"}]},{"matcher":"*","hooks":[{"type":"command","command":"A.sh"}]},{"matcher":"*","hooks":[{"type":"command","command":"B.sh"}]},{"matcher":"startup","hooks":[{"type":"command","command":"B.sh"}]}]' + new_kit="$snapshot" + + run_func _merge_arrays_3way "$snapshot" "$current" "$new_kit" + + if [[ "$_RF_RC" -eq 0 ]] \ + && printf '%s' "$_RF_STDOUT" | jq -e ' + length == 2 + and ([.[] | .hooks[0].command] | sort) == ["A.sh","B.sh"]' >/dev/null; then + pass "$test_name" + else + fail "$test_name (got '$_RF_STDOUT')" + fi +} + +# async/asyncTimeout drift is the same class: only the payload differs. +{ + test_name="arrays_3way: an async/asyncTimeout-only difference does not duplicate" + snapshot='[{"matcher":"startup","hooks":[{"type":"command","command":"A.sh","async":true,"asyncTimeout":300000}]}]' + current='[{"matcher":"startup","hooks":[{"type":"command","command":"A.sh"}]}]' + new_kit='[{"matcher":"startup","hooks":[{"type":"command","command":"A.sh","async":true,"asyncTimeout":720000}]}]' + + run_func _merge_arrays_3way "$snapshot" "$current" "$new_kit" + + if [[ "$_RF_RC" -eq 0 ]] \ + && printf '%s' "$_RF_STDOUT" | jq -e ' + length == 1 and .[0].hooks[0].asyncTimeout == 720000' >/dev/null; then + pass "$test_name" + else + fail "$test_name (got '$_RF_STDOUT')" + fi +} + +# Identity only matches entries the kit shipped, so a user's own hook is safe. +{ + test_name="arrays_3way: a user's own hook entry survives the identity check" + snapshot='[{"matcher":"startup","hooks":[{"type":"command","command":"A.sh"}]}]' + current='[{"matcher":"*","hooks":[{"type":"command","command":"A.sh"}]},{"matcher":"startup","hooks":[{"type":"command","command":"MY-OWN.sh"}]}]' + new_kit="$snapshot" + + run_func _merge_arrays_3way "$snapshot" "$current" "$new_kit" + + if [[ "$_RF_RC" -eq 0 ]] \ + && printf '%s' "$_RF_STDOUT" | jq -e ' + length == 2 + and any(.[]; .hooks[0].command == "MY-OWN.sh") + and ([.[] | select(.hooks[0].command == "A.sh")] | length) == 1' >/dev/null; then + pass "$test_name" + else + fail "$test_name (got '$_RF_STDOUT')" + fi +} + +# --------------------------------------------------------------------------- +# 32. _merge_arrays_3way: dedup must not reorder +# +# PreToolUse runs in array order and safety-net is required to be first +# (lib/features.sh; lib/deploy.sh asserts it for the build). `unique` sorts, so +# a user hook whose command sorted earlier took index 0 away from safety-net. +# --------------------------------------------------------------------------- +{ + test_name="arrays_3way: dedup preserves kit order (safety-net stays first)" + snapshot='[{"matcher":"Bash","hooks":[{"type":"command","command":"cc-safety-net"}]}]' + current='[{"matcher":"Bash","hooks":[{"type":"command","command":"cc-safety-net"}]},{"matcher":"Bash","hooks":[{"type":"command","command":"!my-own-hook"}]}]' + new_kit='[{"matcher":"Bash","hooks":[{"type":"command","command":"cc-safety-net"}]},{"matcher":"Bash","hooks":[{"type":"command","command":"zz-new-kit"}]}]' + + run_func _merge_arrays_3way "$snapshot" "$current" "$new_kit" + + if [[ "$_RF_RC" -eq 0 ]] \ + && printf '%s' "$_RF_STDOUT" | jq -e ' + .[0].hooks[0].command == "cc-safety-net" + and length == 3' >/dev/null; then + pass "$test_name" + else + fail "$test_name (got '$_RF_STDOUT')" + fi +} + +# String arrays have no "hooks" key, so identity is the element itself and no +# identity match is possible — permissions keep their existing semantics. +{ + test_name="arrays_3way: string arrays keep user additions and kit removals" + snapshot='["Read","WebFetch"]' + current='["Read","WebFetch","Mine"]' + new_kit='["Read","Bash(git diff)"]' + + run_func _merge_arrays_3way "$snapshot" "$current" "$new_kit" + + if [[ "$_RF_RC" -eq 0 ]] \ + && printf '%s' "$_RF_STDOUT" | jq -e ' + (. | sort) == ["Bash(git diff)","Mine","Read","WebFetch"] + and .[0] == "Read"' >/dev/null; then + pass "$test_name" + else + fail "$test_name (got '$_RF_STDOUT')" + fi +} + +# The identity rule must also hold on the very update that SHIPS the kit edit. +# There the stale generation is byte-equal to the snapshot, so it never reaches +# the user_added filter — it lands in kit_removed instead, and the +# non-interactive branch used to re-append it next to the kit's new version, +# recreating the duplicate this merge exists to prevent. +{ + test_name="arrays_3way: the update shipping a kit edit does not resurrect the old generation" + snapshot='[{"matcher":"*","hooks":[{"type":"command","command":"A.sh"}]}]' + current='[{"matcher":"*","hooks":[{"type":"command","command":"A.sh"}]},{"matcher":"Bash","hooks":[{"type":"command","command":"MY-OWN.sh"}]}]' + new_kit='[{"matcher":"startup","hooks":[{"type":"command","command":"A.sh"}]}]' + + run_func _merge_arrays_3way "$snapshot" "$current" "$new_kit" + + if [[ "$_RF_RC" -eq 0 ]] \ + && printf '%s' "$_RF_STDOUT" | jq -e ' + length == 2 + and (.[0] == {"matcher":"startup","hooks":[{"type":"command","command":"A.sh"}]}) + and (.[1].hooks[0].command == "MY-OWN.sh")' >/dev/null; then + pass "$test_name" + else + fail "$test_name (got '$_RF_STDOUT')" + fi +} + +# An entry whose identity is GONE from the new kit is genuinely kit-removed and +# keeps the existing non-interactive default: the user's value stays. +{ + test_name="arrays_3way: a truly removed kit entry is still kept non-interactively" + snapshot='[{"matcher":"*","hooks":[{"type":"command","command":"GONE.sh"}]}]' + current='[{"matcher":"*","hooks":[{"type":"command","command":"GONE.sh"}]}]' + new_kit='[]' + + run_func _merge_arrays_3way "$snapshot" "$current" "$new_kit" + + if [[ "$_RF_RC" -eq 0 ]] \ + && printf '%s' "$_RF_STDOUT" | jq -e ' + length == 1 and .[0].hooks[0].command == "GONE.sh"' >/dev/null; then + pass "$test_name" + else + fail "$test_name (got '$_RF_STDOUT')" + fi +} diff --git a/tests/unit/test-retired-hooks.sh b/tests/unit/test-retired-hooks.sh index fa9ffbd..ded9ff1 100644 --- a/tests/unit/test-retired-hooks.sh +++ b/tests/unit/test-retired-hooks.sh @@ -178,4 +178,124 @@ JSON fi } +# ── _strip_superseded_kit_hook_generations ───────────────────────────────── +# +# The 3-way merge only runs when snapshot, current and new kit all differ, so +# a hooks array that already carries a stale kit generation next to the kit's +# current entry (#163) would persist until the kit next edits that very array. +# This post-merge sweep heals it on every update. +_rh_sweep() { # + HOME=/home/u bash -c ' + set -uo pipefail + PROJECT_DIR="'"$PROJECT_DIR"'" + ok(){ :; }; warn(){ :; }; info(){ :; }; is_true(){ [[ "$1" == "true" ]]; } + _SETUP_TMP_FILES=() + source "$PROJECT_DIR/lib/features.sh" + source "$PROJECT_DIR/lib/snapshot.sh" + source "$PROJECT_DIR/lib/update.sh" 2>/dev/null || true + _strip_superseded_kit_hook_generations "'"$1"'" "'"$2"'" + ' +} + +_rh_kit="$_rh_tmp/kit-built.json" +printf '%s\n' '{ + "hooks": { + "SessionStart": [ + {"matcher": "startup", "hooks": [{"type": "command", "command": "A.sh"}]} + ], + "PreToolUse": [ + {"matcher": "*", "hooks": [{"type": "command", "command": "safety.sh"}]} + ] + } +}' > "$_rh_kit" + +{ + test_name="superseded-generations: a stale kit generation next to the current entry is dropped" + _rh_dup="$_rh_tmp/dup.json" + printf '%s\n' '{ + "hooks": { + "SessionStart": [ + {"matcher": "startup", "hooks": [{"type": "command", "command": "A.sh"}]}, + {"matcher": "*", "hooks": [{"type": "command", "command": "A.sh"}]} + ], + "PreToolUse": [ + {"matcher": "*", "hooks": [{"type": "command", "command": "safety.sh"}]}, + {"matcher": "Bash", "hooks": [{"type": "command", "command": "mine.sh"}]} + ] + } +}' > "$_rh_dup" + _rh_sweep "$_rh_dup" "$_rh_kit" >/dev/null 2>&1 + if jq -e ' + (.hooks.SessionStart | length) == 1 + and .hooks.SessionStart[0].matcher == "startup" + and (.hooks.PreToolUse | length) == 2 + and .hooks.PreToolUse[1].hooks[0].command == "mine.sh"' \ + "$_rh_dup" >/dev/null 2>&1; then + pass "$test_name" + else + fail "$test_name" + fi +} + +{ + test_name="superseded-generations: a kit entry edited in place is left alone" + _rh_edited="$_rh_tmp/edited.json" + printf '%s\n' '{ + "hooks": { + "SessionStart": [ + {"matcher": "*", "hooks": [{"type": "command", "command": "A.sh"}]} + ] + } +}' > "$_rh_edited" + _rh_sweep "$_rh_edited" "$_rh_kit" >/dev/null 2>&1 + if jq -e ' + (.hooks.SessionStart | length) == 1 + and .hooks.SessionStart[0].matcher == "*"' \ + "$_rh_edited" >/dev/null 2>&1; then + pass "$test_name" + else + fail "$test_name" + fi +} + +{ + test_name="superseded-generations: user hooks and unknown events are untouched" + _rh_mixed="$_rh_tmp/mixed.json" + printf '%s\n' '{ + "hooks": { + "SessionStart": [ + {"matcher": "startup", "hooks": [{"type": "command", "command": "A.sh"}]}, + {"matcher": "startup", "hooks": [{"type": "command", "command": "mine.sh"}]} + ], + "Stop": [ + {"matcher": "*", "hooks": [{"type": "command", "command": "A.sh"}]} + ] + }, + "permissions": {"allow": ["Read"]} +}' > "$_rh_mixed" + _rh_before="$(jq -cS . "$_rh_mixed")" + _rh_sweep "$_rh_mixed" "$_rh_kit" >/dev/null 2>&1 + if [[ "$(jq -cS . "$_rh_mixed")" == "$_rh_before" ]]; then + pass "$test_name" + else + fail "$test_name" + fi +} + +{ + test_name="superseded-generations: a missing or broken kit build changes nothing" + _rh_safe="$_rh_tmp/safe.json" + printf '{"hooks":{"SessionStart":[{"matcher":"*","hooks":[{"type":"command","command":"A.sh"}]}]}}\n' > "$_rh_safe" + printf 'not json' > "$_rh_tmp/broken-kit.json" + _rh_before="$(jq -cS . "$_rh_safe")" + _rh_rc1=0; _rh_sweep "$_rh_safe" "$_rh_tmp/no-such-kit.json" >/dev/null 2>&1 || _rh_rc1=$? + _rh_rc2=0; _rh_sweep "$_rh_safe" "$_rh_tmp/broken-kit.json" >/dev/null 2>&1 || _rh_rc2=$? + if [[ "$_rh_rc1" -eq 0 && "$_rh_rc2" -eq 0 \ + && "$(jq -cS . "$_rh_safe")" == "$_rh_before" ]]; then + pass "$test_name" + else + fail "$test_name" + fi +} + rm -rf "$_rh_tmp"