Files
ISEP/hooks/tests/wiki-compress.test.sh
Claude Code da9bd53cae 沒人會叫的事會自己叫+讀不完的必讀檔要被整理(inkstone/ISEP#93、inkstone/ISEP#89)
兩張票放同一條分支:都是「該發生卻不會自己發生的事」,觸發點都在 session 邊界。

## inkstone/ISEP#93 —— 逾期和掛著沒人接的事會主動叫

- scripts/isep-nag        撈三種沒人會叫的事(逾期 milestone/等 leo 的票/掉在地上的棒子)
- scripts/isep-notify     發 Telegram,而且**發不出去的時候不會安靜**
- hooks/overdue-nag-guard.sh  SessionStart 跑一次(不輪詢、不 fan-out、不掛 Actions)
- hooks/tests/overdue-nag.test.sh  35 條,全程離線

實跑撈得出票上點名的那五個逾期 milestone(08-24 三個、08-26 兩個)。
沒東西可報時會說「查過了,沒有」——安靜跟壞掉長得一模一樣。

🔴 工單補的那個限制(今天實測出來的)已經處理:
「能不能發得出去」取決於這個 session 載到的 ISEP 是哪一版
(prod-write-guard v0.10.0 才認得出 notify_leo 不是部署,而 hook 註冊路徑
在 session 啟動當下就寫死了)。所以 isep-notify 會**先拿真的要送的那一則
去問這個 session 註冊的那支閘**,把判定寫成檔(誰都查得到),然後:
  · 放行 ⇒ 送,並驗內層 data.data.ok(外層 200 不算送到)
  · 會擋 ⇒ **不繞路**,改貼回票上並把原文印在眼前
用 git 歷史裡的真跡(c263866 那一版閘)測過「會擋」那條路。

 實測發現通道本身現在是斷的:實例上找不到 notify_leo 工作流(404)。
   不是閘、不是網路、不是金鑰。詳情與修法寫在 docs/TESTING.md 最後一段。
   退路兩次都走通了(inkstone/ISEP#93 comment 5182、5184)。

## inkstone/ISEP#89 —— wiki 太長時有人整理

- scripts/wiki-compress   audit/plan/apply/verify/bench 五個動詞
- hooks/wiki-size-guard.sh  SessionStart 點名太長的檔;寫檔時擋「沒走流程的壓縮」
- hooks/tests/wiki-compress.test.sh  25 條,全程離線、不碰真的 wiki

設計上最重要的一條:**只搬不改**,正文一個字都不動。
「合併同類、濃縮成一行」要重寫正文,而重寫的當下沒有人會發現弄丟了什麼。
所以機器只做「搬 + 目錄 + 標 ×N/↻」,合併留給人。

拿現在的 mistakes.md 複本實壓過(不動真的 wiki):
  7,681 行 → 1,199 行
  260 條一條都沒少(verify 用內文雜湊逐條對帳)
  80 個查詢命中率 80/80,定位成本 2,956 → 131 行,**快 22.5 倍**(bench)
verify 反向測過:真的弄丟一條時它抓得到(抓不到的對帳表比沒有更糟)。

## 盤點數字

在這棵樹上當場數的,不是拿上一版加減推的:
  ls hooks/*.sh | wc -l              → 55(was 53)
  grep -c '"command":' hooks.json    → 71(was 68)
plugin.json/README/hooks-inventory 三處同步改。
**版本號沒動**(0.11.0),待總管定版。
2026-08-28 01:04:16 +00:00

187 lines
10 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 ]