#!/usr/bin/env sh # lint-frontmatter.sh — 檢查一個 domain 存取庫每支 skills/*/SKILL.md 的 frontmatter 能不能安全解析。 # # 用法: lint-frontmatter.sh # # 檢查五項(掃 {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 ' >&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