例行稽核新增腳本路徑檢核,判文件裡寫的路徑解不解得出來 #74

Merged
admin merged 4 commits from feat/check-skill-script-paths-resolve into develop 2026-09-04 03:33:15 +00:00
Showing only changes of commit 763709ed7b - Show all commits
+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