feat(skills): add SKILL.md metadata validator and CI gate
Signed-off-by: 林晨 (Leo Cheng) <chengkelfan@qq.com>
This commit is contained in:
parent
158c8e5aae
commit
64db1fa7c0
|
|
@ -20,6 +20,11 @@ jobs:
|
|||
- name: Build
|
||||
run: go build ./...
|
||||
|
||||
- name: Validate i18n and skill metadata
|
||||
run: |
|
||||
go run ./internal/i18n/cmd/check
|
||||
go run ./internal/skillmeta/cmd/check
|
||||
|
||||
- name: Lint
|
||||
run: make lint
|
||||
|
||||
|
|
|
|||
|
|
@ -26,5 +26,8 @@ jobs:
|
|||
- name: Scan i18n key references
|
||||
run: go run ./internal/i18n/cmd/check --scan-code
|
||||
|
||||
- name: Validate skill metadata
|
||||
run: go run ./internal/skillmeta/cmd/check
|
||||
|
||||
- name: Run Go tests
|
||||
run: go test ./...
|
||||
|
|
|
|||
7
Makefile
7
Makefile
|
|
@ -3,7 +3,7 @@ BINARY := gitlink-cli
|
|||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
LDFLAGS := -s -w -X '$(MODULE)/cmd.Version=$(VERSION)'
|
||||
|
||||
.PHONY: build install clean test check vet fmt cover lint
|
||||
.PHONY: build install clean test check vet fmt cover lint skills
|
||||
|
||||
build:
|
||||
go build -ldflags "$(LDFLAGS)" -o $(BINARY) .
|
||||
|
|
@ -35,7 +35,10 @@ cover:
|
|||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
check: fmt vet lint test
|
||||
skills:
|
||||
go run ./internal/skillmeta/cmd/check
|
||||
|
||||
check: fmt vet lint skills test
|
||||
@echo "All checks passed."
|
||||
|
||||
hooks:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
// Command check validates the SKILL.md frontmatter of the skills/ registry.
|
||||
// It mirrors the i18n gate: run from the repo root, it exits non-zero and
|
||||
// prints every problem, so CI and `make check` can keep the registry honest.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/skillmeta"
|
||||
)
|
||||
|
||||
func main() {
|
||||
problems, err := skillmeta.Validate("skills")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
for _, p := range problems {
|
||||
fmt.Fprintln(os.Stderr, p.String())
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("skill metadata is valid")
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
// Package skillmeta validates the YAML frontmatter of the skills/ registry so
|
||||
// that every SKILL.md is discoverable and structurally sound.
|
||||
package skillmeta
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Frontmatter is the metadata block every SKILL.md carries.
|
||||
type Frontmatter struct {
|
||||
Name string `yaml:"name"`
|
||||
Version string `yaml:"version"`
|
||||
Description string `yaml:"description"`
|
||||
Metadata Metadata `yaml:"metadata"`
|
||||
}
|
||||
|
||||
// Metadata holds the nested metadata fields of a skill.
|
||||
type Metadata struct {
|
||||
Requires Requires `yaml:"requires"`
|
||||
CLIHelp string `yaml:"cliHelp"`
|
||||
}
|
||||
|
||||
// Requires lists what a skill needs to run.
|
||||
type Requires struct {
|
||||
Bins []string `yaml:"bins"`
|
||||
}
|
||||
|
||||
var errNoFrontmatter = errors.New("no `---` delimited frontmatter block")
|
||||
|
||||
// ExtractFrontmatter returns the YAML between the first pair of `---` fences.
|
||||
func ExtractFrontmatter(src []byte) ([]byte, error) {
|
||||
const fence = "---"
|
||||
trimmed := bytes.TrimLeft(src, " \t\r\n")
|
||||
if !bytes.HasPrefix(trimmed, []byte(fence)) {
|
||||
return nil, errNoFrontmatter
|
||||
}
|
||||
rest := trimmed[len(fence):]
|
||||
idx := bytes.Index(rest, []byte("\n"+fence))
|
||||
if idx < 0 {
|
||||
return nil, errNoFrontmatter
|
||||
}
|
||||
return rest[:idx], nil
|
||||
}
|
||||
|
||||
// ParseFrontmatter strictly decodes the frontmatter, rejecting unknown or
|
||||
// wrongly-nested keys so that structural mistakes surface as errors instead of
|
||||
// being silently dropped.
|
||||
func ParseFrontmatter(block []byte) (Frontmatter, error) {
|
||||
var fm Frontmatter
|
||||
dec := yaml.NewDecoder(bytes.NewReader(block))
|
||||
dec.KnownFields(true)
|
||||
if err := dec.Decode(&fm); err != nil {
|
||||
return Frontmatter{}, fmt.Errorf("invalid frontmatter: %w", err)
|
||||
}
|
||||
return fm, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
---
|
||||
name: gitlink-flat
|
||||
version: 1.0.0
|
||||
description: "A test skill whose metadata children are flattened to the top instead of nested."
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli x --help"
|
||||
---
|
||||
|
||||
# body
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
---
|
||||
name: gitlink-wrongname
|
||||
version: 1.0.0
|
||||
description: "A test skill whose name field does not match its own directory name."
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli x --help"
|
||||
---
|
||||
|
||||
# body
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
---
|
||||
name: gitlink-nobins
|
||||
version: 1.0.0
|
||||
description: "A test skill whose requires.bins does not include the gitlink-cli binary."
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["other-tool"]
|
||||
cliHelp: "gitlink-cli x --help"
|
||||
---
|
||||
|
||||
# body
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
name: gitlink-nohelp
|
||||
version: 1.0.0
|
||||
description: "A test skill whose metadata is missing the cliHelp field for the command."
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
---
|
||||
|
||||
# body
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
name: gitlink-noversion
|
||||
description: "A test skill whose frontmatter is missing the version field entirely."
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli x --help"
|
||||
---
|
||||
|
||||
# body
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
package skillmeta
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Problem is a single validation failure against the shared skill schema.
|
||||
type Problem struct {
|
||||
Skill string
|
||||
Field string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (p Problem) String() string {
|
||||
return fmt.Sprintf("%s: %s: %s", p.Skill, p.Field, p.Message)
|
||||
}
|
||||
|
||||
var (
|
||||
semverRe = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
|
||||
skillDirRe = regexp.MustCompile(`^gitlink-`)
|
||||
)
|
||||
|
||||
const minDescriptionRunes = 20
|
||||
|
||||
// Validate checks every <root>/gitlink-*/SKILL.md against the shared schema and
|
||||
// returns all problems found; it reports the whole registry rather than
|
||||
// stopping at the first failure.
|
||||
func Validate(root string) ([]Problem, error) {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var problems []Problem
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() || !skillDirRe.MatchString(entry.Name()) {
|
||||
continue
|
||||
}
|
||||
problems = append(problems, validateSkill(root, entry.Name())...)
|
||||
}
|
||||
sort.Slice(problems, func(i, j int) bool {
|
||||
if problems[i].Skill != problems[j].Skill {
|
||||
return problems[i].Skill < problems[j].Skill
|
||||
}
|
||||
return problems[i].Field < problems[j].Field
|
||||
})
|
||||
return problems, nil
|
||||
}
|
||||
|
||||
func validateSkill(root, name string) []Problem {
|
||||
var ps []Problem
|
||||
add := func(field, msg string) {
|
||||
ps = append(ps, Problem{Skill: name, Field: field, Message: msg})
|
||||
}
|
||||
|
||||
src, err := os.ReadFile(filepath.Join(root, name, "SKILL.md"))
|
||||
if err != nil {
|
||||
add("SKILL.md", "cannot read: "+err.Error())
|
||||
return ps
|
||||
}
|
||||
block, err := ExtractFrontmatter(src)
|
||||
if err != nil {
|
||||
add("frontmatter", err.Error())
|
||||
return ps
|
||||
}
|
||||
fm, err := ParseFrontmatter(block)
|
||||
if err != nil {
|
||||
add("frontmatter", err.Error())
|
||||
return ps
|
||||
}
|
||||
|
||||
switch {
|
||||
case fm.Name == "":
|
||||
add("name", "must not be empty")
|
||||
case fm.Name != name:
|
||||
add("name", fmt.Sprintf("must equal the directory name %q", name))
|
||||
}
|
||||
if !semverRe.MatchString(fm.Version) {
|
||||
add("version", "must be semantic version X.Y.Z")
|
||||
}
|
||||
if utf8.RuneCountInString(fm.Description) < minDescriptionRunes {
|
||||
add("description", fmt.Sprintf("must be at least %d characters; it is the router's only routing signal", minDescriptionRunes))
|
||||
}
|
||||
if !containsString(fm.Metadata.Requires.Bins, "gitlink-cli") {
|
||||
add("metadata.requires.bins", `must contain "gitlink-cli"`)
|
||||
}
|
||||
if strings.TrimSpace(fm.Metadata.CLIHelp) == "" {
|
||||
add("metadata.cliHelp", "must name the command group, e.g. \"gitlink-cli x --help\"")
|
||||
}
|
||||
return ps
|
||||
}
|
||||
|
||||
func containsString(xs []string, target string) bool {
|
||||
for _, x := range xs {
|
||||
if x == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package skillmeta
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestRepoSkillsValid treats the real skills/ registry as a regression
|
||||
// baseline: once fixed, every SKILL.md must keep passing the schema.
|
||||
func TestRepoSkillsValid(t *testing.T) {
|
||||
problems, err := Validate("../../skills")
|
||||
if err != nil {
|
||||
t.Fatalf("validate skills: %v", err)
|
||||
}
|
||||
for _, p := range problems {
|
||||
t.Errorf("unexpected problem in registry: %s", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateCatchesBadSkills pins each rule to a deliberately broken sample.
|
||||
func TestValidateCatchesBadSkills(t *testing.T) {
|
||||
problems, err := Validate("testdata/bad")
|
||||
if err != nil {
|
||||
t.Fatalf("validate testdata: %v", err)
|
||||
}
|
||||
got := make(map[string]bool)
|
||||
for _, p := range problems {
|
||||
got[p.Skill+"/"+p.Field] = true
|
||||
}
|
||||
want := []string{
|
||||
"gitlink-noversion/version",
|
||||
"gitlink-nobins/metadata.requires.bins",
|
||||
"gitlink-nohelp/metadata.cliHelp",
|
||||
"gitlink-flat/frontmatter",
|
||||
"gitlink-mismatch/name",
|
||||
}
|
||||
for _, w := range want {
|
||||
if !got[w] {
|
||||
t.Errorf("expected problem %q, got %v", w, problems)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue