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)
111 lines
4.0 KiB
Go
111 lines
4.0 KiB
Go
package skills
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// ---- N2 WP4:开放规范校验矩阵 ----
|
|
|
|
func TestParseFrontmatterAllFields(t *testing.T) {
|
|
text := "---\n" +
|
|
"name: docx-author\n" +
|
|
"description: 生成 Word\n" +
|
|
"license: Apache-2.0\n" +
|
|
"compatibility: 需要 python-docx\n" +
|
|
"metadata:\n" +
|
|
" author: 交付组\n" +
|
|
" version: \"1.0\"\n" +
|
|
"allowed-tools: read_file write_file\n" +
|
|
"---\n\n正文"
|
|
fm, body, err := parseFrontmatter("x/SKILL.md", text)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fm.Name != "docx-author" || fm.Description != "生成 Word" || fm.License != "Apache-2.0" ||
|
|
fm.Compatibility != "需要 python-docx" || body == "" || !strings.Contains(body, "正文") {
|
|
t.Fatalf("%+v body=%q", fm, body)
|
|
}
|
|
if fm.Metadata["author"] != "交付组" || fm.Metadata["version"] != "\"1.0\"" {
|
|
t.Fatalf("metadata: %+v", fm.Metadata)
|
|
}
|
|
if len(fm.AllowedTools) != 2 || fm.AllowedTools[0] != "read_file" || fm.AllowedTools[1] != "write_file" {
|
|
t.Fatalf("allowed-tools: %+v", fm.AllowedTools)
|
|
}
|
|
}
|
|
|
|
func TestValidateFrontmatterMatrix(t *testing.T) {
|
|
mk := func(mutate func(*Frontmatter)) *Frontmatter {
|
|
fm := &Frontmatter{Name: "good-name", Description: "d", Metadata: map[string]string{}}
|
|
mutate(fm)
|
|
return fm
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
fm *Frontmatter
|
|
dir string
|
|
wantKey string
|
|
}{
|
|
{"缺 name", mk(func(f *Frontmatter) { f.Name = "" }), "good-name", "name"},
|
|
{"name 大写", mk(func(f *Frontmatter) { f.Name = "Bad-Name" }), "good-name", "name"},
|
|
{"name 下划线", mk(func(f *Frontmatter) { f.Name = "bad_name" }), "good-name", "name"},
|
|
{"name 连续连字符", mk(func(f *Frontmatter) { f.Name = "bad--name" }), "good-name", "name"},
|
|
{"name 尾连字符", mk(func(f *Frontmatter) { f.Name = "bad-" }), "good-name", "name"},
|
|
{"name≠目录", mk(func(f *Frontmatter) {}), "other-dir", "name"},
|
|
{"缺 description", mk(func(f *Frontmatter) { f.Description = " " }), "good-name", "description"},
|
|
{"description 超长", mk(func(f *Frontmatter) { f.Description = strings.Repeat("长", 1025) }), "good-name", "description"},
|
|
{"compatibility 超长", mk(func(f *Frontmatter) { f.Compatibility = strings.Repeat("长", 501) }), "good-name", "compatibility"},
|
|
}
|
|
for _, tc := range cases {
|
|
err := validateFrontmatter("p/SKILL.md", tc.dir, tc.fm)
|
|
ve, ok := err.(ValidationError)
|
|
if err == nil || !ok || ve.Field != tc.wantKey {
|
|
t.Fatalf("%s: 期望 %s 字段错误,得到 %v", tc.name, tc.wantKey, err)
|
|
}
|
|
if !strings.Contains(ve.Error(), "p/SKILL.md") {
|
|
t.Fatalf("%s: 错误必须带路径:%v", tc.name, ve)
|
|
}
|
|
}
|
|
// 合法形态通过。
|
|
if err := validateFrontmatter("p/SKILL.md", "good-name", mk(func(f *Frontmatter) {})); err != nil {
|
|
t.Fatalf("合法形态被误拒:%v", err)
|
|
}
|
|
}
|
|
|
|
func TestParseFrontmatterRejectsBadShapes(t *testing.T) {
|
|
cases := map[string]string{
|
|
"未知键": "---\nname: a\ndescription: d\nbogus: x\n---\n",
|
|
"非 kv 行": "---\nname: a\n这是垃圾\n---\n",
|
|
"metadata 行内值": "---\nname: a\ndescription: d\nmetadata: {a: b}\n---\n",
|
|
"allowed-tools 空": "---\nname: a\ndescription: d\nallowed-tools:\n---\n",
|
|
"无结束围栏": "---\nname: a\n",
|
|
}
|
|
for name, text := range cases {
|
|
if _, _, err := parseFrontmatter("p/SKILL.md", text); err == nil {
|
|
t.Fatalf("%s:应拒绝", name)
|
|
} else if ve, ok := err.(ValidationError); !ok || ve.Msg == "" {
|
|
t.Fatalf("%s:错误必须带定位:%v", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStoreDiagnosticsSurface(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeSkill(t, root, "good", "x")
|
|
writeRawSkill(t, root, "bad-skill", "---\nname: bad-skill\n---\n缺 description")
|
|
store := NewStore(root)
|
|
list := store.List()
|
|
if len(list) != 1 {
|
|
t.Fatalf("good only: %d", len(list))
|
|
}
|
|
diags := store.Diagnostics()
|
|
if len(diags) != 1 || diags[0].Field != "description" {
|
|
t.Fatalf("diagnostics: %+v", diags)
|
|
}
|
|
// Load 同名返回诊断原文(含路径与字段)。
|
|
_, err := store.Load("bad-skill")
|
|
if err == nil || !strings.Contains(err.Error(), "description") {
|
|
t.Fatalf("load error: %v", err)
|
|
}
|
|
}
|