feat(skills): add SKILL.md metadata validator and CI gate

Signed-off-by: 林晨 (Leo Cheng) <chengkelfan@qq.com>
This commit is contained in:
林晨 (Leo Cheng) 2026-07-08 02:49:34 +08:00
parent 158c8e5aae
commit 64db1fa7c0
No known key found for this signature in database
GPG Key ID: 24FCF87A069356B9
12 changed files with 296 additions and 2 deletions

View File

@ -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

View File

@ -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 ./...

View File

@ -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:

View File

@ -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")
}

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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)
}
}
}