feat(檢核): 新增腳本路徑檢核,判文件裡寫的路徑解不解得出來

lint-scripts.sh 掃的是 tools/ 與 hooks/ 目錄裡的檔案,它證明得了「這個存取庫
裡有這支腳本」,卻證明不了「文件裡寫的那條路徑解得出那支腳本」。兩件事差
很遠,而中間那道縫剛好放得下一整類缺失。

2026-09-04 實際踩到:一份技能本文把自家腳本寫成不帶前綴的相對路徑,而那支
技能的基底目錄是 skills/{名稱}/,照字面解出來的位置沒有那個檔案,結束碼 127。
人工覆核看的是「tools/ 底下有沒有這支」,有,於是每一輪都過。覆核的問題本身
問錯了,所以錯幾次都不會被發現。

三種判定:指到不存在的檔案算缺失;不帶 domain 名但檔案在這個存取庫底下只算
提示;跨 domain 而那個存取庫不在這台機器上也只算提示。中間那一種為什麼不算
缺失——全套技能組有三百多處那樣寫,判成缺失會讓每個存取庫同時亮紅,而一份
全紅的報告跟沒有報告一樣。先量出來、指得出是哪幾行,改不改由人決定。

跨 domain 的路徑靠 plugins-root.sh 找別的存取庫,推不出來的那幾筆印成提示,
不當缺失:這台機器沒裝,跟路徑寫錯,是兩件事。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 10:55:14 +08:00
co-authored by Claude Opus 5
parent 54e77a8da9
commit 763709ed7b
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env sh
# check-skill-paths.sh — 檢查一個 domain 存取庫的文件裡,寫出來的腳本路徑解不解得出來。
#
# 用法: check-skill-paths.sh <domain-path>
#
# 檢查兩項(掃 {domain-path} 底下的 skills/*/SKILL.md 與 references/*.md):
# 一、帶 domain 名的路徑(jsc-{domain}/tools/x.sh、{domain}/hooks/x.sh)——那個檔案要
# 真的在對應的存取庫底下。指到不存在的檔案算缺失。
# 二、不帶 domain 名的路徑(tools/x.sh、hooks/x.sh)——檔案在這個存取庫底下就只算提示,
# 不算缺失,理由見下面「為什麼不帶前綴只算提示」。檔案根本不在,才算缺失。
#
# 為什麼要有這支: lint-scripts.sh 掃的是 tools/ 與 hooks/ 目錄裡的檔案,它證明得了
# 「這個存取庫裡有這支腳本、語法沒問題」,卻證明不了「文件裡寫的那條路徑解得出那支腳本」。
# 兩件事差很遠。2026-09-04 實際踩到: 一份技能本文把自家腳本寫成不帶前綴的 tools/x.sh,
# 而那支技能的基底目錄是 skills/{名稱}/,照字面解出來是 skills/{名稱}/tools/x.sh,
# 結束碼 127。人工覆核那一輪看的是「tools/ 底下有沒有 x.sh」,有,於是就過了——
# 覆核的問題問錯了,所以每一輪都會再過一次。
#
# 為什麼不帶前綴只算提示,不算缺失:
# 不帶前綴的相對路徑會相對於**當下的工作目錄**解。在自己那個存取庫裡跑,剛好解得對;
# 換一個工作目錄就解到別人的 tools/ 底下,或者解不出來。兩種都不會有明確的錯誤訊息,
# 前者更糟——它會跑起來,跑的是另一支腳本。無人值守那一輪還多一層: 相對路徑進不了
# 權限允許清單(那邊比對的是完整字面絕對路徑),會被靜靜擋掉。
# 話說回來,這是整個技能組共通的寫法,一次上百處。把它判成缺失會讓每一個存取庫都紅,
# 而一份全紅的報告跟沒有報告一樣。所以先量出來、指得出是哪幾行,改不改由人決定。
#
# 判定範圍與其極限: 只認 tools/ 與 hooks/ 兩個目錄底下的 *.sh。templates/ 與 references/
# 底下的檔案不在這一支的範圍內——那些是資料不是入口,指錯了不會變成一支跑起來的別的程式。
# 跨 domain 的路徑要那個 domain 的存取庫也在這台機器上才驗得到;不在就印成提示,
# 不當缺失。這台機器沒裝,跟路徑寫錯,是兩件事。
#
# 輸出: 一行一項,格式 {檔案}:{行號}:{類別}:{路徑}(stdout);統計摘要走 stderr。
# 類別 missing=指到不存在的檔案,unrooted=不帶前綴、相對於工作目錄解,
# unknown=跨 domain 但那個存取庫不在這台機器上。
# 結束碼: 0=掃到文件且沒有缺失(提示不算缺失)
# 1=有缺失(missing 那幾行)
# 2=用法錯誤(本腳本只吃一個參數)
# 3=domain 路徑不存在,或底下一份可掃的文件都沒有——**什麼都沒掃**,不等於通過
set -u
usage() {
echo 'usage: check-skill-paths.sh <domain-path>' >&2
exit 2
}
[ "$#" -eq 1 ] || usage
DOMAIN=${1%/}
[ -n "$DOMAIN" ] || usage
[ -d "$DOMAIN" ] || { echo "找不到 domain 路徑:$DOMAIN" >&2; exit 3; }
HERE=$(CDPATH= cd -P -- "$(dirname -- "$0")" && pwd -P)
# 根目錄推導與別的檢核腳本共用同一套規則。跨 domain 的路徑要靠它才找得到別的存取庫;
# 推不出來不是致命的,那幾筆改印成 unknown 提示。
ROOT=''
if [ -f "$HERE/plugins-root.sh" ]; then
# shellcheck source=/dev/null
. "$HERE/plugins-root.sh"
ROOT=$(jsc_plugins_root 2>/dev/null) || ROOT=''
fi
TMP=$(mktemp) || { echo "無法建立暫存檔" >&2; exit 3; }
HITS=$(mktemp) || { rm -f "$TMP"; echo "無法建立暫存檔" >&2; exit 3; }
trap 'rm -f "$TMP" "$HITS"' EXIT
# skills/*/SKILL.md 與 references/*.md 兩處。README 不掃: 那裡的路徑是給人看的目錄,
# 不是叫誰去跑的入口。
find "$DOMAIN/skills" -type f -name 'SKILL.md' -print 2>/dev/null > "$TMP"
find "$DOMAIN/references" -type f -name '*.md' -print 2>/dev/null >> "$TMP"
sort -o "$TMP" "$TMP"
[ -s "$TMP" ] || { echo "底下沒有 skills/*/SKILL.md 也沒有 references/*.md,無文件可掃:$DOMAIN" >&2; exit 3; }
TOTAL=$(wc -l < "$TMP" | tr -d ' ')
# 這個存取庫自己的 domain 名。目錄名可能帶 jsc- 前綴(安裝後)或不帶(工作目錄版面),
# 兩種都要認得,否則自家的路徑會被當成跨 domain 而驗不到。
SELF=$(basename -- "$DOMAIN")
SELF=${SELF#jsc-}
# 找某個 domain 的存取庫目錄。工作目錄版面不帶前綴、安裝後帶前綴,兩種都試。
domain_dir() { # $1=domain 名
[ -n "$ROOT" ] || return 1
if [ -d "$ROOT/jsc-$1" ]; then printf '%s' "$ROOT/jsc-$1"; return 0; fi
if [ -d "$ROOT/$1" ]; then printf '%s' "$ROOT/$1"; return 0; fi
return 1
}
while IFS= read -r f; do
[ -n "$f" ] || continue
# 第一趟:帶 domain 名的路徑。前面可能還接著根目錄代入點(例如 {CURRENT}/),
# 那不影響判定——要驗的是 {domain}/{tools|hooks}/{檔名} 這一段。
grep -noE '(jsc-)?[a-z][a-z0-9-]*/(tools|hooks)/[a-z0-9-]+\.sh' "$f" 2>/dev/null |
while IFS=: read -r ln path; do
dom=${path%%/*}
dom=${dom#jsc-}
rest=${path#*/}
if [ "$dom" = "$SELF" ]; then
[ -f "$DOMAIN/$rest" ] || printf '%s:%s:missing:%s\n' "$f" "$ln" "$path"
continue
fi
if dir=$(domain_dir "$dom"); then
[ -f "$dir/$rest" ] || printf '%s:%s:missing:%s\n' "$f" "$ln" "$path"
else
printf '%s:%s:unknown:%s\n' "$f" "$ln" "$path"
fi
done
# 第二趟:不帶 domain 名的路徑。字元集把斜線排掉,第一趟已經算過的那幾筆才不會再算一次。
grep -noE '(^|[^-a-zA-Z0-9/_.{])(tools|hooks)/[a-z0-9-]+\.sh' "$f" 2>/dev/null |
while IFS=: read -r ln raw; do
path=$(printf '%s' "$raw" | sed 's|^[^t h]*||; s|^\(tools\|hooks\)|\1|')
case "$path" in
tools/*|hooks/*) ;;
*) path=${raw#?} ;;
esac
case "$path" in
tools/*|hooks/*) ;;
*) continue ;;
esac
if [ -f "$DOMAIN/$path" ]; then
printf '%s:%s:unrooted:%s\n' "$f" "$ln" "$path"
else
printf '%s:%s:missing:%s\n' "$f" "$ln" "$path"
fi
done
done < "$TMP" | sort -u > "$HITS"
N_MISS=$(grep -c ':missing:' "$HITS" 2>/dev/null || true)
N_UNROOT=$(grep -c ':unrooted:' "$HITS" 2>/dev/null || true)
N_UNKNOWN=$(grep -c ':unknown:' "$HITS" 2>/dev/null || true)
: "${N_MISS:=0}" "${N_UNROOT:=0}" "${N_UNKNOWN:=0}"
[ -s "$HITS" ] && cat "$HITS"
if [ "$N_MISS" -gt 0 ]; then
echo "腳本路徑檢查有缺失:共掃 $TOTAL 份文件,$N_MISS 條指到不存在的檔案,另有 $N_UNROOT 條不帶 domain 名、$N_UNKNOWN 條跨 domain 但存取庫不在這台機器上" >&2
exit 1
fi
echo "腳本路徑檢查通過:共掃 $TOTAL 份文件,沒有指到不存在的檔案;另有 $N_UNROOT 條不帶 domain 名、$N_UNKNOWN 條跨 domain 但存取庫不在這台機器上,兩種都只是提示" >&2
exit 0