Skip to content

Commit 6a65090

Browse files
committed
fix: validate rule frontmatter
1 parent 73821e7 commit 6a65090

5 files changed

Lines changed: 584 additions & 7 deletions

File tree

go.mod

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,35 @@ module rules-cli
22

33
go 1.24.2
44

5+
require (
6+
github.com/fatih/color v1.18.0
7+
github.com/google/uuid v1.6.0
8+
github.com/manifoldco/promptui v0.9.0
9+
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
10+
github.com/spf13/cobra v1.9.1
11+
github.com/spf13/viper v1.20.1
12+
github.com/xeipuuv/gojsonschema v1.2.0
13+
gopkg.in/yaml.v3 v3.0.1
14+
)
15+
516
require (
617
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
7-
github.com/fatih/color v1.18.0 // indirect
818
github.com/fsnotify/fsnotify v1.8.0 // indirect
919
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
10-
github.com/google/uuid v1.6.0 // indirect
1120
github.com/inconshreveable/mousetrap v1.1.0 // indirect
12-
github.com/manifoldco/promptui v0.9.0 // indirect
1321
github.com/mattn/go-colorable v0.1.13 // indirect
1422
github.com/mattn/go-isatty v0.0.20 // indirect
1523
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
16-
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
1724
github.com/sagikazarmark/locafero v0.7.0 // indirect
1825
github.com/sourcegraph/conc v0.3.0 // indirect
1926
github.com/spf13/afero v1.12.0 // indirect
2027
github.com/spf13/cast v1.7.1 // indirect
21-
github.com/spf13/cobra v1.9.1 // indirect
2228
github.com/spf13/pflag v1.0.6 // indirect
23-
github.com/spf13/viper v1.20.1 // indirect
2429
github.com/subosito/gotenv v1.6.0 // indirect
30+
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
31+
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
2532
go.uber.org/atomic v1.9.0 // indirect
2633
go.uber.org/multierr v1.9.0 // indirect
2734
golang.org/x/sys v0.29.0 // indirect
2835
golang.org/x/text v0.21.0 // indirect
29-
gopkg.in/yaml.v3 v3.0.1 // indirect
3036
)

go.sum

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,31 @@
1+
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
12
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
23
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
34
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
5+
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
46
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
57
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
68
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
9+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
710
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
811
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
912
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
13+
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
14+
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
1015
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
1116
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
1217
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
1318
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
19+
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
20+
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
1421
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
1522
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
1623
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
1724
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
25+
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
26+
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
27+
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
28+
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
1829
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
1930
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
2031
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@@ -26,7 +37,10 @@ github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNH
2637
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
2738
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
2839
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
40+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
2941
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
42+
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
43+
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
3044
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
3145
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
3246
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
@@ -44,8 +58,16 @@ github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
4458
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
4559
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
4660
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
61+
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
62+
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
4763
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
4864
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
65+
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
66+
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
67+
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
68+
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
69+
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
70+
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
4971
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
5072
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
5173
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
@@ -59,5 +81,7 @@ golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
5981
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
6082
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
6183
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
84+
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
85+
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
6286
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
6387
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"title": "Rule Frontmatter Schema",
4+
"description": "Schema for validating the frontmatter of markdown rules files",
5+
"type": "object",
6+
"properties": {
7+
"alwaysApply": {
8+
"type": "boolean",
9+
"description": "Whether to always apply the rule automatically",
10+
"default": false
11+
},
12+
"description": {
13+
"type": "string",
14+
"description": "Short description of what the rule does",
15+
"minLength": 1,
16+
"maxLength": 500
17+
},
18+
"globs": {
19+
"type": "string",
20+
"description": "Glob patterns to match files that this rule applies to",
21+
"minLength": 1,
22+
"examples": ["*.{jsx,tsx}", "**/*.js", "src/**/*.ts"]
23+
},
24+
"tags": {
25+
"type": "array",
26+
"description": "Tags for categorizing and organizing rules",
27+
"items": {
28+
"type": "string",
29+
"minLength": 1,
30+
"maxLength": 50
31+
},
32+
"uniqueItems": true,
33+
"maxItems": 20
34+
}
35+
},
36+
"additionalProperties": false
37+
}

internal/validation/schema.go

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
package validation
2+
3+
import (
4+
_ "embed"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
"strings"
9+
10+
"github.com/xeipuuv/gojsonschema"
11+
"gopkg.in/yaml.v3"
12+
)
13+
14+
// RuleFrontmatterSchema contains the embedded JSON schema for rule frontmatter
15+
//
16+
//go:embed rule_frontmatter_schema.json
17+
var RuleFrontmatterSchema string
18+
19+
// RuleFrontmatter represents the structure of rule frontmatter
20+
type RuleFrontmatter struct {
21+
AlwaysApply bool `yaml:"alwaysApply,omitempty" json:"alwaysApply,omitempty"`
22+
Description string `yaml:"description,omitempty" json:"description,omitempty"`
23+
Globs string `yaml:"globs,omitempty" json:"globs,omitempty"`
24+
Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"`
25+
}
26+
27+
// ValidationError represents a validation error with context
28+
type ValidationError struct {
29+
Field string `json:"field"`
30+
Message string `json:"message"`
31+
Value interface{} `json:"value,omitempty"`
32+
Type string `json:"type,omitempty"`
33+
SchemaPath string `json:"schemaPath,omitempty"`
34+
Context string `json:"context,omitempty"`
35+
}
36+
37+
// ValidationResult contains the validation results
38+
type ValidationResult struct {
39+
Valid bool `json:"valid"`
40+
Errors []ValidationError `json:"errors,omitempty"`
41+
}
42+
43+
var (
44+
// compiledSchema holds the compiled JSON schema for reuse
45+
compiledSchema *gojsonschema.Schema
46+
)
47+
48+
// init compiles the schema once at startup
49+
func init() {
50+
schemaLoader := gojsonschema.NewStringLoader(RuleFrontmatterSchema)
51+
schema, err := gojsonschema.NewSchema(schemaLoader)
52+
if err != nil {
53+
panic(fmt.Errorf("failed to compile frontmatter schema: %w", err))
54+
}
55+
compiledSchema = schema
56+
}
57+
58+
// ValidateRuleFile validates a markdown rule file's frontmatter against the schema
59+
func ValidateRuleFile(filePath string) (*ValidationResult, error) {
60+
// Read the file
61+
content, err := os.ReadFile(filePath)
62+
if err != nil {
63+
return nil, fmt.Errorf("failed to read file %s: %w", filePath, err)
64+
}
65+
66+
// Parse frontmatter
67+
frontmatter, err := extractFrontmatter(content)
68+
if err != nil {
69+
return &ValidationResult{
70+
Valid: false,
71+
Errors: []ValidationError{
72+
{
73+
Field: "frontmatter",
74+
Message: fmt.Sprintf("Failed to parse frontmatter: %v", err),
75+
},
76+
},
77+
}, nil
78+
}
79+
80+
// If no frontmatter found, consider it valid (rules without frontmatter are allowed)
81+
if frontmatter == nil {
82+
return &ValidationResult{Valid: true}, nil
83+
}
84+
85+
return ValidateFrontmatter(frontmatter)
86+
}
87+
88+
// ValidateFrontmatter validates frontmatter data against the JSON schema
89+
func ValidateFrontmatter(frontmatter map[string]interface{}) (*ValidationResult, error) {
90+
// Create a document loader from the frontmatter data
91+
documentLoader := gojsonschema.NewGoLoader(frontmatter)
92+
93+
// Validate against the compiled schema
94+
result, err := compiledSchema.Validate(documentLoader)
95+
if err != nil {
96+
return nil, fmt.Errorf("validation failed: %w", err)
97+
}
98+
99+
validationResult := &ValidationResult{
100+
Valid: result.Valid(),
101+
Errors: make([]ValidationError, 0),
102+
}
103+
104+
// Convert validation errors to our format
105+
if !result.Valid() {
106+
for _, err := range result.Errors() {
107+
validationErr := ValidationError{
108+
Field: extractFieldName(err.Field()),
109+
Message: err.Description(),
110+
Type: err.Type(),
111+
Context: err.Context().String(),
112+
Value: err.Value(),
113+
}
114+
115+
// Safely extract schema path from details
116+
if details := err.Details(); details != nil {
117+
if property, ok := details["property"]; ok && property != nil {
118+
validationErr.SchemaPath = fmt.Sprintf("%v", property)
119+
}
120+
}
121+
122+
validationResult.Errors = append(validationResult.Errors, validationErr)
123+
}
124+
}
125+
126+
return validationResult, nil
127+
}
128+
129+
// extractFieldName cleans up the field name for better display
130+
func extractFieldName(field string) string {
131+
// Remove (root). prefix and return clean field name
132+
cleanField := strings.TrimPrefix(field, "(root).")
133+
if cleanField == "(root)" {
134+
return "root"
135+
}
136+
return cleanField
137+
}
138+
139+
// extractFrontmatter extracts YAML frontmatter from markdown content
140+
func extractFrontmatter(content []byte) (map[string]interface{}, error) {
141+
lines := string(content)
142+
143+
// Check if content starts with frontmatter delimiter
144+
if len(lines) < 4 || lines[:4] != "---\n" {
145+
return nil, nil // No frontmatter
146+
}
147+
148+
// Find the end of frontmatter
149+
endDelimiter := "\n---\n"
150+
endIndex := strings.Index(lines[4:], endDelimiter)
151+
if endIndex == -1 {
152+
return nil, fmt.Errorf("unterminated frontmatter: missing closing ---")
153+
}
154+
155+
// Extract frontmatter content
156+
frontmatterContent := lines[4 : 4+endIndex]
157+
if strings.TrimSpace(frontmatterContent) == "" {
158+
return nil, nil // Empty frontmatter
159+
}
160+
161+
// Parse YAML
162+
var frontmatter map[string]interface{}
163+
if err := yaml.Unmarshal([]byte(frontmatterContent), &frontmatter); err != nil {
164+
return nil, fmt.Errorf("invalid YAML frontmatter: %w", err)
165+
}
166+
167+
return frontmatter, nil
168+
}
169+
170+
// GetSchema returns the JSON schema as a string for external use
171+
func GetSchema() string {
172+
return RuleFrontmatterSchema
173+
}
174+
175+
// ValidateJSON validates JSON data directly against the schema
176+
func ValidateJSON(data []byte) (*ValidationResult, error) {
177+
var jsonData interface{}
178+
if err := json.Unmarshal(data, &jsonData); err != nil {
179+
return &ValidationResult{
180+
Valid: false,
181+
Errors: []ValidationError{
182+
{
183+
Field: "json",
184+
Message: fmt.Sprintf("Invalid JSON: %v", err),
185+
},
186+
},
187+
}, nil
188+
}
189+
190+
if jsonMap, ok := jsonData.(map[string]interface{}); ok {
191+
return ValidateFrontmatter(jsonMap)
192+
}
193+
194+
return &ValidationResult{
195+
Valid: false,
196+
Errors: []ValidationError{
197+
{
198+
Field: "root",
199+
Message: "JSON data must be an object",
200+
},
201+
},
202+
}, nil
203+
}
204+
205+
// ValidateYAML validates YAML data directly against the schema
206+
func ValidateYAML(data []byte) (*ValidationResult, error) {
207+
var yamlData map[string]interface{}
208+
if err := yaml.Unmarshal(data, &yamlData); err != nil {
209+
return &ValidationResult{
210+
Valid: false,
211+
Errors: []ValidationError{
212+
{
213+
Field: "yaml",
214+
Message: fmt.Sprintf("Invalid YAML: %v", err),
215+
},
216+
},
217+
}, nil
218+
}
219+
220+
return ValidateFrontmatter(yamlData)
221+
}

0 commit comments

Comments
 (0)