#!/usr/bin/env bash # sdd-guard 退役的迴歸測試(inkstone/ISEP#91) # # ── 這支在守什麼 ──────────────────────────────────────────────────── # leo 2026-08-16 在 inkstone/InkStoneCo#40 裁定「**取消 Active SDD**」: # 任務狀態搬到 Gitea 管,SDD 只記「起初的樣子」。 # 但 `sdd-guard.sh` 是 **fail-closed** 的—— # 動 code 檔時,active SDD 不是「恰好 1 份」→ 擋(0 份也擋) # 路徑所在的 repo 沒有 3-specs → 也擋 # ⇒ 照裁決把 active SDD 拿掉,會把「動任何 .ts/.py/.go」整個鎖死。 # 這就是那個裁決 11 天沒人敢執行的真正原因。 # # 所以退役的順序是「**先讓閘退役,再拿掉 active SDD**」,而這支測的是第一步做完了沒有。 # # 🔴 它測的不是「檔案刪掉了沒有」,而是**那個擋還會不會發生**: # 把整組 `Write|Edit|MultiEdit` 的 PreToolUse 閘,拿去撞「裁決執行完之後的世界」 # (一個沒有 3-specs 的 repo、以及一個有兩份 active SDD 的 repo),一支都不准擋。 # ⇒ 有人日後用別的檔名把同一個形狀種回來,這支照樣會紅。 # # 用法:hooks/tests/sdd-guard-retired.test.sh # 🔴 全程在 TMP 底下建假 repo,跑完自己清;不動任何真 repo、不打網路。 set -u HOOKS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO_ROOT="$(cd "$HOOKS_DIR/.." && pwd)" TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT PASS=0; FAIL=0 ok() { echo " ✅ $1"; PASS=$((PASS+1)); } bad() { echo " ❌ $1"; FAIL=$((FAIL+1)); } # ── ① 閘本體與它的註冊都不在了 ──────────────────────────────────── echo "── ① 退役:檔案與註冊 ──" if [ -e "$HOOKS_DIR/sdd-guard.sh" ]; then bad "hooks/sdd-guard.sh 還在(應已刪除)" else ok "hooks/sdd-guard.sh 已刪除" fi if grep -q 'sdd-guard' "$HOOKS_DIR/hooks.json"; then bad "hooks.json 還註冊著 sdd-guard(照裁決做會鎖死所有 code 寫入)" else ok "hooks.json 已無 sdd-guard 註冊" fi # hooks.json 要仍然是合法 JSON,而且別的閘一支都沒被順手弄掉 # (README:合錯的時候「語法合法、閘卻不見了」,不會有任何東西喊一聲) REGISTERED=$(python3 - "$HOOKS_DIR/hooks.json" <<'PY' import json, sys d = json.load(open(sys.argv[1])) print(sum(len(g.get("hooks", [])) for ev in d["hooks"].values() for g in ev)) PY ) || REGISTERED="" if [ -n "$REGISTERED" ]; then ok "hooks.json 仍是合法 JSON(註冊 $REGISTERED 條)" else bad "hooks.json 解析失敗" fi # ── ② 真正的驗收:裁決執行完之後,動 code 檔不能被擋 ────────────── echo "── ② 行為:把「拿掉 active SDD 之後的世界」丟給整組寫檔閘,一支都不准擋 ──" # 這兩個假 repo 就是舊 sdd-guard 一定會擋下的兩種狀態: # A. 完全沒有 3-specs(=裁決執行完的樣子,也是 ISEP 自己現在的樣子) # B. 兩份 status: active(=舊的「單一活性鐵律」違反) REPO_NO_SDD="$TMP/repo-no-sdd" mkdir -p "$REPO_NO_SDD/src" git init -q "$REPO_NO_SDD" REPO_MULTI="$TMP/repo-multi-active" mkdir -p "$REPO_MULTI/system-dev/docs/3-specs/a" "$REPO_MULTI/system-dev/docs/3-specs/b" "$REPO_MULTI/src" git init -q "$REPO_MULTI" printf -- '---\nstatus: active\n---\n# A\n' > "$REPO_MULTI/system-dev/docs/3-specs/a/design.md" printf -- '---\nstatus: active\n---\n# B\n' > "$REPO_MULTI/system-dev/docs/3-specs/b/design.md" # 撈出所有掛在 Write|Edit|MultiEdit 上的 PreToolUse 閘(照 hooks.json 的實況,不寫死清單) mapfile -t WRITE_HOOKS < <(python3 - "$HOOKS_DIR/hooks.json" <<'PY' import json, sys d = json.load(open(sys.argv[1])) for g in d["hooks"].get("PreToolUse", []): m = g.get("matcher", "") or "" if "Write" in m or "Edit" in m: for h in g.get("hooks", []): print(h["command"].split("/")[-1]) PY ) if [ "${#WRITE_HOOKS[@]}" -eq 0 ]; then bad "撈不到任何 Write|Edit 的 PreToolUse 閘——測試本身失效了,先修這裡" fi # 判準:擋下來的理由**是不是 SDD**。 # # 🔴 這裡刻意不寫成「一支閘都不准擋」——同一組 matcher 上還住著好幾支跟 SDD 無關、 # 而且看 session 狀態決定擋不擋的閘(`subagent-first-guard`/`history-first-guard`…)。 # 把它們算成失敗,這支測試就會在別人改別的東西時無故變紅, # 紅久了就沒人看——`docs/TESTING.md` 開頭那句「誤攔比漏擋更該修」對測試一樣成立。 # 所以失敗的定義是:**有閘擋下來,而且它擋的理由指向 SDD/3-specs**。 # 其餘的擋只印出來給人看,不判失敗。 probe() { # probe <說明> local desc="$1" path="$2" sdd_blocked="" other_blocked="" local payload out rc payload=$(python3 -c "import json,sys;print(json.dumps({'tool_name':'Write','tool_input':{'file_path':sys.argv[1],'content':'export const x = 1;\n'}}))" "$path") for name in "${WRITE_HOOKS[@]}"; do [ -x "$HOOKS_DIR/$name" ] || continue out=$(printf '%s' "$payload" | "$HOOKS_DIR/$name" 2>&1) rc=$? [ "$rc" -eq 2 ] || continue if printf '%s' "$out" | grep -qE 'SDD|3-specs'; then sdd_blocked="$sdd_blocked $name" else other_blocked="$other_blocked $name" fi done if [ -n "$sdd_blocked" ]; then bad "$desc —— 仍被 SDD 理由擋下:$sdd_blocked" else ok "$desc" [ -n "$other_blocked" ] && echo " (另有與 SDD 無關的閘擋下,不算失敗:$other_blocked)" fi } # 刻意讓 $_root(CLAUDE_PROJECT_DIR/pwd)跟這些假 repo 對不上—— # 舊 sdd-guard 在這個情境下走的正是 fail-closed 那條分支。 unset CLAUDE_PROJECT_DIR cd "$TMP" || exit 1 probe "repo 沒有 3-specs(裁決執行完的樣子)→ 寫 .ts 不被擋" "$REPO_NO_SDD/src/x.ts" probe "repo 沒有 3-specs → 寫 .py 不被擋" "$REPO_NO_SDD/src/x.py" probe "兩份 status: active(舊「單一活性」違反)→ 寫 .ts 不被擋" "$REPO_MULTI/src/x.ts" # ISEP 自己就是「沒有 3-specs 的 repo」——退役前,改自己的 .py 當場被擋。 probe "ISEP 自己的 hooks/lib/*.py → 不被擋(退役前這一格是紅的)" "$REPO_ROOT/hooks/lib/dispatch_parse.py" # ── ③ 會被載入的那幾份,不能再有人讀到「只准一份 active SDD」 ────── echo "── ③ 條文:載入面不再宣告單一活性鐵律 ──" # 只查**會被載入**的那幾份(commands / agents / skills / hooks 腳本)。 # docs/ 與 wiki/ 不查——它們得說得出「退役了什麼」,不然這件事沒有歷史。 STALE="" while IFS= read -r f; do grep -qE '只(准|允許)一份|單一活性' "$f" && STALE="$STALE $f" done < <(find "$REPO_ROOT/commands" "$REPO_ROOT/agents" "$REPO_ROOT/skills" -name '*.md' 2>/dev/null find "$REPO_ROOT/hooks" -maxdepth 1 -name '*.sh' 2>/dev/null) if [ -n "$STALE" ]; then bad "這些載入面還在宣告「只准一份 active SDD」:$STALE" else ok "commands/agents/skills/hooks 都不再宣告單一活性鐵律" fi echo echo "結果:通過 $PASS / 失敗 $FAIL" [ "$FAIL" -eq 0 ] || exit 1