#!/usr/bin/env bash # wiki 壓縮的迴歸測試(inkstone/ISEP#89) # # 對應票上那三條驗收: # 1 壓完之後同一件事查得到、而且查得比壓之前快 → C 群(bench) # 2 壓縮前後不能弄丟任何一條教訓(逐條對帳) → B 群(verify) # 3 壓縮這件事本身要有票、有交付紀錄 → D 群(--ticket + .compress-log.md) # # 另外兩群是這套的主要失效模式: # A 群 切條要切對 —— 切錯的話後面三件全部失準(尤其 code fence 裡的 `#`) # E 群 閘:大幅刪減要擋、正常編輯不准擋 —— **誤攔比漏擋更該修** # # 🔴 全程離線、只在 mktemp 目錄裡動檔案,不碰任何真的 wiki。 set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "$HERE/../.." && pwd)" WC="$ROOT/scripts/wiki-compress" HOOK="$ROOT/hooks/wiki-size-guard.sh" TMP=$(mktemp -d) trap 'rm -rf "$TMP" /tmp/.wiki-size-guard-wctest*' EXIT # 🔴 目錄結構要跟真的一樣(`<專案>/system-dev/wiki`),不然 F 群測到的是 # 「hook 找不到 wiki 目錄所以閉嘴」,那是假綠——它跟「都在門檻內」長得一模一樣。 W="$TMP/system-dev/wiki"; mkdir -p "$W" PASS=0; FAIL=0 ok(){ printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); } no(){ printf ' ❌ %s —— %s\n' "$1" "$2"; FAIL=$((FAIL+1)); } has(){ printf '%s' "$1" | grep -q -- "$2"; } # ── 測資:一份小而完整的假 wiki(含所有會出事的形狀)────────────────── { echo "# 假的 mistakes" echo echo "> 前言,壓縮後要留在最上面。" echo for i in $(seq 1 40); do d=$(printf '2026-0%d-%02d' $(( (i % 3) + 6 )) $(( (i % 27) + 1 ))) echo echo "## 第 $i 條:獨特關鍵詞 KEY$i 的教訓($d)" echo echo "**症狀**:某某東西壞了 $i。票:inkstone/ISEP#$((100 + i))" echo echo '```sh' echo "# 這一行是程式碼註解,不是標題 $i" echo "## 這一行也是,切條的時候不准把它當成一條" echo '```' echo for _ in $(seq 1 12); do echo "正文填充 $i:這一段是為了讓每條有足夠的行數。"; done done echo echo "## 自承重複的那一條:這是同款第三次(2026-08-20)" echo echo "同一個病又犯一次。票:inkstone/ISEP#999" } > "$W/mistakes.md" cp "$W/mistakes.md" "$TMP/orig.md" echo "── A 群:切條要切對(切錯的話後面三件全部失準)──────────────────" A=$(python3 "$WC" audit "$W" 2>&1) has "$A" "41 條" && ok "① 切出 41 條(40 + 自承重複那條)" \ || no "① 切出 41 條" "實得:$(printf '%s' "$A" | grep mistakes)" has "$A" "這一行也是" && no "② code fence 裡的 ## 不算一條" "算進去了" \ || ok "② code fence 裡的 ## 不算一條(mistakes.md 真的有這種行)" has "$A" "自承是同款" && ok "③ 認得出「自承是同款第 N 次」(人下的判斷比機器猜的準)" \ || no "③ 自承重複" "沒偵測到" echo "── B 群:不能弄丟任何一條(票上驗收 2)──────────────────────────" AP=$(WIKI_COMPRESS_THRESHOLD=400 python3 "$WC" apply "$W/mistakes.md" \ --ticket inkstone/ISEP#89 2>&1) has "$AP" "✅ 壓完了" && ok "④ 壓得動" || no "④ 壓得動" "$AP" V=$(python3 "$WC" verify "$W/mistakes.md.before-compress" "$W/mistakes.md" \ "$W"/mistakes-archive-*.md 2>&1) has "$V" "一條都沒弄丟" && ok "⑤ 逐條對帳:一條都沒弄丟" || no "⑤ 逐條對帳" "$V" diff -q "$TMP/orig.md" "$W/mistakes.md.before-compress" >/dev/null \ && ok "⑥ 壓縮前的原檔完整留著(.before-compress,錯了還原得回去)" \ || no "⑥ 原檔留底" "跟壓縮前不一樣" # 真的弄丟一條 ⇒ verify 一定要抓到。**抓不到的對帳表比沒有更糟。** cp "$W/mistakes.md" "$TMP/tampered.md" (python3 - "$W" "$TMP/tampered.md" <<'PY' import glob, os, re, sys w, tam = sys.argv[1], sys.argv[2] # 從某個 archive 裡整條刪掉一條,模擬「壓縮的時候弄丟了」 for f in sorted(glob.glob(os.path.join(w, "mistakes-archive-*.md"))): t = open(f, encoding="utf-8").read() parts = re.split(r"(?m)^## ", t) if len(parts) > 2: open(f + ".broken", "w", encoding="utf-8").write( parts[0] + "## " + "## ".join(parts[2:])) print(f + ".broken") break PY ) >/dev/null BROKEN=$(ls "$W"/mistakes-archive-*.md.broken 2>/dev/null | head -1) if [ -n "$BROKEN" ]; then KEEP=$(ls "$W"/mistakes-archive-*.md 2>/dev/null | grep -v '\.broken$' \ | grep -v "$(basename "${BROKEN%.broken}")" || true) # shellcheck disable=SC2086 V2=$(python3 "$WC" verify "$W/mistakes.md.before-compress" "$W/mistakes.md" \ "$BROKEN" $KEEP 2>&1); RC2=$? has "$V2" "弄丟了" && [ "$RC2" = 1 ] \ && ok "⑦ **真的弄丟一條時 verify 抓得到**(反向測過,離開碼 1)" \ || no "⑦ 反向測" "沒抓到(rc=$RC2):$(printf '%s' "$V2" | tr '\n' ' ')" else printf ' ⏭️ SKIP ⑦:這次沒有 archive 可以拿來破壞\n' fi echo "── C 群:查得到,而且查得更快(票上驗收 1)──────────────────────" B=$(python3 "$WC" bench "$W/mistakes.md.before-compress" "$W/mistakes.md" \ "$W"/mistakes-archive-*.md 2>&1) has "$B" "查得更快" && ok "⑧ 命中率沒掉,而且更快" || no "⑧ 更快" "$B" BEF=$(printf '%s' "$B" | grep -o '壓縮前.*平均 [0-9]* 行' | grep -o '[0-9]*' | tail -1) AFT=$(printf '%s' "$B" | grep -o '壓縮後.*平均 [0-9]* 行' | grep -o '[0-9]*' | tail -1) [ -n "$BEF" ] && [ -n "$AFT" ] && [ "$AFT" -lt "$BEF" ] \ && ok "⑨ 定位成本真的變小($BEF → $AFT 行)" \ || no "⑨ 定位成本" "前=$BEF 後=$AFT" has "$(cat "$W/mistakes.md")" "KEY7" && ok "⑩ 壓完的原檔裡查得到舊條目(目錄留了指標)" \ || no "⑩ 目錄留指標" "KEY7 在壓縮後的原檔裡不見了" echo "── D 群:壓縮這件事本身要有票、有交付紀錄(票上驗收 3)────────────" NT=$(python3 "$WC" apply "$W/mistakes.md" 2>&1); RCN=$? [ "$RCN" != 0 ] && ok "⑪ 沒給 --ticket 就不准壓" || no "⑪ 沒票不准壓" "居然壓了" BAD=$(python3 "$WC" apply "$W/mistakes.md" --ticket 89 2>&1); RCB=$? [ "$RCB" != 0 ] && ok "⑫ 裸號不算票號(跨 repo 會撞號)" || no "⑫ 裸號" "收了" [ -f "$W/.compress-log.md" ] && ok "⑬ 留下交付紀錄 .compress-log.md" \ || no "⑬ 交付紀錄" "沒有這個檔" L=$(cat "$W/.compress-log.md" 2>/dev/null) has "$L" "inkstone/ISEP#89" && ok "⑭ 紀錄裡寫著票號(誰批准壓的查得到)" || no "⑭ 票號" "$L" has "$L" "對帳指令" && ok "⑮ 紀錄裡寫著對帳指令(下一個人跑得起來)" || no "⑮ 對帳指令" "$L" echo "── E 群:閘——大幅刪減要擋,正常編輯不准擋 ──────────────────────" mk(){ printf '{"hook_event_name":"PreToolUse","session_id":"wctest%s","tool_name":"%s","tool_input":%s}' "$1" "$2" "$3"; } run(){ rm -f /tmp/.wiki-size-guard-wctest*; out=$(printf '%s' "$1" | bash "$HOOK" 2>&1); printf '%s|%s' "$?" "$(printf '%s' "$out" | tr '\n' ' ')"; } BIG=$(python3 -c 'print("這是一段很長的舊內容。" * 200)') SMALL="剩這一句。" R=$(run "$(mk 1 Edit "$(python3 -c " import json,sys print(json.dumps({'file_path':'$W/mistakes.md','old_string':'''$BIG''','new_string':'$SMALL'}, ensure_ascii=False))")")") case "$R" in 2\|*) ok "⑯ 砍掉一大段 wiki ⇒ 擋一次" ;; *) no "⑯ 該擋" "實得 $R" ;; esac has "$R" "只搬不改" && ok "⑰ 擋的訊息說得出出路(三步指令)" || no "⑰ 出路" "$R" R=$(run "$(mk 2 Edit "$(python3 -c " import json print(json.dumps({'file_path':'$W/mistakes.md','old_string':'這是一句舊的話,長度普通。','new_string':'這是一句新的話,長度也普通,改了幾個字。'}, ensure_ascii=False))")")") case "$R" in 0\|*) ok "⑱ 一般編輯不擋(誤攔比漏擋更該修)" ;; *) no "⑱ 不該擋" "實得 $R" ;; esac R=$(run "$(mk 3 Edit "$(python3 -c " import json print(json.dumps({'file_path':'/somewhere/src/app.ts','old_string':'x'*3000,'new_string':'y'}, ensure_ascii=False))")")") case "$R" in 0\|*) ok "⑲ 不是 wiki 的檔不管(別的檔縮水是它們自己的事)" ;; *) no "⑲ 非 wiki" "實得 $R" ;; esac R=$(run "$(mk 4 Edit "$(python3 -c " import json,sys print(json.dumps({'file_path':'$W/mistakes.md','old_string':'''$BIG''','new_string':'剩這一句。 wiki-compress-ok'}, ensure_ascii=False))")")") case "$R" in 0\|*) ok "⑳ wiki-compress-ok 留痕式豁免走得通" ;; *) no "⑳ 豁免" "實得 $R" ;; esac R=$(run "$(mk 5 Write "$(python3 -c " import json print(json.dumps({'file_path':'$W/從來沒有過的新檔.md','content':'新檔案的一點內容'}, ensure_ascii=False))")")") case "$R" in 0\|*) ok "㉑ 新建檔案不算壓縮" ;; *) no "㉑ 新檔" "實得 $R" ;; esac # 至多擋一次:同一個 session 連兩次,第二次要放行(不然會卡死) PAY=$(mk 6 Edit "$(python3 -c " import json print(json.dumps({'file_path':'$W/mistakes.md','old_string':'''$BIG''','new_string':'剩這一句。'}, ensure_ascii=False))")") rm -f /tmp/.wiki-size-guard-wctest6 printf '%s' "$PAY" | bash "$HOOK" >/dev/null 2>&1; first=$? printf '%s' "$PAY" | bash "$HOOK" >/dev/null 2>&1; second=$? [ "$first" = 2 ] && [ "$second" = 0 ] \ && ok "㉒ 至多擋一次(被擋兩次的閘會被想辦法繞過)" \ || no "㉒ 至多擋一次" "第一次 $first、第二次 $second" echo "── F 群:SessionStart 講出來哪幾個檔沒人讀得完 ──────────────────" S=$(printf '{"hook_event_name":"SessionStart","source":"startup"}' \ | CLAUDE_PROJECT_DIR="$TMP" WIKI_COMPRESS_THRESHOLD=50 bash "$HOOK" 2>/dev/null) printf '%s' "$S" | python3 -c 'import json,sys;json.load(sys.stdin)' 2>/dev/null \ && ok "㉓ 吐得出合法 JSON" || no "㉓ 合法 JSON" "實得:$(printf '%s' "$S" | head -c 200)" has "$S" "沒人讀得完" && ok "㉔ 真的點名超過門檻的檔" || no "㉔ 點名" "$S" S2=$(printf '{"hook_event_name":"SessionStart","source":"startup"}' \ | CLAUDE_PROJECT_DIR="$TMP" WIKI_COMPRESS_THRESHOLD=99999 bash "$HOOK" 2>/dev/null) [ -z "$S2" ] && ok "㉕ 都在門檻內就閉嘴(沒事還講話=下次沒人看)" || no "㉕ 沒事閉嘴" "$S2" echo printf '通過 %d 條,失敗 %d 條\n' "$PASS" "$FAIL" [ "$FAIL" = 0 ]