From e9fd1577b6e3138c5a9775394d3f10cf8f10e7a1 Mon Sep 17 00:00:00 2001 From: jackchuka Date: Thu, 7 May 2026 01:33:02 +0900 Subject: [PATCH] feat(frontmatter): support nested keys via dot-notation Resolves #59. Field names can now use dot-notation paths (e.g., `metadata.author`) to validate nested YAML frontmatter keys. Adds `object` field type for asserting a parent is a map. Generator emits properly indented nested YAML for grouped paths. --- README.md | 30 ++++- internal/rules/frontmatter.go | 141 +++++++++++++++++++-- internal/rules/frontmatter_test.go | 197 +++++++++++++++++++++++++++++ internal/schema/schema.go | 11 +- schema.json | 7 +- 5 files changed, 370 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index c9e7825..e494478 100644 --- a/README.md +++ b/README.md @@ -372,9 +372,37 @@ frontmatter: - { name: "repo", optional: true, format: url } ``` -**Field types:** `string`, `number`, `boolean`, `array`, `date` +**Field types:** `string`, `number`, `boolean`, `array`, `date`, `object` **Field formats:** `date` (YYYY-MM-DD), `email`, `url` +##### Nested Frontmatter Keys + +Use dot-notation in `name` to validate nested keys: + +```yaml +frontmatter: + fields: + - { name: "title" } + - { name: "metadata", type: object } + - { name: "metadata.author" } + - { name: "metadata.version" } + - { name: "metadata.homepage", optional: true, format: url } +``` + +Validates a document like: + +```yaml +--- +title: My Document +metadata: + author: example-org + version: "1.0" +--- +``` + +If a key segment contains a literal dot, escape it with a backslash: +`name: "weird\\.key"`. + ## Use Cases - **Documentation Standards** - Enforce consistent README structure across repositories diff --git a/internal/rules/frontmatter.go b/internal/rules/frontmatter.go index 6f1f5da..a618333 100644 --- a/internal/rules/frontmatter.go +++ b/internal/rules/frontmatter.go @@ -59,7 +59,7 @@ func (r *FrontmatterRule) ValidateWithContext(ctx *vast.Context) []Violation { // Validate required fields for _, field := range config.Fields { - value, exists := fm.Data[field.Name] + value, exists := lookupField(fm.Data, field.Name) if !field.Optional && !exists { violations = append(violations, @@ -91,6 +91,56 @@ func (r *FrontmatterRule) ValidateWithContext(ctx *vast.Context) []Violation { return violations } +// splitFieldPath splits a dot-notation path into segments. A literal dot can +// be escaped with a backslash (e.g. "weird\\.key" → ["weird.key"]). +func splitFieldPath(name string) []string { + segments := []string{} + var current strings.Builder + for i := 0; i < len(name); i++ { + c := name[i] + if c == '\\' && i+1 < len(name) && name[i+1] == '.' { + current.WriteByte('.') + i++ + continue + } + if c == '.' { + segments = append(segments, current.String()) + current.Reset() + continue + } + current.WriteByte(c) + } + segments = append(segments, current.String()) + return segments +} + +// lookupField walks a frontmatter data map using a dot-notation path. It +// handles both map[string]any and map[any]any (yaml.v3 may produce the +// latter for nested maps). +func lookupField(data map[string]any, name string) (any, bool) { + segments := splitFieldPath(name) + var current any = data + for _, seg := range segments { + switch m := current.(type) { + case map[string]any: + v, ok := m[seg] + if !ok { + return nil, false + } + current = v + case map[any]any: + v, ok := m[seg] + if !ok { + return nil, false + } + current = v + default: + return nil, false + } + } + return current, true +} + // validateFieldType checks if a field value matches the expected type func (r *FrontmatterRule) validateFieldType(name string, value any, expectedType schema.FieldType) string { switch expectedType { @@ -113,6 +163,12 @@ func (r *FrontmatterRule) validateFieldType(name string, value any, expectedType if _, ok := value.([]any); !ok { return fmt.Sprintf("Frontmatter field '%s' should be an array", name) } + case schema.FieldTypeObject: + switch value.(type) { + case map[string]any, map[any]any: + default: + return fmt.Sprintf("Frontmatter field '%s' should be an object", name) + } case schema.FieldTypeDate: // Date can be string or time.Time depending on YAML parsing // YAML may parse dates like 2024-01-15 as time.Time @@ -194,19 +250,86 @@ func (r *FrontmatterRule) Generate(builder *strings.Builder, s *schema.Schema) b return false } + tree := buildFrontmatterTree(s.Frontmatter.Fields) + builder.WriteString("---\n") + r.writeFrontmatterTree(builder, tree, 0) + builder.WriteString("---\n\n") + return true +} - for _, field := range s.Frontmatter.Fields { - placeholder := r.getPlaceholder(field) - if !field.Optional { - builder.WriteString(field.Name + ": " + placeholder + " # required\n") +// fmTreeNode is a node in the tree built from dot-notation field paths. +// Branches (nodes with children) emit as YAML maps; leaves emit a placeholder. +type fmTreeNode struct { + key string + field *schema.FrontmatterField + children []*fmTreeNode +} + +func buildFrontmatterTree(fields []schema.FrontmatterField) []*fmTreeNode { + var roots []*fmTreeNode + for i := range fields { + f := fields[i] + segments := splitFieldPath(f.Name) + insertFrontmatterPath(&roots, segments, &f, 0) + } + return roots +} + +func insertFrontmatterPath(siblings *[]*fmTreeNode, segments []string, f *schema.FrontmatterField, depth int) { + seg := segments[depth] + var node *fmTreeNode + for _, s := range *siblings { + if s.key == seg { + node = s + break + } + } + if node == nil { + node = &fmTreeNode{key: seg} + *siblings = append(*siblings, node) + } + if depth == len(segments)-1 { + node.field = f + return + } + insertFrontmatterPath(&node.children, segments, f, depth+1) +} + +func (r *FrontmatterRule) writeFrontmatterTree(builder *strings.Builder, nodes []*fmTreeNode, depth int) { + indent := strings.Repeat(" ", depth) + for _, n := range nodes { + if len(n.children) > 0 { + if hasRequiredDescendant(n) { + builder.WriteString(indent + n.key + ": # required\n") + } else { + builder.WriteString(indent + n.key + ":\n") + } + r.writeFrontmatterTree(builder, n.children, depth+1) + continue + } + if n.field == nil { + continue + } + placeholder := r.getPlaceholder(*n.field) + if !n.field.Optional { + builder.WriteString(indent + n.key + ": " + placeholder + " # required\n") } else { - builder.WriteString(field.Name + ": " + placeholder + "\n") + builder.WriteString(indent + n.key + ": " + placeholder + "\n") } } +} - builder.WriteString("---\n\n") - return true +func hasRequiredDescendant(n *fmTreeNode) bool { + if n.field != nil && !n.field.Optional { + return true + } + for _, c := range n.children { + if hasRequiredDescendant(c) { + return true + } + } + return false } // getPlaceholder returns an appropriate placeholder value based on field type/format @@ -233,6 +356,8 @@ func (r *FrontmatterRule) getPlaceholder(field schema.FrontmatterField) string { return "[\"item1\", \"item2\"]" case schema.FieldTypeDate: return "2024-01-01" + case schema.FieldTypeObject: + return "{}" default: return "\"TODO\"" } diff --git a/internal/rules/frontmatter_test.go b/internal/rules/frontmatter_test.go index ee563c8..182c913 100644 --- a/internal/rules/frontmatter_test.go +++ b/internal/rules/frontmatter_test.go @@ -389,3 +389,200 @@ func TestFrontmatterNoFrontmatter(t *testing.T) { t.Error("FrontMatter should be nil when not present") } } + +func TestFrontmatterRuleNestedFields(t *testing.T) { + const agentSkills = `--- +name: my-skill +metadata: + author: example-org + version: "1.0" +--- + +# Title +` + + tests := []struct { + name string + content string + fields []schema.FrontmatterField + wantViolation bool + wantMessageSubstr string + }{ + { + name: "nested keys present", + content: agentSkills, + fields: []schema.FrontmatterField{ + {Name: "name"}, + {Name: "metadata.author"}, + {Name: "metadata.version"}, + }, + }, + { + name: "nested key missing", + content: "---\nname: x\nmetadata:\n version: \"1.0\"\n---\n\n# T\n", + fields: []schema.FrontmatterField{ + {Name: "metadata.author"}, + }, + wantViolation: true, + wantMessageSubstr: "metadata.author", + }, + { + name: "parent missing for nested key", + content: "---\nname: x\n---\n\n# T\n", + fields: []schema.FrontmatterField{ + {Name: "metadata.author"}, + }, + wantViolation: true, + wantMessageSubstr: "metadata.author", + }, + { + name: "nested key optional missing", + content: "---\nname: x\n---\n\n# T\n", + fields: []schema.FrontmatterField{ + {Name: "metadata.author", Optional: true}, + }, + }, + { + name: "nested key wrong type", + content: "---\nmetadata:\n version: 1.0\n---\n\n# T\n", + fields: []schema.FrontmatterField{ + {Name: "metadata.version", Type: schema.FieldTypeString}, + }, + wantViolation: true, + wantMessageSubstr: "string", + }, + { + name: "object type on parent", + content: "---\nmetadata:\n author: x\n---\n\n# T\n", + fields: []schema.FrontmatterField{ + {Name: "metadata", Type: schema.FieldTypeObject}, + }, + }, + { + name: "object type rejects scalar", + content: "---\nmetadata: just-a-string\n---\n\n# T\n", + fields: []schema.FrontmatterField{ + {Name: "metadata", Type: schema.FieldTypeObject}, + }, + wantViolation: true, + wantMessageSubstr: "object", + }, + { + name: "scalar parent rejects nested lookup", + content: "---\nmetadata: just-a-string\n---\n\n# T\n", + fields: []schema.FrontmatterField{ + {Name: "metadata.author"}, + }, + wantViolation: true, + wantMessageSubstr: "metadata.author", + }, + { + name: "format on nested url", + content: "---\nmetadata:\n homepage: not-a-url\n---\n\n# T\n", + fields: []schema.FrontmatterField{ + {Name: "metadata.homepage", Format: schema.FieldFormatURL}, + }, + wantViolation: true, + wantMessageSubstr: "URL", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := parser.New() + doc, err := p.Parse("test.md", []byte(tt.content)) + if err != nil { + t.Fatalf("Parse() error: %v", err) + } + + s := &schema.Schema{ + Frontmatter: &schema.FrontmatterConfig{Fields: tt.fields}, + } + + ctx := vast.NewContext(doc, s, "") + rule := NewFrontmatterRule() + violations := rule.ValidateWithContext(ctx) + + if tt.wantViolation { + if len(violations) == 0 { + t.Fatalf("expected violation, got none") + } + found := false + for _, v := range violations { + if strings.Contains(v.Message, tt.wantMessageSubstr) { + found = true + break + } + } + if !found { + t.Errorf("expected violation containing %q, got %+v", tt.wantMessageSubstr, violations) + } + return + } + if len(violations) != 0 { + t.Errorf("expected no violations, got %+v", violations) + } + }) + } +} + +func TestSplitFieldPath(t *testing.T) { + tests := []struct { + input string + want []string + }{ + {"author", []string{"author"}}, + {"metadata.author", []string{"metadata", "author"}}, + {"a.b.c", []string{"a", "b", "c"}}, + {`weird\.key`, []string{"weird.key"}}, + {`a.b\.c.d`, []string{"a", "b.c", "d"}}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := splitFieldPath(tt.input) + if len(got) != len(tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("got %v, want %v", got, tt.want) + } + } + }) + } +} + +func TestFrontmatterRuleGenerateNested(t *testing.T) { + rule := NewFrontmatterRule() + var builder strings.Builder + + s := &schema.Schema{ + Frontmatter: &schema.FrontmatterConfig{ + Fields: []schema.FrontmatterField{ + {Name: "name", Type: schema.FieldTypeString}, + {Name: "metadata.author", Type: schema.FieldTypeString}, + {Name: "metadata.version", Type: schema.FieldTypeString}, + {Name: "metadata.homepage", Optional: true, Format: schema.FieldFormatURL}, + }, + }, + } + + if !rule.Generate(&builder, s) { + t.Fatal("Generate should return true") + } + + output := builder.String() + wantSubstrings := []string{ + "---\n", + "name: \"TODO\" # required", + "metadata: # required", + " author: \"TODO\" # required", + " version: \"TODO\" # required", + " homepage: https://example.com", + } + for _, s := range wantSubstrings { + if !strings.Contains(output, s) { + t.Errorf("output missing %q\nGot:\n%s", s, output) + } + } +} diff --git a/internal/schema/schema.go b/internal/schema/schema.go index 99be083..2dac0e5 100644 --- a/internal/schema/schema.go +++ b/internal/schema/schema.go @@ -463,14 +463,15 @@ const ( FieldTypeBoolean FieldType = "boolean" FieldTypeArray FieldType = "array" FieldTypeDate FieldType = "date" + FieldTypeObject FieldType = "object" ) // JSONSchema implements jsonschema.JSONSchemer to add enum constraint func (FieldType) JSONSchema() *jsonschema.Schema { return &jsonschema.Schema{ Type: "string", - Enum: []any{"string", "number", "boolean", "array", "date"}, - Description: "Field type: string, number, boolean, array, or date", + Enum: []any{"string", "number", "boolean", "array", "date", "object"}, + Description: "Field type: string, number, boolean, array, date, or object", } } @@ -495,8 +496,10 @@ func (FieldFormat) JSONSchema() *jsonschema.Schema { // FrontmatterField defines a single frontmatter field requirement type FrontmatterField struct { - // Name is the field name (required) - Name string `yaml:"name" json:"name" lc:"field name"` + // Name is the field name (required). Supports dot-notation for nested + // frontmatter keys, e.g., "metadata.author". Path segments containing a + // literal dot can be escaped with a backslash, e.g., "weird\\.key". + Name string `yaml:"name" json:"name" lc:"field name (dot-notation for nested keys, e.g. 'metadata.author')"` // Optional indicates whether this field is not required (default: false = required) Optional bool `yaml:"optional,omitempty" json:"optional,omitempty" lc:"field is not required"` diff --git a/schema.json b/schema.json index 8732ce1..03c3068 100644 --- a/schema.json +++ b/schema.json @@ -51,9 +51,10 @@ "number", "boolean", "array", - "date" + "date", + "object" ], - "description": "Field type: string, number, boolean, array, or date" + "description": "Field type: string, number, boolean, array, date, or object" }, "FrontmatterConfig": { "properties": { @@ -76,7 +77,7 @@ "properties": { "name": { "type": "string", - "description": "Field name" + "description": "Field name (dot-notation for nested keys, e.g. 'metadata.author')" }, "optional": { "type": "boolean",