5173c7f3e5
三支測試在乾淨 main 上一直紅,全部是測試自己的 bug,不是工具的離開碼漂了:
1. hooks/tests/wiki-compress.test.sh(⑯⑰㉒):E 群拼 payload 時把
{'file_path':...} 直接嵌進三層巢狀 "$(...)" 裡;其中一層雙引號提早收口,
讓大括號在 bash 眼裡變成「未加引號」,觸發大括號展開——逗號分隔的
內容被拆成三條各自獨立的 python3 -c 呼叫,`{`/`}` 本身被吃掉,
python 因此噴三次 SyntaxError。改用兩支小函式(json_edit/json_write)
把內容經環境變數傳給 python,內容永遠不會被當成 bash 原始碼解析。
2. scripts/test-stage.sh(A1–A4、D1–D4、D14):全部卡在
`[ "$(wc -l < "$LOG")" = 0 ]`——macOS/BSD 的 `wc -l` 會在數字前墊空白
(GNU coreutils 不會),比對永遠是 " 0" != "0"。工具本身的離開碼
是對的(rc=2,正確拒絕),是測試的比對方式在這台機器上有 bug。
一律加 `| tr -d " "` 去掉墊白。
3. scripts/test-milestone-account.sh(⑰):同一款 macOS `wc -l` 墊白 bug,
帳本筆數比對 `[ "$(wc -l < ...)" = "4" ]` 永遠比不過。同樣修法。
三支都在 `env -u CLAUDE_CODE_CHILD_SESSION` 下重跑到 exit 0:
wiki-compress.test.sh 25/25 通過
test-stage.sh 45/45 通過(連跑 3 次穩定)
test-milestone-account.sh 23/23 通過
跑過 docs/TESTING.md 記載的其餘 52 支測試(不含兩支需要真 Claude CLI 的
live 測試),全數 0 失敗,唯一例外是 hooks/tests/mainline-members-by-dependency.test.sh
的 ㊽(帳本 cwd 形狀)——在乾淨的 main(未套用本次修改)上同樣紅,
與本次改動的三個檔案無關,非本票範圍,另行處理。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
200 lines
11 KiB
Bash
Executable File
200 lines
11 KiB
Bash
Executable File
#!/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' ' ')"; }
|
||
|
||
# 🔴 這兩支輔助函式的存在理由(inkstone/ISEP#161):舊版把 $BIG/$SMALL 直接嵌進
|
||
# `python3 -c "..."` 的原始碼字串裡,而外層又套了三層 "$(...)"。當中一層雙引號
|
||
# 提早收口,讓 `{'file_path':...}` 這段在 bash 眼裡變成「未加引號」,觸發**大括號展開**
|
||
# (逗號分隔 → 拆成三條各自獨立的 python3 -c 呼叫,`{`/`}` 本身被吃掉)——
|
||
# 症狀就是 payload 少了 dict 的大括號、且 python 噴三次 SyntaxError。
|
||
# 改用環境變數把內容傳給 python:內容永遠不會被當成 bash 原始碼解析,
|
||
# 也不必再猜三層引號怎麼配對。
|
||
json_edit(){ # file_path old_string new_string
|
||
PL_FILE="$1" PL_OLD="$2" PL_NEW="$3" python3 -c '
|
||
import json, os
|
||
print(json.dumps({
|
||
"file_path": os.environ["PL_FILE"],
|
||
"old_string": os.environ["PL_OLD"],
|
||
"new_string": os.environ["PL_NEW"],
|
||
}, ensure_ascii=False))'
|
||
}
|
||
json_write(){ # file_path content
|
||
PL_FILE="$1" PL_CONTENT="$2" python3 -c '
|
||
import json, os
|
||
print(json.dumps({
|
||
"file_path": os.environ["PL_FILE"],
|
||
"content": os.environ["PL_CONTENT"],
|
||
}, ensure_ascii=False))'
|
||
}
|
||
|
||
BIG=$(python3 -c 'print("這是一段很長的舊內容。" * 200)')
|
||
SMALL="剩這一句。"
|
||
R=$(run "$(mk 1 Edit "$(json_edit "$W/mistakes.md" "$BIG" "$SMALL")")")
|
||
case "$R" in 2\|*) ok "⑯ 砍掉一大段 wiki ⇒ 擋一次" ;; *) no "⑯ 該擋" "實得 $R" ;; esac
|
||
has "$R" "只搬不改" && ok "⑰ 擋的訊息說得出出路(三步指令)" || no "⑰ 出路" "$R"
|
||
|
||
R=$(run "$(mk 2 Edit "$(json_edit "$W/mistakes.md" "這是一句舊的話,長度普通。" "這是一句新的話,長度也普通,改了幾個字。")")")
|
||
case "$R" in 0\|*) ok "⑱ 一般編輯不擋(誤攔比漏擋更該修)" ;; *) no "⑱ 不該擋" "實得 $R" ;; esac
|
||
|
||
R=$(run "$(mk 3 Edit "$(json_edit "/somewhere/src/app.ts" "$(python3 -c "print('x' * 3000)")" "y")")")
|
||
case "$R" in 0\|*) ok "⑲ 不是 wiki 的檔不管(別的檔縮水是它們自己的事)" ;; *) no "⑲ 非 wiki" "實得 $R" ;; esac
|
||
|
||
R=$(run "$(mk 4 Edit "$(json_edit "$W/mistakes.md" "$BIG" "剩這一句。 wiki-compress-ok")")")
|
||
case "$R" in 0\|*) ok "⑳ wiki-compress-ok 留痕式豁免走得通" ;; *) no "⑳ 豁免" "實得 $R" ;; esac
|
||
|
||
R=$(run "$(mk 5 Write "$(json_write "$W/從來沒有過的新檔.md" "新檔案的一點內容")")")
|
||
case "$R" in 0\|*) ok "㉑ 新建檔案不算壓縮" ;; *) no "㉑ 新檔" "實得 $R" ;; esac
|
||
|
||
# 至多擋一次:同一個 session 連兩次,第二次要放行(不然會卡死)
|
||
PAY=$(mk 6 Edit "$(json_edit "$W/mistakes.md" "$BIG" "剩這一句。")")
|
||
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 ]
|