fix(smoke): 擋人斷言改依各 CLI 的形態判定

What:
- wire-cli.sh 的 smoke 區段新增四個共用小函式:smoke_deny_rc 給結束碼、smoke_deny_mark 給擋人標記、smoke_deny_ok 做判定、smoke_deny_desc 產生失敗訊息。
- smoke_rs_case 與 smoke_vg_case 的判定改走 smoke_deny_ok,五個呼叫點的預期值由結束碼 2 改成字面值 deny。
- 三份 manifest 的版本一起提升,由 sync-skill-manifest.sh 同步。

Why:
- 兩個函式把「擋下」寫死成結束碼 2,但擋下的形態是由 deny.sh 依 CLI 決定的。claude、codex、copilot 與認不得的代號走 stderr 加結束碼 2;antigravity 改印一行 stdout 的 deny JSON,kiro 只能注入警告,這兩支的結束碼都固定 0。
- 結果是這兩支的 smoke 各有五條判定失敗,回報成執行期錯誤。但擋人訊息其實都正確印出來了,配套的訊息斷言也全部通過,壞的只有結束碼那一項比對——是斷言認錯形態,不是 hook 失效。
- 不能改成一律放寬到 0。那兩支上放行也是 0,放寬之後「該擋沒擋」與「正確擋下」完全同形,這道斷言等於作廢。

How:
- 形態表在 smoke 這側鏡射一份,事實來源仍是 deny.sh 的 case。表只有一份,改一支不會忘了另一支。
- 判定同時比結束碼與擋人標記;預期放行的案例反過來要求標記不得出現,所以「該擋沒擋」與「不該擋卻擋了」兩個方向都守得住。
- 走 stderr 的三支標記為空字串,判定行為與原本完全相同,不產生回歸。
- 斷言條數不增不減,兩個預期條數常數都不必動。
- 反向測試確認斷言仍然有效:拿掉 deny.sh 裡 antigravity 的 deny JSON 輸出,做出該擋卻靜靜放行的情境,smoke 正確判失敗。結束碼相同,靠擋人標記才分得出來。

Who:
接線後的冒煙測試在 antigravity 與 kiro 上判定失敗,追出來的是斷言本身的缺陷。
This commit is contained in:
2026-09-01 11:48:13 +08:00
parent 462264a253
commit fbf0aec9f7
4 changed files with 56 additions and 14 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jsc-hooks",
"version": "0.3.4",
"version": "0.3.5",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門",
"skills": "./skills",
"author": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"hooks": "./hooks/codex-hooks.json",
"name": "jsc-hooks",
"version": "0.3.4",
"version": "0.3.5",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門",
"skills": "./skills",
"jsc": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jsc-hooks",
"version": "0.3.4",
"version": "0.3.5",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門",
"skills": "./skills/",
"jsc": {
+53 -11
View File
@@ -1506,16 +1506,57 @@ if [ "$action" = smoke ]; then
# 舊格式的單一狀態檔照樣擋得下來。用暫時目錄是為了不動到使用者真正的
# $JSC_HOME/restart-required.d/——冒煙測試不該把別人的閘門拆掉。
# 一律 </dev/null:hook 模式會讀標準輸入,管線沒人關閉時整支卡死。
smoke_rs_case() { # $1=情境 $2=技能名 $3=預期結束碼 $4=JSC_RESTART_GATE 值(可省略)
#
# 「擋下」的形態依 CLI 而定,事實來源是 hooks/deny.sh 的 case:claude、codex、copilot 與認不得
# 的代號走 stderr 加結束碼 2;antigravity 改印一行 stdout 的 deny JSON,kiro 只能注入警告,
# 這兩支的結束碼都固定 0。所以預期擋下不能寫死 2。
# 也不能一律放寬成 0:那兩支上放行也是 0,放寬之後「該擋沒擋」與「正確擋下」完全同形,
# 這道斷言等於作廢。解法是結束碼與擋人標記一起比,而且預期放行的案例要反過來確認標記沒出現,
# 兩個方向都守得住。既有的 smoke_dn_case 與 smoke_cx_case 已經是這個做法,這裡沿用同一套。
smoke_deny_rc() { # 這支 CLI 擋下時的結束碼
case "$cli" in antigravity|kiro) printf 0 ;; *) printf 2 ;; esac
}
smoke_deny_mark() { # 結束碼分不出擋下與放行時,輸出必定出現的擋人標記;空字串代表結束碼已經夠用
case "$cli" in
antigravity) printf '"decision":"deny"' ;;
kiro) printf 'kiro 擋不下技能叫用' ;;
*) printf '' ;;
esac
}
smoke_deny_ok() { # $1=預期(數字,或字面值 deny)$2=實際結束碼 $3=實際輸出;回 0 代表通過
_d_want=$1; _d_rc=$2; _d_out=$3
_d_mark=$(smoke_deny_mark)
if [ "$_d_want" = deny ]; then
[ "$_d_rc" -eq "$(smoke_deny_rc)" ] || return 1
[ -n "$_d_mark" ] || return 0
printf '%s' "$_d_out" | grep -qF "$_d_mark"
return $?
fi
[ "$_d_rc" -eq "$_d_want" ] || return 1
[ -n "$_d_mark" ] || return 0
! printf '%s' "$_d_out" | grep -qF "$_d_mark"
}
smoke_deny_desc() { # $1=預期;把預期講成失敗訊息看得懂的形態
_d_mark=$(smoke_deny_mark)
if [ "$1" = deny ]; then
if [ -n "$_d_mark" ]; then printf 'exit %s 且輸出含「%s」' "$(smoke_deny_rc)" "$_d_mark"
else printf 'exit %s' "$(smoke_deny_rc)"; fi
else
if [ -n "$_d_mark" ]; then printf 'exit %s 且輸出不含「%s」' "$1" "$_d_mark"
else printf 'exit %s' "$1"; fi
fi
}
smoke_rs_case() { # $1=情境 $2=技能名 $3=預期(結束碼,或 deny)$4=JSC_RESTART_GATE 值(可省略)
_out=$(JSC_HOME="$rs_home" JSC_CLI="$cli" JSC_SKILL="$2" SKILL="$2" \
JSC_RESTART_GATE="${4:-}" sh "$HOOKS/restart-gate.sh" </dev/null 2>&1); _rc=$?
smoke_n_rs=$((smoke_n_rs + 1))
if [ "$_rc" -eq "$3" ]; then
if smoke_deny_ok "$3" "$_rc" "$_out"; then
printf '[jsc] restart-gate.sh(%s):exit %s,與預期相同。\n' "$1" "$_rc" >> "$smoke_out"
else
smoke_fails=$((smoke_fails + 1))
printf '[jsc] restart-gate.sh(%s):exit %s,預期 %s,重啟閘門判定壞了:%s\n' \
"$1" "$_rc" "$3" "$(printf '%s' "$_out" | tr '\n' ' ' | cut -c1-200)" >> "$smoke_out"
"$1" "$_rc" "$(smoke_deny_desc "$3")" \
"$(printf '%s' "$_out" | tr '\n' ' ' | cut -c1-200)" >> "$smoke_out"
fi
}
# 狀態檔在不在也要比:一支 CLI 一份的重點就在「該留的留、該刪的刪」,只看結束碼看不出來。
@@ -1540,7 +1581,7 @@ if [ "$action" = smoke ]; then
JSC_HOME="$rs_home" JSC_CLI="$cli" \
sh "$HOOKS/restart-gate.sh" require update hooks cli </dev/null 2>/dev/null
smoke_rs_file "require 寫出當前 CLI 那一份" "$rs_dir/$cli" exist
smoke_rs_case "當前 CLI 那份存在,技能 jsc-sdlc:implement" jsc-sdlc:implement 2
smoke_rs_case "當前 CLI 那份存在,技能 jsc-sdlc:implement" jsc-sdlc:implement deny
smoke_rs_case "當前 CLI 那份存在,豁免技能 jsc-cli:deploy" jsc-cli:deploy 0
smoke_rs_case "當前 CLI 那份存在,豁免技能 jsc-gitea:wiki" jsc-gitea:wiki 0
smoke_rs_case "當前 CLI 那份存在,豁免技能 jsc-log:worklog" jsc-log:worklog 0
@@ -1557,7 +1598,7 @@ if [ "$action" = smoke ]; then
# 舊格式的單一狀態檔(過渡相容):沒有 per-CLI 資訊,所以一律擋,clear 一併刪掉。
printf 'at=%s\nmode=update\ndomains=hooks\ncli=%s\n' "$(now_iso)" "$rs_other" \
> "$rs_home/restart-required" 2>/dev/null
smoke_rs_case "舊格式單一狀態檔存在" jsc-sdlc:implement 2
smoke_rs_case "舊格式單一狀態檔存在" jsc-sdlc:implement deny
JSC_HOME="$rs_home" JSC_CLI="$cli" JSC_SESSION_ID=smoke-restart-legacy \
sh "$HOOKS/session-timer.sh" restart </dev/null 2>/dev/null
smoke_rs_file "清除一併刪掉舊格式狀態檔" "$rs_home/restart-required" absent
@@ -1658,7 +1699,7 @@ if [ "$action" = smoke ]; then
# GITEA_HOST 一律清空:放行的案例會繼續往下走到遠端比對,站台推導得出來就會真的連網,
# 冒煙不該依賴網路,也不該讓遠端落後把預期放行的案例判成擋下。
# 一律 </dev/null:hook 模式會讀標準輸入,管線沒人關閉時整支卡死。
smoke_vg_case() { # $1=情境 $2=技能名 $3=預期結束碼 $4=額外環境設定(KEY=值,可省略)
smoke_vg_case() { # $1=情境 $2=技能名 $3=預期(結束碼,或 deny)$4=額外環境設定(KEY=值,可省略)
# 第四個參數省略時仍要餵一個合法的 KEY=值 給 env,否則它會把空字串當成要執行的指令。
# 這個名字沒有任何 hook 讀它,只是佔位。
_extra="${4:-JSC_VERSION_GUARD_UNUSED=1}"
@@ -1668,12 +1709,13 @@ if [ "$action" = smoke ]; then
JSC_TOOL_NAME=Skill JSC_SKILL="$2" SKILL="$2" "$_extra" \
sh "$HOOKS/version-guard.sh" </dev/null 2>&1); _rc=$?
smoke_n_vg=$((smoke_n_vg + 1))
if [ "$_rc" -eq "$3" ]; then
if smoke_deny_ok "$3" "$_rc" "$vg_out"; then
printf '[jsc] version-guard.sh(%s):exit %s,與預期相同。\n' "$1" "$_rc" >> "$smoke_out"
else
smoke_fails=$((smoke_fails + 1))
printf '[jsc] version-guard.sh(%s):exit %s,預期 %s,相依版本判定壞了:%s\n' \
"$1" "$_rc" "$3" "$(printf '%s' "$vg_out" | tr '\n' ' ' | cut -c1-200)" >> "$smoke_out"
"$1" "$_rc" "$(smoke_deny_desc "$3")" \
"$(printf '%s' "$vg_out" | tr '\n' ' ' | cut -c1-200)" >> "$smoke_out"
fi
}
# 訊息內容也要比:擋下來卻沒指名是哪一個相依落後,使用者看不出要更新哪一個 plugin,
@@ -1710,7 +1752,7 @@ if [ "$action" = smoke ]; then
# 一、相依確定落後:擋下,而且訊息要指名是哪一項、需要哪一版、目前哪一版。
printf '{"name": "jsc-sdlc", "version": "0.1.0", "jsc": {"requires": {"jsc-cli": ">=9.9.9"}}}\n' \
> "$vg_manifest"
smoke_vg_case "相依落後" jsc-sdlc:implement 2
smoke_vg_case "相依落後" jsc-sdlc:implement deny
smoke_vg_msg "相依落後時訊息指名那一項" "jsc-cli 需要 >=9.9.9,目前 0.1.0"
# 二、相依相等或超前都放行:只擋落後,本機超前是開發技能組時的常態,擋了維護者自己動不了。
@@ -1724,7 +1766,7 @@ if [ "$action" = smoke ]; then
# 三、豁免技能:這一條最要緊,它證明閘門不會把更新與修復的唯一路徑鎖死。同一個 jsc-cli
# plugin 底下,非豁免的技能在相依落後時被擋、豁免的那一支照樣放行,差別只在豁免清單。
smoke_vg_case "豁免技能 jsc-cli:deploy,相依落後照樣放行" jsc-cli:deploy 0
smoke_vg_case "同 plugin 的非豁免技能 jsc-cli:doctor" jsc-cli:doctor 2
smoke_vg_case "同 plugin 的非豁免技能 jsc-cli:doctor" jsc-cli:doctor deny
# 四、fail-open 四條:沒有證據不等於落後。五支 CLI 只有 claude 讀得到本機載入版本,
# 這四條任何一條改成擋人,另外四支就會被整批鎖死。
@@ -1746,7 +1788,7 @@ if [ "$action" = smoke ]; then
# 前面那一項則是滿足的:漏掉最後一筆就會變成放行,當場被這一條抓出來。
printf '{\n "name": "jsc-sdlc",\n "version": "0.1.0",\n "jsc": {\n "requires": {\n "jsc-hooks": ">=0.0.1",\n "jsc-cli": ">=9.9.9"\n }\n }\n}\n' \
> "$vg_manifest"
smoke_vg_case "多行縮排 manifest,落後的相依擺在最後一個鍵" jsc-sdlc:implement 2
smoke_vg_case "多行縮排 manifest,落後的相依擺在最後一個鍵" jsc-sdlc:implement deny
smoke_vg_msg "最後一個鍵的落後項也列進訊息" "jsc-cli 需要 >=9.9.9,目前 0.1.0"
rm -rf "$vg_home"
else