Files
larksuite-cli/internal/qualitygate/rules/skillquality.go
2026-06-17 16:29:33 +08:00

150 lines
3.9 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package rules
import (
"errors"
"fmt"
"io/fs"
"path/filepath"
"sort"
"strings"
"github.com/larksuite/cli/internal/qualitygate/facts"
"github.com/larksuite/cli/internal/qualitygate/report"
"github.com/larksuite/cli/internal/vfs"
)
type SkillDoc struct {
File string
Name string
Description string
Body string
}
func LoadSkillDocs(skillsDir string) ([]SkillDoc, error) {
var out []SkillDoc
if err := walkSkillDocs(skillsDir, func(path string) error {
data, err := vfs.ReadFile(path)
if err != nil {
return err
}
out = append(out, parseSkillDoc(path, string(data)))
return nil
}); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return nil, err
}
sort.Slice(out, func(i, j int) bool {
return out[i].File < out[j].File
})
return out, nil
}
func CheckSkillQuality(docs []SkillDoc) ([]report.Diagnostic, []facts.SkillQualityFact) {
var diags []report.Diagnostic
var out []facts.SkillQualityFact
for _, doc := range docs {
criticalCount := strings.Count(doc.Body, "CRITICAL")
wordCount := len(strings.Fields(doc.Body))
fact := facts.SkillQualityFact{
SourceFile: doc.File,
WordCount: wordCount,
CriticalCount: criticalCount,
DescriptionLength: len([]rune(doc.Description)),
CriticalOverBudget: criticalCount > 3,
}
if fact.CriticalOverBudget {
diags = append(diags, report.Diagnostic{
Rule: "skill_critical_noise",
Action: report.ActionWarning,
File: doc.File,
Message: fmt.Sprintf("skill has %d CRITICAL markers; keep hard instructions focused", criticalCount),
Suggestion: "reduce CRITICAL markers to at most 3 and move procedural detail into references",
})
}
if fact.DescriptionLength < 20 || fact.DescriptionLength > 500 {
diags = append(diags, report.Diagnostic{
Rule: "skill_description_route_quality",
Action: report.ActionWarning,
File: doc.File,
Message: fmt.Sprintf("description length is %d runes; routing description may be too vague or too noisy", fact.DescriptionLength),
Suggestion: "write a concise WHAT / WHEN / NOT description for skill routing",
})
}
if wordCount > 2500 {
diags = append(diags, report.Diagnostic{
Rule: "skill_size_budget",
Action: report.ActionWarning,
File: doc.File,
Message: fmt.Sprintf("skill body has %d words", wordCount),
Suggestion: "move long procedural sections into references and keep SKILL.md focused on routing",
})
}
out = append(out, fact)
}
return diags, out
}
func walkSkillDocs(root string, visit func(string) error) error {
entries, err := vfs.ReadDir(root)
if err != nil {
return err
}
for _, entry := range entries {
path := filepath.Join(root, entry.Name())
if entry.IsDir() {
if err := walkSkillDocs(path, visit); err != nil {
return err
}
continue
}
if entry.Type()&fs.ModeType != 0 || entry.Name() != "SKILL.md" {
continue
}
if err := visit(path); err != nil {
return err
}
}
return nil
}
func parseSkillDoc(path, raw string) SkillDoc {
doc := SkillDoc{File: path, Body: raw}
lines := strings.Split(raw, "\n")
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
return doc
}
end := -1
for i := 1; i < len(lines); i++ {
if strings.TrimSpace(lines[i]) == "---" {
end = i
break
}
key, value, ok := strings.Cut(lines[i], ":")
if !ok {
continue
}
switch strings.TrimSpace(key) {
case "name":
doc.Name = trimFrontmatterValue(value)
case "description":
doc.Description = trimFrontmatterValue(value)
}
}
if end >= 0 && end+1 < len(lines) {
doc.Body = strings.Join(lines[end+1:], "\n")
}
return doc
}
func trimFrontmatterValue(value string) string {
value = strings.TrimSpace(value)
value = strings.Trim(value, `"'`)
return value
}