Files
ISEP/scripts/test-comment-carries-task-guard.sh
T
Leo 58c3689e4b 閘教的那條出路要真的走得通(inkstone/ISEP#112)
comment-carries-task-guard 擋下留言時教人跑 `scripts/ticket subtask …`,
而那一行跑不動。兩個獨立的原因,各自都足以害死它:

① 相對路徑對「貼上去那個人的 cwd」解析。總管的 cwd 是 InkStoneCo/,
   那裡的 scripts/ticket 是停在 08-27 之前的舊複本(grep -c subtask → 0),
   照著貼只印出它「四個動詞」的說明然後 exit 0。
② 用法是手抄的第二份,而它已經漂了:寫了 --assign 卻沒有 --next,
   正本會因為「指派了人卻沒寫 --next」直接擋下(exit 2,在打 API 之前)。

修法是拿掉可以漂的那一格,不是把兩份對齊一次:
- 路徑     scripts/ticket 的 self_path()(__file__ 的絕對路徑)
           ⇒ 印指令的人與跑指令的人是同一個檔案
- 用法     USAGE / usage_text() 一份,閘去 `ticket usage subtask --example` 現要
- 內文模板 body_template() 由 REQUIRED_SECTIONS 現生,跟檢查同源

新增動詞 `ticket usage <verb> [--example] [--parent] [--draft]`。
閘的訊息改成整塊可貼可跑:腳本絕對路徑、母票已填、內文草稿附在同一塊裡,
只剩標題/目標/驗收條件三格是機器不可能知道的。
正本叫不動時 fail-open 的只有「訊息內容」(只指路,不補一份手抄用法),
擋不擋不受影響。

測試 scripts/test-comment-carries-task-guard.sh 22 → 29 條。
新增的 ⑮–㉑ 驗的是「出路走不走得通」,不是「有沒有擋」——
⑱ 把閘 stderr 裡那塊指令原樣抽出來、只填三格、真的執行一次,
離開碼必須是 1(走到網路才停);是 2 就代表這個病復發。
六個變異全部驗過會紅(相對路徑/閘自己抄用法/--assign 沒配 --next/
模板寫死少一段/fallback 補回手抄用法)。

版本:待總管定版(plugin update 比的是版本號不是內容)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SP7DiGMfSkWjZ15CHMbPFE
2026-08-31 11:01:45 +00:00

222 lines
12 KiB
Bash
Executable File
Raw 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.
#!/bin/bash
# 討論串裡的任務要長成子票——這道閘的測試
# 判準:6 種該擋(留言裡真的藏著待辦)、10 種不該擋
# 🔴 「不該擋」比「該擋」重要:branch-holds.md 檔頭那句——
# 永遠在響的警報,等於訓練人忽略這個警報。
cd "$(dirname "$0")/.." || exit 1
H=hooks/comment-carries-task-guard.sh
PASS=0; FAIL=0
SID=test-cctg-$$
run(){ # $1=want $2=cmd
rm -f "/tmp/.comment-task-guard-warned-$SID"
printf '%s' "{\"session_id\":\"$SID\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$2")}}" \
| bash "$H" >/dev/null 2>&1
got=$?
if [ "$got" = "$1" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
printf 'want=%s got=%s %.62s\n' "$1" "$got" "$2"
}
echo "── 該擋:留言裡藏著一件還沒做完的事 ──"
# 這一條就是 2026-08-26 真的掉了的那則的形狀
run 2 'req("POST", f"{API}/repos/inkstone/arcrun-rag/issues/136/comments", {"body":"Phase 1 的驗收在雲端那半出貨之前驗不了"})'
run 2 'curl -X POST "$API/repos/inkstone/Arcrun/issues/12/comments" -d "{\"body\":\"要等 leo 開閘才能部署\"}"'
run 2 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"[blocker] 收端還沒上線"})'
run 2 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"這條路徑還沒測,先擱著"})'
run 2 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"TODO: 補一條端到端測試"})'
run 2 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"等雲端出貨之後要回頭重驗一次"})'
echo "── 不該擋 ──"
run 0 'curl -s "$API/repos/inkstone/ISEP/issues/30/comments"' # 純讀
run 0 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"已驗過,8/8 全綠"})' # 沒有待辦形狀
run 0 'scripts/ticket subtask inkstone/arcrun-rag#136 --title "身為x,我要y,我才z" -F /tmp/c.md'
run 0 'scripts/ticket handback inkstone/arcrun-rag#136 --to claude-code --next "還沒驗的那格去驗"'
run 0 'scripts/ticket mine'
run 0 'scripts/ticket close inkstone/x#1 --deliverable https://example.invalid/pr/1'
run 0 'grep -n "驗不了" hooks/comment-carries-task-guard.sh' # 只是讀這支閘
run 0 'echo "這則留言等雲端出貨才驗得了 —— no-subtask: 它已經有子票 #143 了"' # 逃生口
run 0 'curl -X POST "$API/repos/x/y/issues" -d "{\"title\":\"等出貨\"}"' # 開新票不歸這支管
run 0 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"🏃 **棒子交回** → claude-code\n\n**下一步**:等 #143"})'
echo "── 交棒標記只認第一行(2026-08-27 dogfood 抓到的漏)──"
# 真的交棒留言:🏃 在第一行 ⇒ 放行
run 0 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"🏃 **棒子交回** → claude-code\n\n**下一步**:等 #143"})'
# 交件報告**引用**了一段有 🏃 的輸出,而報告本身帶著待辦 ⇒ 不准被豁免
TMPR=$(mktemp)
printf '交件報告\n\n```\n🏃 在總管手上的棒子:2 根\n```\n\n還有一格等雲端那半出貨才驗得了\n' > "$TMPR"
run 2 "scripts/ticket say inkstone/ISEP#30 -F $TMPR"
rm -f "$TMPR"
echo "── 同一 session 只擋一次(不鬼打牆)──"
rm -f "/tmp/.comment-task-guard-warned-$SID"
C='req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"驗不了"})'
for want in 2 0; do
printf '%s' "{\"session_id\":\"$SID\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$C")}}" \
| bash "$H" >/dev/null 2>&1
got=$?
if [ "$got" = "$want" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
printf 'want=%s got=%s 第 %s 次同樣的指令\n' "$want" "$got" "$([ "$want" = 2 ] && echo 1 || echo 2)"
done
echo "── -F <檔>:內文在檔案裡也要看得到 ──"
TMPF=$(mktemp); printf '這件事等雲端那半出貨才驗得了\n' > "$TMPF"
run 2 "scripts/ticket say inkstone/arcrun-rag#136 -F $TMPF"
printf '已驗完,全部綠燈\n' > "$TMPF"
run 0 "scripts/ticket say inkstone/arcrun-rag#136 -F $TMPF"
rm -f "$TMPF" "/tmp/.comment-task-guard-warned-$SID"
echo "── 閘教的那行指令要真的跑得動(inkstone/ISEP#112)──"
# 🔴 這一組**不看離開碼**,看訊息內容、而且**真的把訊息裡那塊指令執行一次**。
# ISEP wiki mistakes.md 記過:訊息壞掉時閘照樣 exit 2,只驗離開碼完全看不出來。
# 這張票就是那個形狀的極端版——閘擋對了,但它教的解法**根本不存在**。
SID2=test-cctg-msg-$$
rm -f "/tmp/.comment-task-guard-warned-$SID2"
PAYLOAD=$(python3 -c '
import json, sys
cmd = ("curl -X POST \"$API/repos/inkstone/arcrun-rag/issues/136/comments\" "
"-d \x27{\"body\":\"Phase 1 的驗收在雲端那半出貨之前驗不了\"}\x27")
print(json.dumps({"session_id": sys.argv[1], "tool_name": "Bash",
"tool_input": {"command": cmd}}, ensure_ascii=False))
' "$SID2")
MSGFILE=$(mktemp)
printf '%s' "$PAYLOAD" | bash "$H" 2>"$MSGFILE" >/dev/null
rm -f "/tmp/.comment-task-guard-warned-$SID2"
RESFILE=$(mktemp)
PAYFILE=$(mktemp); printf '%s' "$PAYLOAD" > "$PAYFILE"
rm -f "/tmp/.comment-task-guard-warned-$SID2"
MSGFILE="$MSGFILE" RESFILE="$RESFILE" GUARD="$H" PAYLOAD="$PAYFILE" python3 - <<'PY'
import io, os, re, subprocess, sys
msg = io.open(os.environ["MSGFILE"], encoding="utf-8").read()
res = []
def t(ok, name, detail=""):
res.append((bool(ok), name, detail))
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) \
if "__file__" in dir() else os.getcwd()
ticket = os.path.realpath(os.path.join(os.getcwd(), "scripts", "ticket"))
# ⑮ 那塊指令**真正要執行的那一行**,開頭是絕對路徑,而且指到這一份正本。
# 🔴 判準刻意放在「要執行的那一行」上,不是「訊息裡出現過的所有路徑」——
# 後者在相對路徑混進來時是**空過**的(別的行還有絕對路徑,all() 照樣真)。
# 2026-08-31 變異測試當場抓到:把 example 改回相對路徑,舊寫法的 ⑮ 沒紅。
run_line = ""
for l in msg.splitlines():
if " subtask " in l and l.strip().endswith("\\"):
run_line = l.strip()
break
first = run_line.split()[0] if run_line else ""
t(first.startswith("/") and os.path.realpath(first) == ticket,
"⑮ 要執行的那一行開頭是絕對路徑,且就是這一份正本",
"那一行:%s" % (run_line or "(找不到)"))
# ⑯ 訊息裡**不准**出現相對的 `scripts/ticket`
# 相對路徑是對「貼上去那個人的 cwd」解析的,而 InkStoneCo/scripts/ticket 是舊複本。
rel = [l for l in msg.splitlines()
if re.search(r"(?<![\w/])scripts/ticket\b", l)]
t(not rel, "⑯ 訊息裡沒有任何相對路徑的 scripts/ticket",
"違規行:%s" % (rel[:2] or ""))
# ⑰ 訊息裡的用法區塊 `ticket usage subtask` 的輸出(**一字不差**
# 這就是「不再有第二份可以漂」的機械證明:漂了這一條當場紅。
usage = subprocess.run([sys.executable, ticket, "usage", "subtask"],
capture_output=True, text=True).stdout.strip()
flat_msg = "\n".join(l.strip() for l in msg.splitlines())
flat_use = "\n".join(l.strip() for l in usage.splitlines())
t(flat_use and flat_use in flat_msg,
"⑰ 訊息裡的用法就是 `ticket usage subtask` 的輸出,沒有第二份",
"" if flat_use in flat_msg else "訊息裡找不到正本印的用法")
def extract_block(m):
"""把訊息裡那塊指令**原樣**抽出來——不重新產生,否則就驗不到漂移。"""
lines = m.splitlines()
i = next(k for k, l in enumerate(lines) if l.startswith("cat > "))
md = next(k for k in range(i, len(lines)) if lines[k] == "MD")
j = next(k for k in range(md + 1, len(lines)) if not lines[k].endswith("\\"))
return "\n".join(lines[i:j + 1])
def fill(block):
"""只填三格機器不可能知道的:標題、目標、驗收條件。其餘一個字都不改。"""
block = (block.replace("<誰>", "跑這行指令的人")
.replace("<什麼>", "它真的跑得動")
.replace("<為什麼>", "不會被迫去繞過它"))
return re.sub(r"<[^<>\n]+>", "測試填的內容", block)
ENV = dict(os.environ, TICKET_HOST="http://127.0.0.1:9",
GITEA_TOKEN_CLAUDE_CODE="dummy", GITEA_TOKEN="dummy")
# ⑱ ★ 把它原樣貼上、只填那三格,**真的跑一次**。
# 離開碼 2 = 被某道閘擋下(動詞不存在/旗標不成組/標題不合格…)= 這張票的病復發
# 離開碼 1 = 一路走到網路才炸(TICKET_HOST 指到連不上的位址)= 參數全部過關
try:
blk = fill(extract_block(msg))
r = subprocess.run(["bash", "-c", blk], capture_output=True, text=True, env=ENV)
code = r.returncode
except StopIteration:
blk, code, r = "", -1, None
t(code == 1, "⑱ ★ 訊息裡那塊指令原樣貼上就跑得完(走到網路才停,不是被閘擋)",
"離開碼 %s2 又被某道閘擋下)%s"
% (code, ("" + (r.stderr or "").strip().splitlines()[0][:80]) if r and code == 2 else ""))
# ⑲ 鑑別力對照:⑱ 不是「只要跑就會過」。
# 把閘**原本**印的那組旗標(`--assign <誰做>` 沒配 `--next`)接回去 ⇒ 必須紅。
if blk:
bad = blk.rstrip() + " --assign claude-code"
rb = subprocess.run(["bash", "-c", bad], capture_output=True, text=True, env=ENV)
t(rb.returncode == 2,
"⑲ 對照組:閘原本那組旗標(--assign 沒配 --next)確實跑不完",
"離開碼 %s(要是 2;不是 2 就表示 ⑱ 沒有鑑別力)" % rb.returncode)
else:
t(False, "⑲ 對照組:抽不出指令區塊", "")
# ⑳ 草稿模板與必填檢查**同源**:模板裡一定有 REQUIRED_SECTIONS 的每一段。
# 寫死一份模板的話,有人往 REQUIRED_SECTIONS 加一段就會生出
# 「照著貼卻過不了自己那道閘」的票——正是這張票要修的形狀。
req = subprocess.run(
[sys.executable, "-c",
"import runpy,sys;m=runpy.run_path(sys.argv[1]);"
"print('\\x00'.join(m['REQUIRED_SECTIONS']));"
"print(m['body_template']())", ticket],
capture_output=True, text=True)
head, _, tmpl = req.stdout.partition("\n")
secs = [x for x in head.split("\x00") if x]
t(secs and all(x in tmpl for x in secs) and all(x in msg for x in secs),
"⑳ 草稿模板由 REQUIRED_SECTIONS 現生,訊息裡也齊",
"必填段:%s" % secs)
# ㉑ 正本叫不動時(雲端裝壞、路徑不對):**閘照樣擋**,而且**不准就地補一份手抄用法**
# ——那份副本正是這張票拔掉的東西。fail-open 的是「訊息內容」,不是「擋不擋」。
fb = subprocess.run(["bash", os.environ["GUARD"]],
input=io.open(os.environ["PAYLOAD"], encoding="utf-8").read(),
capture_output=True, text=True,
env=dict(os.environ, CLAUDE_PLUGIN_ROOT="/tmp/isep-no-such-root"))
fbmsg = fb.stderr
t(fb.returncode == 2 and "usage subtask" in fbmsg
and "--title" not in fbmsg and "[--journey" not in fbmsg,
"㉑ 正本叫不動時仍然擋,且只指路、不補一份手抄用法",
"離開碼 %s|訊息裡有沒有手抄用法:%s"
% (fb.returncode, "有(違規)" if "[--journey" in fbmsg else "沒有"))
npass = 0
for ok, name, detail in res:
print(" %s %s%s" % ("✅" if ok else "❌", name,
(" ※ " + detail) if (detail and not ok) else ""))
npass += 1 if ok else 0
io.open(os.environ["RESFILE"], "w", encoding="utf-8").write(
"%d %d" % (npass, len(res) - npass))
PY
read -r P2 F2 < "$RESFILE"
PASS=$((PASS + P2)); FAIL=$((FAIL + F2))
rm -f "$MSGFILE" "$RESFILE" "$PAYFILE" "/tmp/isep-subtask-$SID2.md" \
"/tmp/.comment-task-guard-warned-$SID2"
echo
echo "$PASS/$((PASS+FAIL)) 通過"
[ "$FAIL" = 0 ]