Why: the completion doc's N2 requires skills validated per the open spec; today frontmatter was a description-only line hack with silent skips, no name field, and no error surface. Evidence: GOWORK=off go vet + go test -count=1 ./... green; parser units cover all field forms and reject malformed shapes with located errors; the validation matrix covers kebab-case/name-equals-dir/ required description/limits; Store.Diagnostics surfaces per-skill reasons and Load returns the located validation error for same-named invalid skills; existing fixtures updated to spec form. Impact: stricter by design — skills without name/description or with unknown frontmatter keys now produce located errors instead of silently loading or silently skipping; ValidateSkillDocument is exported so the product plugin/seed paths share the same validator. Refs: Agent-System-Documents#42 (N2 plan WP4)
185 lines
6.4 KiB
Go
185 lines
6.4 KiB
Go
package skills
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
)
|
||
|
||
// frontmatter.go — SKILL.md frontmatter 的开放规范校验(N2 WP4)。
|
||
// 坚持不引入 YAML 依赖(skills.go 包注释的既有约束):手写严格子集,
|
||
// 只支持规范字段形态——标量、metadata 字符串映射、allowed-tools 空格
|
||
// 分隔列表;形态不合法即错误,不猜测。
|
||
|
||
// 已知键白名单(agentskills.io 开放规范);未知键带字段定位拒绝。
|
||
var knownFrontmatterKeys = map[string]bool{
|
||
"name": true, "description": true, "license": true,
|
||
"compatibility": true, "metadata": true, "allowed-tools": true,
|
||
}
|
||
|
||
const (
|
||
maxNameRunes = 64
|
||
maxDescription = 1024 // 规范上限(替换旧的 200 截断)
|
||
maxCompatibility = 500
|
||
maxMetadataEntries = 32
|
||
maxAllowedTools = 64
|
||
)
|
||
|
||
// ValidationError 是一条带字段定位的技能校验错误。
|
||
type ValidationError struct {
|
||
Path string // SKILL.md 路径
|
||
Field string // frontmatter 键("name"、"metadata.author"…);整体错误用 "-"
|
||
Msg string
|
||
}
|
||
|
||
func (e ValidationError) Error() string {
|
||
return fmt.Sprintf("%s: frontmatter %s: %s", e.Path, e.Field, e.Msg)
|
||
}
|
||
|
||
// Frontmatter 是解析+校验后的 SKILL.md 头。
|
||
type Frontmatter struct {
|
||
Name string
|
||
Description string
|
||
License string
|
||
Compatibility string
|
||
Metadata map[string]string
|
||
AllowedTools []string
|
||
}
|
||
|
||
// parseFrontmatter 解析 frontmatter 子集。无 frontmatter 时返回 nil(由
|
||
// 调用方按「name/description 必填」判错)。data 是完整文件内容。
|
||
func parseFrontmatter(path, text string) (*Frontmatter, string, error) {
|
||
fm := &Frontmatter{Metadata: map[string]string{}}
|
||
lines := strings.Split(text, "\n")
|
||
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
|
||
return fm, text, nil
|
||
}
|
||
end := -1
|
||
for i := 1; i < len(lines); i++ {
|
||
if strings.TrimSpace(lines[i]) == "---" {
|
||
end = i
|
||
break
|
||
}
|
||
}
|
||
if end < 0 {
|
||
return nil, "", ValidationError{Path: path, Field: "-", Msg: "frontmatter 起始 --- 后找不到结束 ---"}
|
||
}
|
||
body := strings.Join(lines[end+1:], "\n")
|
||
for i := 1; i < end; i++ {
|
||
line := lines[i]
|
||
trimmed := strings.TrimSpace(line)
|
||
if trimmed == "" {
|
||
continue
|
||
}
|
||
// metadata 的缩进映射行在父键循环里处理。
|
||
key, value, found := strings.Cut(trimmed, ":")
|
||
if !found {
|
||
return nil, "", ValidationError{Path: path, Field: "-", Msg: fmt.Sprintf("第 %d 行不是 key: value 形态", i)}
|
||
}
|
||
key = strings.TrimSpace(key)
|
||
if !knownFrontmatterKeys[key] {
|
||
return nil, "", ValidationError{Path: path, Field: key, Msg: "未知 frontmatter 键(开放规范白名单:name/description/license/compatibility/metadata/allowed-tools)"}
|
||
}
|
||
value = strings.TrimSpace(value)
|
||
switch key {
|
||
case "metadata":
|
||
if value != "" {
|
||
return nil, "", ValidationError{Path: path, Field: "metadata", Msg: "metadata 必须是缩进映射,不接受行内值"}
|
||
}
|
||
j := i + 1
|
||
for ; j < end; j++ {
|
||
sub := lines[j]
|
||
if strings.TrimSpace(sub) == "" {
|
||
continue
|
||
}
|
||
if !strings.HasPrefix(sub, " ") && !strings.HasPrefix(sub, "\t") {
|
||
break
|
||
}
|
||
sk, sv, ok := strings.Cut(strings.TrimSpace(sub), ":")
|
||
if !ok || strings.TrimSpace(sk) == "" {
|
||
return nil, "", ValidationError{Path: path, Field: "metadata", Msg: fmt.Sprintf("第 %d 行不是 key: value 形态", j)}
|
||
}
|
||
if len(fm.Metadata) >= maxMetadataEntries {
|
||
return nil, "", ValidationError{Path: path, Field: "metadata." + strings.TrimSpace(sk), Msg: fmt.Sprintf("metadata 超过 %d 条上限", maxMetadataEntries)}
|
||
}
|
||
fm.Metadata[strings.TrimSpace(sk)] = strings.TrimSpace(sv)
|
||
}
|
||
i = j - 1
|
||
case "name":
|
||
fm.Name = value
|
||
case "description":
|
||
fm.Description = value
|
||
case "license":
|
||
fm.License = value
|
||
case "compatibility":
|
||
fm.Compatibility = value
|
||
case "allowed-tools":
|
||
if value == "" {
|
||
return nil, "", ValidationError{Path: path, Field: "allowed-tools", Msg: "allowed-tools 为空(应为空格分隔的工具名列表)"}
|
||
}
|
||
for _, t := range strings.Fields(value) {
|
||
fm.AllowedTools = append(fm.AllowedTools, t)
|
||
}
|
||
if len(fm.AllowedTools) > maxAllowedTools {
|
||
return nil, "", ValidationError{Path: path, Field: "allowed-tools", Msg: fmt.Sprintf("allowed-tools 超过 %d 条上限", maxAllowedTools)}
|
||
}
|
||
}
|
||
}
|
||
return fm, body, nil
|
||
}
|
||
|
||
// validateFrontmatter 按开放规范校验(dirName 是目录名,name 须与之相等)。
|
||
func validateFrontmatter(path, dirName string, fm *Frontmatter) error {
|
||
if fm.Name == "" {
|
||
return ValidationError{Path: path, Field: "name", Msg: "name 必填(开放规范)"}
|
||
}
|
||
if !validSkillName(fm.Name) {
|
||
return ValidationError{Path: path, Field: "name", Msg: fmt.Sprintf("name %q 不是 kebab-case(小写字母数字与单连字符,1-64 字符)", fm.Name)}
|
||
}
|
||
if fm.Name != dirName {
|
||
return ValidationError{Path: path, Field: "name", Msg: fmt.Sprintf("name %q 与目录名 %q 不一致(规范要求同名)", fm.Name, dirName)}
|
||
}
|
||
if strings.TrimSpace(fm.Description) == "" {
|
||
return ValidationError{Path: path, Field: "description", Msg: "description 必填且非空(开放规范)"}
|
||
}
|
||
if len([]rune(fm.Description)) > maxDescription {
|
||
return ValidationError{Path: path, Field: "description", Msg: fmt.Sprintf("description 超过 %d 字符上限", maxDescription)}
|
||
}
|
||
if len([]rune(fm.Compatibility)) > maxCompatibility {
|
||
return ValidationError{Path: path, Field: "compatibility", Msg: fmt.Sprintf("compatibility 超过 %d 字符上限", maxCompatibility)}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// validSkillName 是开放规范的 kebab-case:小写字母/数字段,段间单连字符,
|
||
// 无首尾/连续连字符,1-64 字符。
|
||
func validSkillName(s string) bool {
|
||
if s == "" || len(s) > maxNameRunes {
|
||
return false
|
||
}
|
||
if strings.HasPrefix(s, "-") || strings.HasSuffix(s, "-") || strings.Contains(s, "--") {
|
||
return false
|
||
}
|
||
for _, r := range s {
|
||
switch {
|
||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// ValidateSkillDocument 解析并校验一份 SKILL.md(供组合根以外的消费方
|
||
// 复用同一校验器:插件技能装载、种子写入前置检查)。dirName 是技能目录名
|
||
// (name 必须与之相等);path 只用于错误定位。
|
||
func ValidateSkillDocument(dirName, path string, data []byte) error {
|
||
if len(data) > maxBodyBytes {
|
||
data = data[:maxBodyBytes]
|
||
}
|
||
fm, _, err := parseFrontmatter(path, string(data))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return validateFrontmatter(path, dirName, fm)
|
||
}
|