feat(frontmatter-lint): 新增 SKILL.md frontmatter 解析檢查並併入例行稽核
What:
- 新增 tools/lint-frontmatter.sh,掃一個 domain 每支 skills/*/SKILL.md 的 frontmatter,檢查分隔線成對、必要鍵齊全、未加引號的純量不含「冒號加空白」、起頭字元不是 YAML 特殊字元、加了引號的值收得起來,共五項。
- 改寫 skills/skill-check/SKILL.md 的第一組稽核,把這支腳本併進去成為第 2 步,原本的行為清單檢查、結束碼路由檢查、hook smoke 依序後移。
- 第二組留白的檢查清單項目由四項改成五項,第 3 步的合併說明、三組的完成條件、第 6 步的重驗完成條件同步改寫。
Why:
- 抓到 6 支技能的 description 是未加引號的 YAML 純量、內容含「冒號加空白」。那在 YAML 是鍵的分隔符號,整份 frontmatter 當場語法錯誤。
- Antigravity 讀到語法錯誤就靜默丟棄整支技能。磁碟上 34 支,它只認 28 支。載入器不報、CLI 不報,技能清單只是少了幾列。
- 這種缺陷唯一的發現途徑是逐檔比對磁碟數量與載入數量。人工比對 10 個 domain 每次稽核都要重做一遍,還會漏。輸入輸出固定的判定就交給程式。
How:
- 腳本用 awk 自己判定 YAML 1.2 的 plain scalar 規則,不相依 pyyaml。護欄不綁在一個不保證存在的相依上,才跑得到每一台機器。
- 單引號的跳脫是重複一次、雙引號的跳脫是反斜線,兩套規則不同,所以引號改用逐字掃描,不用正規表示式一次比對兩種。
- 結束碼分四種:0 是掃到 SKILL.md 且五項全過、1 是有不合格項目(清單走 stderr,格式 {檔案}:{鍵}:{說明})、2 是用法錯誤、3 是什麼都沒掃。
- SKILL.md 明寫退出 3 不算通過,並把「每個 domain 的 lint-frontmatter.sh 退出 0」列進第 6 步的完成條件。
Who:
屬 CLI hook 接線修正(jsc-hooks 0.3.4)在 meta 這一側的稽核工具。
This commit is contained in:
Executable
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env sh
|
||||
# lint-frontmatter.sh — 檢查一個 domain 存取庫每支 skills/*/SKILL.md 的 frontmatter 能不能安全解析。
|
||||
#
|
||||
# 用法: lint-frontmatter.sh <domain-path>
|
||||
#
|
||||
# 檢查五項(掃 {domain-path}/skills/*/SKILL.md):
|
||||
# 1. 分隔線 — 第一行是「---」,而且找得到成對的收尾「---」。缺一邊,底下整段都不是
|
||||
# frontmatter,鍵一個都讀不到。
|
||||
# 2. 必要鍵 — 「name」與「description」都在,而且值不是空的。載入器靠這兩個鍵認技能。
|
||||
# 3. 冒號 — 未加引號的純量不得含「冒號加空白」,也不得以冒號結尾。那在 YAML 是鍵的
|
||||
# 分隔符號,解析器會把一行拆成兩個鍵,整份 frontmatter 當場語法錯誤。
|
||||
# 4. 起頭字元 — 未加引號的純量不得以 & * ! | > % @ ` 起頭。這八個在 YAML 1.2 分別是錨點、
|
||||
# 別名、標籤、區塊純量、指令與保留字元,起頭寫了就不是原本那串字。
|
||||
# 5. 引號 — 加了引號的值要收得起來:單引號內部的「'」要寫成「''」,收尾引號之後除了
|
||||
# 註解不得有殘餘。修這個缺陷的手法就是補單引號,補歪了照樣是語法錯誤。
|
||||
#
|
||||
# 為什麼要這支: 2026-08-31 抓到 6 支技能的 description 是未加引號的純量、內容含「冒號加空白」。
|
||||
# 那在 YAML 是語法錯誤,Antigravity 讀到就**靜默丟棄整支技能**——磁碟上 34 支,它只認 28 支,
|
||||
# 而且**完全沒有錯誤訊息**:載入器不報、CLI 不報、技能清單只是少了幾列。這種缺陷唯一的發現
|
||||
# 途徑是逐檔比對磁碟數量與載入數量,人工比對 10 個 domain 每次稽核都要重做一遍,還會漏。
|
||||
# 輸入輸出固定的判定就交給程式,別靠眼睛。
|
||||
#
|
||||
# 為什麼不用 YAML 套件: 本機沒有 pyyaml,而護欄不該把自己綁在一個不保證存在的相依上。這五項
|
||||
# 都只需要 YAML 1.2 的 plain scalar 規則,自己判定就夠,也才跑得到每一台機器上。
|
||||
#
|
||||
# 輸出: 一行一個不合格項目,格式 {檔案}:{鍵}:{說明}(stderr);通過時在 stderr 印一行摘要。
|
||||
# 結構性問題(分隔線、必要鍵、無法辨識的一行)的「鍵」欄寫 frontmatter。stdout 不印東西。
|
||||
# 結束碼: 0=掃到 SKILL.md 且五項全過
|
||||
# 1=有不合格項目(清單在 stderr)
|
||||
# 2=用法錯誤(本腳本只吃一個參數)
|
||||
# 3=domain 路徑不存在、找不到 {domain-path}/skills/,或 skills/ 底下一支 SKILL.md
|
||||
# 都沒有——**什麼都沒掃**,不等於通過
|
||||
set -u
|
||||
|
||||
usage() {
|
||||
echo 'usage: lint-frontmatter.sh <domain-path>' >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
[ "$#" -eq 1 ] || usage
|
||||
DOMAIN=${1%/}
|
||||
[ -n "$DOMAIN" ] || usage
|
||||
|
||||
SKILLS="$DOMAIN/skills"
|
||||
[ -d "$DOMAIN" ] || { echo "找不到 domain 路徑:$DOMAIN" >&2; exit 3; }
|
||||
[ -d "$SKILLS" ] || { echo "找不到 skills/:$SKILLS" >&2; exit 3; }
|
||||
|
||||
TMP=$(mktemp) || { echo "無法建立暫存檔" >&2; exit 3; }
|
||||
trap 'rm -f "$TMP"' EXIT
|
||||
|
||||
find "$SKILLS" -mindepth 2 -maxdepth 2 -type f -name 'SKILL.md' 2>/dev/null \
|
||||
| LC_ALL=C sort > "$TMP"
|
||||
[ -s "$TMP" ] || { echo "skills/ 底下沒有任何 SKILL.md,無 frontmatter 可掃:$SKILLS" >&2; exit 3; }
|
||||
|
||||
BOM=$(printf '\357\273\277')
|
||||
|
||||
hit=0
|
||||
total=0
|
||||
while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
total=$((total + 1))
|
||||
awk -v f="$f" -v bom="$BOM" '
|
||||
function trim(s) { gsub(/^[ \t\r]+/, "", s); gsub(/[ \t\r]+$/, "", s); return s }
|
||||
function rep(k, m) { printf "%s:%s:%s\n", f, k, m; bad = 1 }
|
||||
|
||||
# 掃過一段引號括起來的值,回傳收尾引號之後的殘餘;收不起來就回哨兵值。
|
||||
# 為什麼要自己逐字掃: 單引號的跳脫是「重複一次」、雙引號的跳脫是反斜線,兩套規則不同,
|
||||
# 用正規表示式一次比對兩種只會在其中一種上判錯。
|
||||
function scan_quoted(v, q, i, c, n) {
|
||||
n = length(v)
|
||||
i = 2
|
||||
while (i <= n) {
|
||||
c = substr(v, i, 1)
|
||||
if (q == "\"" && c == "\\") { i += 2; continue }
|
||||
if (c == q) {
|
||||
if (q == "\047" && substr(v, i + 1, 1) == "\047") { i += 2; continue }
|
||||
return substr(v, i + 1)
|
||||
}
|
||||
i++
|
||||
}
|
||||
return "\001"
|
||||
}
|
||||
|
||||
BEGIN { state = 0; bad = 0 }
|
||||
{ sub(/\r$/, "") }
|
||||
|
||||
NR == 1 {
|
||||
line = $0
|
||||
sub("^" bom, "", line)
|
||||
if (trim(line) != "---") {
|
||||
rep("frontmatter", "第一行不是 ---,整份 frontmatter 讀不到,載入器會靜默丟棄這支技能")
|
||||
state = 3 # 已判定並回報,END 不必再補話
|
||||
exit
|
||||
}
|
||||
state = 1
|
||||
next
|
||||
}
|
||||
|
||||
# frontmatter 收尾。YAML 的文件結束標記「...」一樣算收尾。
|
||||
state == 1 && (trim($0) == "---" || trim($0) == "...") { state = 2; next }
|
||||
|
||||
state == 1 {
|
||||
if ($0 ~ /^[ \t]/) next # 縮排的續行或巢狀對應,判不出就不判
|
||||
if ($0 ~ /^[ \t]*$/) next # 空行
|
||||
if ($0 ~ /^#/) next # 註解
|
||||
if ($0 ~ /^- /) next # 與鍵同縮排的序列項
|
||||
|
||||
if ($0 !~ /^[A-Za-z0-9_.-]+:([ \t]|$)/) {
|
||||
rep("frontmatter", "這一行既不是鍵也不是續行,YAML 解析會在這裡中斷:" substr($0, 1, 40))
|
||||
next
|
||||
}
|
||||
|
||||
ci = index($0, ":")
|
||||
k = substr($0, 1, ci - 1)
|
||||
v = trim(substr($0, ci + 1))
|
||||
|
||||
if (k in seen) rep(k, "同一個鍵出現兩次,後面那份會靜默蓋掉前面那份")
|
||||
seen[k] = 1
|
||||
val[k] = v
|
||||
|
||||
if (v == "") next # 值在下一段,前面的縮排規則已經放過
|
||||
|
||||
q = substr(v, 1, 1)
|
||||
if (q == "\047" || q == "\"") {
|
||||
rest = scan_quoted(v, q)
|
||||
if (rest == "\001") {
|
||||
if (q == "\047") rep(k, "單引號沒有收尾,內部的 \047 要寫成 \047\047")
|
||||
else rep(k, "雙引號沒有收尾")
|
||||
} else {
|
||||
rest = trim(rest)
|
||||
if (rest != "" && substr(rest, 1, 1) != "#")
|
||||
rep(k, "收尾引號之後還有內容,解析器會當成語法錯誤:" substr(rest, 1, 30))
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
# 以下都是未加引號的純量(plain scalar)。
|
||||
if (index("&*!|>%@`", q) > 0)
|
||||
rep(k, "未加引號的純量以 YAML 特殊字元「" q "」起頭,會被當成錨點、別名、標籤、區塊純量或指令")
|
||||
|
||||
if (index(v, ": ") > 0)
|
||||
rep(k, "未加引號的純量含「冒號加空白」,YAML 會把它當成鍵的分隔符號,整份 frontmatter 語法錯誤;整串加單引號即可")
|
||||
else if (substr(v, length(v), 1) == ":")
|
||||
rep(k, "未加引號的純量以冒號結尾,YAML 會把它當成鍵的分隔符號;整串加單引號即可")
|
||||
}
|
||||
|
||||
END {
|
||||
if (state == 0) {
|
||||
rep("frontmatter", "檔案是空的,沒有 frontmatter")
|
||||
} else if (state == 1) {
|
||||
rep("frontmatter", "frontmatter 分隔線不成對,找不到收尾的 ---")
|
||||
} else if (state == 2) {
|
||||
if (!("name" in seen)) rep("frontmatter", "缺必要鍵 name")
|
||||
else if (val["name"] == "") rep("name", "必要鍵的值是空的")
|
||||
if (!("description" in seen)) rep("frontmatter", "缺必要鍵 description")
|
||||
else if (val["description"] == "") rep("description", "必要鍵的值是空的")
|
||||
}
|
||||
exit bad
|
||||
}
|
||||
' "$f" >&2 || hit=1
|
||||
done < "$TMP"
|
||||
|
||||
if [ "$hit" -eq 0 ]; then
|
||||
echo "frontmatter 檢查通過:$total 支(分隔線、必要鍵、冒號、起頭字元、引號)" >&2
|
||||
else
|
||||
echo "frontmatter 檢查有不合格項目:共掃 $total 支,清單在上面" >&2
|
||||
fi
|
||||
exit $hit
|
||||
Reference in New Issue
Block a user