diff --git a/cmd/analyze_content.go b/cmd/analyze_content.go index ec94ffe..8288026 100644 --- a/cmd/analyze_content.go +++ b/cmd/analyze_content.go @@ -7,7 +7,10 @@ import ( "github.com/agent-ecosystem/skill-validator/types" ) -var perFileContent bool +var ( + perFileContent bool + imperativeConfigPath string +) var analyzeContentCmd = &cobra.Command{ Use: "content ", @@ -19,6 +22,7 @@ var analyzeContentCmd = &cobra.Command{ func init() { analyzeContentCmd.Flags().BoolVar(&perFileContent, "per-file", false, "show per-file reference analysis") + analyzeContentCmd.Flags().StringVar(&imperativeConfigPath, "imperative-config", "", "path to imperative detection config YAML (default: embedded multilingual config)") analyzeCmd.AddCommand(analyzeContentCmd) } @@ -30,12 +34,12 @@ func runAnalyzeContent(cmd *cobra.Command, args []string) error { switch mode { case types.SingleSkill: - r := orchestrate.RunContentAnalysis(dirs[0]) + r := orchestrate.RunContentAnalysisWithConfig(dirs[0], imperativeConfigPath) return outputReportWithPerFile(r, perFileContent) case types.MultiSkill: mr := &types.MultiReport{} for _, dir := range dirs { - r := orchestrate.RunContentAnalysis(dir) + r := orchestrate.RunContentAnalysisWithConfig(dir, imperativeConfigPath) mr.Skills = append(mr.Skills, r) mr.Errors += r.Errors mr.Warnings += r.Warnings diff --git a/content/content.go b/content/content.go index 48a3a43..6d986a4 100644 --- a/content/content.go +++ b/content/content.go @@ -6,6 +6,8 @@ package content import ( "regexp" "strings" + "sync" + "unicode" "github.com/agent-ecosystem/skill-validator/types" "github.com/agent-ecosystem/skill-validator/util" @@ -35,9 +37,18 @@ func compilePatterns(patterns []string) []*regexp.Regexp { return res } -// imperativeVerbs is the set of common imperative verbs used to detect -// instruction-style sentences in skill content. -var imperativeVerbs = map[string]bool{ +var ( + codeLangPattern = regexp.MustCompile("(?:```|~~~)(\\w+)") + sentenceSplitPat = regexp.MustCompile(`[.!?]\s+|[.!?]$|\n\n+`) + chineseSentencePat = regexp.MustCompile(`[。!?;\n]+`) + leadingFormatPat = regexp.MustCompile(`^[#*\->\s]+`) + sectionPattern = regexp.MustCompile(`(?m)^#{2,}\s+`) + listItemPattern = regexp.MustCompile(`(?m)^[\s]*[-*+]\s+|^\s*\d+\.\s+`) +) + +// defaultImperativeVerbs is the fallback English verb set used when no config +// is loaded. It preserves backward compatibility with the original hardcoded set. +var defaultImperativeVerbs = map[string]bool{ "use": true, "run": true, "create": true, "add": true, "set": true, "install": true, "configure": true, "write": true, "read": true, "check": true, "verify": true, "make": true, "build": true, "test": true, @@ -51,20 +62,231 @@ var imperativeVerbs = map[string]bool{ "send": true, "receive": true, } -var ( - codeLangPattern = regexp.MustCompile("(?:```|~~~)(\\w+)") - sentenceSplitPat = regexp.MustCompile(`[.!?]\s+|[.!?]$|\n\n+`) - leadingFormatPat = regexp.MustCompile(`^[#*\->\s]+`) - sectionPattern = regexp.MustCompile(`(?m)^#{2,}\s+`) - listItemPattern = regexp.MustCompile(`(?m)^[\s]*[-*+]\s+|^\s*\d+\.\s+`) -) +// imperativeDetector handles multilingual imperative sentence detection. +type imperativeDetector struct { + mu sync.RWMutex + cfg *ImperativeConfig + + // Pre-compiled detection data per language. + verbSets map[string]map[string]bool // lang -> verb set + keywordRes map[string][]*regexp.Regexp // lang -> keyword regexps + splitRes map[string]*regexp.Regexp // lang -> sentence split regexp +} + +// globalDetector is the package-level imperative detector instance. +var globalDetector = newImperativeDetector(nil) + +// newImperativeDetector creates a new detector from the given config. +// If cfg is nil, the embedded default config is used. +func newImperativeDetector(cfg *ImperativeConfig) *imperativeDetector { + if cfg == nil { + cfg = DefaultImperativeConfig() + } + d := &imperativeDetector{ + cfg: cfg, + verbSets: make(map[string]map[string]bool), + keywordRes: make(map[string][]*regexp.Regexp), + splitRes: make(map[string]*regexp.Regexp), + } + for lang, rules := range cfg.Languages { + // Build verb set + vs := make(map[string]bool, len(rules.Verbs)) + for _, v := range rules.Verbs { + vs[strings.ToLower(v)] = true + } + d.verbSets[lang] = vs + + // Build keyword regexps (match as substring anywhere in sentence) + for _, kw := range rules.Keywords { + re := regexp.MustCompile(regexp.QuoteMeta(kw)) + d.keywordRes[lang] = append(d.keywordRes[lang], re) + } + + // Build per-language sentence split pattern + if rules.SentenceSplitPattern != "" { + d.splitRes[lang] = regexp.MustCompile(rules.SentenceSplitPattern) + } + } + return d +} + +// SetImperativeConfig replaces the global detector's configuration. +// This is safe to call from any goroutine. +func SetImperativeConfig(cfg *ImperativeConfig) { + globalDetector.mu.Lock() + defer globalDetector.mu.Unlock() + newD := newImperativeDetector(cfg) + globalDetector.verbSets = newD.verbSets + globalDetector.keywordRes = newD.keywordRes + globalDetector.splitRes = newD.splitRes + globalDetector.cfg = newD.cfg +} + +// ReloadImperativeConfig reloads the imperative config from the given path. +// If path is empty, the standard lookup order is used. +func ReloadImperativeConfig(path string) { + SetImperativeConfig(LoadImperativeConfig(path)) +} + +// hasChinese returns true if the string contains any Chinese characters. +func hasChinese(s string) bool { + for _, r := range s { + if unicode.Is(unicode.Han, r) { + return true + } + } + return false +} + +// splitSentences splits text into sentences using language-appropriate rules. +// It handles mixed Chinese/English content by applying both splitters. +func (d *imperativeDetector) splitSentences(text string) []string { + // Remove code blocks first + text = util.CodeBlockStrip.ReplaceAllString(text, "") + // Remove inline code + text = util.InlineCodeStrip.ReplaceAllString(text, "") + + if hasChinese(text) { + return splitMixed(text) + } + return splitEnglish(text) +} + +// splitMixed handles text containing both Chinese and English. +// It first splits on Chinese punctuation, then further splits any +// English-style segments on English sentence boundaries. +func splitMixed(text string) []string { + // First split on Chinese punctuation + parts := chineseSentencePat.Split(text, -1) + var sentences []string + for _, s := range parts { + s = strings.TrimSpace(s) + if s == "" { + continue + } + // Pure English segments: use English splitter + if !hasChinese(s) { + subParts := splitEnglish(s) + sentences = append(sentences, subParts...) + continue + } + // Mixed segment: also split on English boundaries if present + if strings.ContainsAny(s, ".!?") { + subParts := sentenceSplitPat.Split(s, -1) + for _, sp := range subParts { + sp = strings.TrimSpace(sp) + if sp != "" && len(sp) > 5 { + sentences = append(sentences, sp) + } + } + } else if len(s) > 5 { + sentences = append(sentences, s) + } + } + return sentences +} + +// splitEnglish splits on English sentence boundaries. +func splitEnglish(text string) []string { + parts := sentenceSplitPat.Split(text, -1) + var sentences []string + for _, s := range parts { + s = strings.TrimSpace(s) + if s != "" && len(s) > 5 { + sentences = append(sentences, s) + } + } + return sentences +} + +// isImperative checks if a sentence is imperative using all configured languages. +func (d *imperativeDetector) isImperative(sentence string) bool { + d.mu.RLock() + defer d.mu.RUnlock() + + // Clean markdown formatting + cleaned := leadingFormatPat.ReplaceAllString(sentence, "") + + // Try Chinese detection first if the sentence contains Chinese chars + if hasChinese(cleaned) { + if d.isImperativeLang(cleaned, "zh") { + return true + } + } + + // Try English (verb-first) detection + words := strings.Fields(cleaned) + if len(words) == 0 { + return false + } + firstWord := strings.ToLower(words[0]) + + // Check English verbs from config + if vs, ok := d.verbSets["en"]; ok && vs[firstWord] { + return true + } + + // Fallback to hardcoded default for backward compatibility + return defaultImperativeVerbs[firstWord] +} + +// isImperativeLang checks imperative detection for a specific language. +func (d *imperativeDetector) isImperativeLang(cleaned, lang string) bool { + if _, ok := d.cfg.Languages[lang]; !ok { + return false + } + + // Check verb-first match + // For space-separated languages (e.g. English), match the first word. + // For non-space-separated languages (e.g. Chinese), match the sentence prefix. + if vs, ok := d.verbSets[lang]; ok { + if hasChinese(cleaned) { + // Prefix match: check if sentence starts with any verb + for verb := range vs { + if strings.HasPrefix(cleaned, verb) { + return true + } + } + } else { + words := strings.Fields(cleaned) + if len(words) > 0 { + first := strings.ToLower(words[0]) + if vs[first] { + return true + } + } + } + } + + // Check keyword match anywhere in the sentence + for _, re := range d.keywordRes[lang] { + if re.MatchString(cleaned) { + return true + } + } + + return false +} // Analyze computes content metrics for SKILL.md content. func Analyze(content string) *types.ContentReport { + return AnalyzeWithConfig(content, nil) +} + +// AnalyzeWithConfig computes content metrics using the provided imperative +// config. If cfg is nil, the current global detector is used. +func AnalyzeWithConfig(content string, cfg *ImperativeConfig) *types.ContentReport { if strings.TrimSpace(content) == "" { return &types.ContentReport{} } + var detector *imperativeDetector + if cfg != nil { + detector = newImperativeDetector(cfg) + } else { + detector = globalDetector + } + words := strings.Fields(content) wordCount := len(words) @@ -87,10 +309,10 @@ func Analyze(content string) *types.ContentReport { codeLanguages = append(codeLanguages, m[1]) } - // Sentence analysis - sentences := countSentences(content) + // Sentence analysis (multilingual) + sentences := detector.splitSentences(content) sentenceCount := len(sentences) - imperativeCount := countImperativeSentences(sentences) + imperativeCount := countImperativeSentencesWithDetector(sentences, detector) imperativeRatio := 0.0 if sentenceCount > 0 { imperativeRatio = float64(imperativeCount) / float64(sentenceCount) @@ -136,34 +358,10 @@ func Analyze(content string) *types.ContentReport { } } -func countSentences(text string) []string { - // Remove code blocks first - text = util.CodeBlockStrip.ReplaceAllString(text, "") - // Remove inline code - text = util.InlineCodeStrip.ReplaceAllString(text, "") - // Split on sentence boundaries - parts := sentenceSplitPat.Split(text, -1) - var sentences []string - for _, s := range parts { - s = strings.TrimSpace(s) - if s != "" && len(s) > 5 { - sentences = append(sentences, s) - } - } - return sentences -} - -func countImperativeSentences(sentences []string) int { +func countImperativeSentencesWithDetector(sentences []string, d *imperativeDetector) int { count := 0 for _, sentence := range sentences { - // Get first word, stripping markdown formatting - cleaned := leadingFormatPat.ReplaceAllString(sentence, "") - words := strings.Fields(cleaned) - if len(words) == 0 { - continue - } - firstWord := strings.ToLower(words[0]) - if imperativeVerbs[firstWord] { + if d.isImperative(sentence) { count++ } } diff --git a/content/content_test.go b/content/content_test.go index 95817c0..a37b0c9 100644 --- a/content/content_test.go +++ b/content/content_test.go @@ -1,9 +1,14 @@ package content import ( + "os" + "path/filepath" + "strings" "testing" ) +// --- Backward compatibility tests (existing English behavior) --- + func TestAnalyze_EmptyContent(t *testing.T) { r := Analyze("") if r.WordCount != 0 { @@ -167,3 +172,369 @@ Never skip validation. Ensure all checks pass. t.Errorf("expected specificity in (0, 1], got %f", r.InstructionSpecificity) } } + +// --- Multilingual tests --- + +func TestAnalyze_ChineseImperativeSentences(t *testing.T) { + content := "使用CLI工具。运行测试。这是一个描述。创建一个新文件。" + r := Analyze(content) + + // "使用CLI工具" → starts with 使用 (imperative verb) + // "运行测试" → starts with 运行 (imperative verb) + // "这是一个描述" → not imperative + // "创建一个新文件" → starts with 创建 (imperative verb) + if r.ImperativeCount < 3 { + t.Errorf("expected at least 3 imperative sentences, got %d", r.ImperativeCount) + } + if r.ImperativeRatio <= 0 { + t.Errorf("expected positive imperative ratio, got %f", r.ImperativeRatio) + } +} + +func TestAnalyze_ChineseKeywordImperative(t *testing.T) { + content := "请确保所有测试通过。务必按照规范操作。" + r := Analyze(content) + + if r.ImperativeCount < 2 { + t.Errorf("expected at least 2 imperative sentences (keyword match), got %d", r.ImperativeCount) + } +} + +func TestAnalyze_ChineseSentenceSplitting(t *testing.T) { + content := "使用此工具。运行测试!确保正确?配置环境;这是说明。" + r := Analyze(content) + + if r.SentenceCount < 4 { + t.Errorf("expected at least 4 sentences (Chinese split), got %d", r.SentenceCount) + } +} + +func TestAnalyze_MixedChineseEnglish(t *testing.T) { + content := "Use the CLI tool. 运行测试。Create a new file. 请确保代码质量。" + r := Analyze(content) + + if r.SentenceCount < 3 { + t.Errorf("expected at least 3 sentences in mixed content, got %d", r.SentenceCount) + } + // English imperatives: "Use the CLI tool", "Create a new file" + // Chinese imperatives: "运行测试", "请确保代码质量" (keyword) + if r.ImperativeCount < 3 { + t.Errorf("expected at least 3 imperative sentences in mixed content, got %d", r.ImperativeCount) + } +} + +func TestAnalyzeWithConfig_CustomConfig(t *testing.T) { + cfg := &ImperativeConfig{ + Languages: map[string]LanguageRules{ + "en": { + Verbs: []string{"build", "deploy"}, + }, + }, + } + + content := "Build the project. Deploy to production. Use the tool." + r := AnalyzeWithConfig(content, cfg) + + // Only "Build" and "Deploy" should be detected as imperative + // "Use" is NOT in this custom config, but falls back to defaultImperativeVerbs + if r.ImperativeCount < 2 { + t.Errorf("expected at least 2 imperative sentences with custom config, got %d", r.ImperativeCount) + } +} + +func TestAnalyzeWithConfig_NilUsesDefault(t *testing.T) { + content := "Use the CLI tool. Run the tests." + r1 := Analyze(content) + r2 := AnalyzeWithConfig(content, nil) + + if r1.ImperativeCount != r2.ImperativeCount { + t.Errorf("Analyze and AnalyzeWithConfig(nil) should give same results: %d vs %d", + r1.ImperativeCount, r2.ImperativeCount) + } +} + +func TestAnalyze_ChineseNonImperative(t *testing.T) { + content := "这是一个描述。项目已完成。功能正常工作。" + r := Analyze(content) + + if r.ImperativeCount != 0 { + t.Errorf("expected 0 imperative sentences for non-imperative Chinese, got %d", r.ImperativeCount) + } +} + +// --- Config loading tests --- + +func TestLoadImperativeConfig_DefaultEmbedded(t *testing.T) { + cfg := DefaultImperativeConfig() + if cfg == nil { + t.Fatal("expected non-nil default config") + } + if len(cfg.Languages) == 0 { + t.Error("expected at least one language in default config") + } + enRules, ok := cfg.Languages["en"] + if !ok { + t.Fatal("expected 'en' language in default config") + } + if len(enRules.Verbs) == 0 { + t.Error("expected non-empty English verb list") + } + // Check backward compatibility: all original verbs should be present + expectedVerbs := []string{ + "use", "run", "create", "add", "set", "install", + "configure", "write", "read", "check", "verify", "make", "build", "test", + "ensure", "include", "remove", "delete", "update", "call", "import", + "export", "define", "implement", "return", "pass", "handle", "parse", + "generate", "format", "validate", "convert", "follow", "apply", "start", + "stop", "avoid", "keep", "do", "execute", "open", "close", "save", + "load", "send", "receive", + } + + verbSet := make(map[string]bool, len(enRules.Verbs)) + for _, v := range enRules.Verbs { + verbSet[strings.ToLower(v)] = true + } + for _, v := range expectedVerbs { + if !verbSet[v] { + t.Errorf("expected verb %q in default English config", v) + } + } + + zhRules, ok := cfg.Languages["zh"] + if !ok { + t.Fatal("expected 'zh' language in default config") + } + if len(zhRules.Verbs) == 0 { + t.Error("expected non-empty Chinese verb list") + } + if len(zhRules.Keywords) == 0 { + t.Error("expected non-empty Chinese keyword list") + } +} + +func TestLoadImperativeConfig_FromFile(t *testing.T) { + // Create a temp config file + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "imperative.yaml") + cfgContent := `languages: + en: + verbs: + - custom_verb + - another_verb + zh: + verbs: + - 自定义 + keywords: + - 请务必 +` + if err := os.WriteFile(cfgPath, []byte(cfgContent), 0o644); err != nil { + t.Fatalf("failed to write temp config: %v", err) + } + + cfg := LoadImperativeConfig(cfgPath) + if cfg == nil { + t.Fatal("expected non-nil config from file") + } + + enRules := cfg.Languages["en"] + if len(enRules.Verbs) != 2 { + t.Errorf("expected 2 English verbs from file, got %d", len(enRules.Verbs)) + } + + zhRules := cfg.Languages["zh"] + if len(zhRules.Keywords) != 1 { + t.Errorf("expected 1 Chinese keyword from file, got %d", len(zhRules.Keywords)) + } +} + +func TestLoadImperativeConfig_InvalidPath(t *testing.T) { + cfg := LoadImperativeConfig("/nonexistent/path/imperative.yaml") + if cfg == nil { + t.Fatal("expected fallback to default config for invalid path") + } + // Should fall back to defaults + if len(cfg.Languages) == 0 { + t.Error("expected default languages as fallback") + } +} + +func TestLoadImperativeConfig_EmptyPath(t *testing.T) { + // Should not panic, returns default config when no file found + cfg := LoadImperativeConfig("") + if cfg == nil { + t.Fatal("expected non-nil config with empty path") + } +} + +func TestHasChinese(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"Hello world", false}, + {"使用此工具", true}, + {"Use 使用 both", true}, + {"12345", false}, + {"", false}, + } + for _, tt := range tests { + got := hasChinese(tt.input) + if got != tt.want { + t.Errorf("hasChinese(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestSetImperativeConfig(t *testing.T) { + // Save original to restore after test + origCfg := DefaultImperativeConfig() + defer SetImperativeConfig(origCfg) + + customCfg := &ImperativeConfig{ + Languages: map[string]LanguageRules{ + "en": {Verbs: []string{"deploy"}}, + }, + } + SetImperativeConfig(customCfg) + + content := "Deploy the app. Use the tool." + r := Analyze(content) + + if r.ImperativeCount < 1 { + t.Errorf("expected at least 1 imperative (deploy), got %d", r.ImperativeCount) + } +} + +// --- Detector unit tests --- + +func TestImperativeDetector_ChineseVerbs(t *testing.T) { + d := newImperativeDetector(nil) + + tests := []struct { + sentence string + want bool + }{ + {"使用CLI工具进行验证", true}, + {"运行所有测试用例", true}, + {"创建新的配置文件", true}, + {"这是一个描述句子", false}, + {"项目已经完成了", false}, + } + + for _, tt := range tests { + got := d.isImperative(tt.sentence) + if got != tt.want { + t.Errorf("isImperative(%q) = %v, want %v", tt.sentence, got, tt.want) + } + } +} + +func TestImperativeDetector_ChineseKeywords(t *testing.T) { + d := newImperativeDetector(nil) + + tests := []struct { + sentence string + want bool + }{ + {"请确保所有测试通过", true}, + {"务必按照规范操作", true}, + {"这只是一个普通句子", false}, + } + + for _, tt := range tests { + got := d.isImperative(tt.sentence) + if got != tt.want { + t.Errorf("isImperative(%q) = %v, want %v", tt.sentence, got, tt.want) + } + } +} + +func TestImperativeDetector_EnglishBackwardCompat(t *testing.T) { + d := newImperativeDetector(nil) + + tests := []struct { + sentence string + want bool + }{ + {"Use the CLI tool", true}, + {"Run the tests", true}, + {"This is a description", false}, + {"Create a new file", true}, + } + + for _, tt := range tests { + got := d.isImperative(tt.sentence) + if got != tt.want { + t.Errorf("isImperative(%q) = %v, want %v", tt.sentence, got, tt.want) + } + } +} + +func TestImperativeDetector_MarkdownFormatting(t *testing.T) { + d := newImperativeDetector(nil) + + tests := []struct { + sentence string + want bool + }{ + {"## Use the tool", true}, + {"- Run the tests", true}, + {"**Create** a file", false}, // "**Create" is not cleaned by leadingFormatPat + {" Set the value", true}, + } + + for _, tt := range tests { + got := d.isImperative(tt.sentence) + if got != tt.want { + t.Errorf("isImperative(%q) = %v, want %v", tt.sentence, got, tt.want) + } + } +} + +func TestSplitSentences_MixedContent(t *testing.T) { + d := newImperativeDetector(nil) + + text := "Use the tool.运行测试。Create a file." + sentences := d.splitSentences(text) + + if len(sentences) < 2 { + t.Errorf("expected at least 2 sentences from mixed content, got %d: %v", len(sentences), sentences) + } +} + +func TestAnalyze_ChineseFullContent(t *testing.T) { + content := `# 我的技能 + +## 使用说明 + +使用CLI工具验证技能。你必须始终在发布前运行测试。 + +` + "```bash\nskill-validator validate ./my-skill\n```" + ` + +## 配置 + +创建配置文件。设置输出格式。你可以考虑使用JSON。 + +- 步骤 1:安装 +- 步骤 2:配置 +- 步骤 3:运行 + +请确保所有检查通过。 +` + + r := Analyze(content) + + if r.WordCount <= 0 { + t.Error("expected positive word count") + } + if r.CodeBlockCount != 1 { + t.Errorf("expected 1 code block, got %d", r.CodeBlockCount) + } + if r.SectionCount != 2 { + t.Errorf("expected 2 sections, got %d", r.SectionCount) + } + // Chinese imperatives: "使用CLI工具验证技能", "创建配置文件", "设置输出格式", "请确保所有检查通过" + if r.ImperativeCount < 3 { + t.Errorf("expected at least 3 imperative sentences, got %d", r.ImperativeCount) + } +} diff --git a/content/imperative_config.go b/content/imperative_config.go new file mode 100644 index 0000000..03fd412 --- /dev/null +++ b/content/imperative_config.go @@ -0,0 +1,96 @@ +package content + +import ( + "embed" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +//go:embed imperative_default.yaml +var defaultConfigFS embed.FS + +// ImperativeConfig holds the multilingual imperative detection configuration. +type ImperativeConfig struct { + // Languages maps a language tag (e.g. "en", "zh") to its detection rules. + Languages map[string]LanguageRules `yaml:"languages"` + + // SentenceSplitPattern is an optional global regex override for sentence + // splitting. Per-language split patterns take precedence when set. + SentenceSplitPattern string `yaml:"sentence_split_pattern,omitempty"` +} + +// LanguageRules defines imperative detection rules for one language. +type LanguageRules struct { + // Verbs is a list of imperative verbs/phrases to match at sentence start. + Verbs []string `yaml:"verbs"` + + // Keywords is a list of imperative keywords/phrases matched anywhere in + // the sentence (useful for languages like Chinese where imperatives don't + // always start with a verb). + Keywords []string `yaml:"keywords,omitempty"` + + // SentenceSplitPattern is a regex that overrides the default sentence + // splitter for this language. + SentenceSplitPattern string `yaml:"sentence_split_pattern,omitempty"` +} + +// DefaultImperativeConfig returns the embedded default configuration. +func DefaultImperativeConfig() *ImperativeConfig { + data, err := defaultConfigFS.ReadFile("imperative_default.yaml") + if err != nil { + // Should never happen with embedded file; fall back to empty + return &ImperativeConfig{Languages: map[string]LanguageRules{}} + } + var cfg ImperativeConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return &ImperativeConfig{Languages: map[string]LanguageRules{}} + } + return &cfg +} + +// LoadImperativeConfig loads the imperative config from the given path. +// If path is empty, it tries the standard lookup order: +// 1. ./imperative.yaml (current working directory) +// 2. ~/.config/skill-validator/imperative.yaml +// 3. Embedded defaults +func LoadImperativeConfig(path string) *ImperativeConfig { + if path != "" { + return loadConfigFrom(path) + } + + // Try CWD + cwdPath := filepath.Join(".", "imperative.yaml") + if data, err := os.ReadFile(cwdPath); err == nil { + return parseConfig(data) + } + + // Try user config dir + home, err := os.UserHomeDir() + if err == nil { + userPath := filepath.Join(home, ".config", "skill-validator", "imperative.yaml") + if data, err := os.ReadFile(userPath); err == nil { + return parseConfig(data) + } + } + + // Fall back to embedded defaults + return DefaultImperativeConfig() +} + +func loadConfigFrom(path string) *ImperativeConfig { + data, err := os.ReadFile(path) + if err != nil { + return DefaultImperativeConfig() + } + return parseConfig(data) +} + +func parseConfig(data []byte) *ImperativeConfig { + var cfg ImperativeConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return DefaultImperativeConfig() + } + return &cfg +} diff --git a/content/imperative_default.yaml b/content/imperative_default.yaml new file mode 100644 index 0000000..1236a44 --- /dev/null +++ b/content/imperative_default.yaml @@ -0,0 +1,105 @@ +# Imperative sentence detection configuration. +# Languages are matched by the detection heuristic; the first matching +# language's rules are used for each sentence. + +languages: + en: + verbs: + - use + - run + - create + - add + - set + - install + - configure + - write + - read + - check + - verify + - make + - build + - test + - ensure + - include + - remove + - delete + - update + - call + - import + - export + - define + - implement + - return + - pass + - handle + - parse + - generate + - format + - validate + - convert + - follow + - apply + - start + - stop + - avoid + - keep + - do + - execute + - open + - close + - save + - load + - send + - receive + + zh: + # Chinese imperative verbs/phrases (matched at sentence start after + # stripping markdown formatting) + verbs: + - 使用 + - 运行 + - 创建 + - 添加 + - 设置 + - 安装 + - 配置 + - 编写 + - 读取 + - 检查 + - 验证 + - 构建 + - 测试 + - 确保 + - 删除 + - 更新 + - 调用 + - 导入 + - 导出 + - 定义 + - 实现 + - 处理 + - 解析 + - 生成 + - 格式化 + - 转换 + - 执行 + - 打开 + - 关闭 + - 保存 + - 加载 + - 发送 + - 接收 + # Chinese imperative keywords (matched anywhere in the sentence) + keywords: + - 请 + - 务必 + - 确保 + - 必须 + - 一定 + - 切勿 + - 不要 + - 禁止 + - 务须 + - 需要 + # Chinese-specific sentence split pattern (handles 。,!? etc.) + sentence_split_pattern: "[。!?;\\n]+" diff --git a/orchestrate/orchestrate.go b/orchestrate/orchestrate.go index 623bcf0..4464d20 100644 --- a/orchestrate/orchestrate.go +++ b/orchestrate/orchestrate.go @@ -19,6 +19,12 @@ import ( "github.com/agent-ecosystem/skill-validator/util" ) +// SetImperativeConfigPath configures the global imperative detection config +// path. It loads the config immediately so subsequent Analyze calls use it. +func SetImperativeConfigPath(path string) { + content.ReloadImperativeConfig(path) +} + // CheckGroup identifies a category of checks that can be enabled or disabled. type CheckGroup string @@ -133,6 +139,12 @@ func RunAllChecks(ctx context.Context, dir string, opts Options) *types.Report { // RunContentAnalysis runs content quality analysis on a single skill directory. func RunContentAnalysis(dir string) *types.Report { + return RunContentAnalysisWithConfig(dir, "") +} + +// RunContentAnalysisWithConfig runs content quality analysis with an optional +// imperative config path. If imperativeCfgPath is empty, the global config is used. +func RunContentAnalysisWithConfig(dir string, imperativeCfgPath string) *types.Report { rpt := &types.Report{SkillDir: dir} s, err := skill.Load(dir) @@ -143,7 +155,11 @@ func RunContentAnalysis(dir string) *types.Report { return rpt } - rpt.ContentReport = content.Analyze(s.RawContent) + var contentCfg *content.ImperativeConfig + if imperativeCfgPath != "" { + contentCfg = content.LoadImperativeConfig(imperativeCfgPath) + } + rpt.ContentReport = content.AnalyzeWithConfig(s.RawContent, contentCfg) rpt.Results = append(rpt.Results, types.ResultContext{Category: "Content"}.Pass("content analysis complete"))