Files
meta/tools/lint-frontmatter.sh
T
jiantw83 d879e634c0 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 這一側的稽核工具。
2026-08-31 19:13:03 +08:00

169 lines
7.1 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.
#!/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