From 8c7fac3ff608cd8302ff27b8e1ff7a628fc025c5 Mon Sep 17 00:00:00 2001 From: jackchuka Date: Fri, 24 Jul 2026 17:39:58 +0900 Subject: [PATCH 1/6] feat(parser): collect top-level paragraphs per section --- internal/parser/extractor.go | 8 +++++ internal/parser/hierarchy.go | 13 ++++---- internal/parser/paragraph_test.go | 55 +++++++++++++++++++++++++++++++ internal/parser/parser.go | 8 ++++- internal/parser/types.go | 10 ++++++ internal/parser/utils.go | 4 +++ internal/vast/types.go | 8 +++++ 7 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 internal/parser/paragraph_test.go diff --git a/internal/parser/extractor.go b/internal/parser/extractor.go index 146f6a7..3758b3e 100644 --- a/internal/parser/extractor.go +++ b/internal/parser/extractor.go @@ -108,6 +108,14 @@ func extractList(node *ast.List, content []byte) *List { return list } +func extractParagraph(node *ast.Paragraph, content []byte) *Paragraph { + line, col := getPosition(node, content) + return &Paragraph{ + Line: line, + Column: col, + } +} + func extractTable(node *east.Table, content []byte) *Table { headers := make([]string, 0) diff --git a/internal/parser/hierarchy.go b/internal/parser/hierarchy.go index 59650b9..95b0a57 100644 --- a/internal/parser/hierarchy.go +++ b/internal/parser/hierarchy.go @@ -3,7 +3,7 @@ package parser import "strings" // buildHierarchicalSections creates a hierarchical tree of sections -func (p *Parser) buildHierarchicalSections(headings []*Heading, codeBlocks []*CodeBlock, tables []*Table, links []*Link, images []*Image, lists []*List, content []byte) *Section { +func (p *Parser) buildHierarchicalSections(headings []*Heading, codeBlocks []*CodeBlock, tables []*Table, links []*Link, images []*Image, lists []*List, paragraphs []*Paragraph, content []byte) *Section { // Create root section for top-level content contentLineCount := len(strings.Split(string(content), "\n")) root := &Section{ @@ -56,20 +56,20 @@ func (p *Parser) buildHierarchicalSections(headings []*Heading, codeBlocks []*Co } // Second pass: set end lines and associate content - p.setEndLinesAndContent(root, codeBlocks, tables, links, images, lists, content) + p.setEndLinesAndContent(root, codeBlocks, tables, links, images, lists, paragraphs, content) return root } // setEndLinesAndContent sets end lines first, then associates content with sections -func (p *Parser) setEndLinesAndContent(section *Section, codeBlocks []*CodeBlock, tables []*Table, links []*Link, images []*Image, lists []*List, content []byte) { +func (p *Parser) setEndLinesAndContent(section *Section, codeBlocks []*CodeBlock, tables []*Table, links []*Link, images []*Image, lists []*List, paragraphs []*Paragraph, content []byte) { contentLines := strings.Split(string(content), "\n") // First pass: set all end lines correctly (bottom-up via recursion) p.setEndLines(section, len(contentLines)) // Second pass: associate elements and extract content - p.associateContent(section, codeBlocks, tables, links, images, lists, contentLines) + p.associateContent(section, codeBlocks, tables, links, images, lists, paragraphs, contentLines) } // setEndLines recursively sets end lines for all sections (top-down) @@ -109,7 +109,7 @@ func (p *Parser) findNextSiblingStart(section *Section) int { } // associateContent recursively extracts content and associates elements with sections -func (p *Parser) associateContent(section *Section, codeBlocks []*CodeBlock, tables []*Table, links []*Link, images []*Image, lists []*List, contentLines []string) { +func (p *Parser) associateContent(section *Section, codeBlocks []*CodeBlock, tables []*Table, links []*Link, images []*Image, lists []*List, paragraphs []*Paragraph, contentLines []string) { // Extract content if section.StartLine > 0 && section.EndLine <= len(contentLines) { sectionContent := make([]string, 0) @@ -131,10 +131,11 @@ func (p *Parser) associateContent(section *Section, codeBlocks []*CodeBlock, tab section.Links = filterElements(section, links) section.Images = filterElements(section, images) section.Lists = filterElements(section, lists) + section.Paragraphs = filterElements(section, paragraphs) // Recursively process children for _, child := range section.Children { - p.associateContent(child, codeBlocks, tables, links, images, lists, contentLines) + p.associateContent(child, codeBlocks, tables, links, images, lists, paragraphs, contentLines) } } diff --git a/internal/parser/paragraph_test.go b/internal/parser/paragraph_test.go new file mode 100644 index 0000000..d58b762 --- /dev/null +++ b/internal/parser/paragraph_test.go @@ -0,0 +1,55 @@ +package parser + +import "testing" + +func TestParagraphsTopLevelOnly(t *testing.T) { + src := []byte(`# Title + +## Overview + +First prose paragraph. + +Second prose paragraph. + +- a bullet +- another bullet + +> a blockquote paragraph +`) + p := New() + doc, err := p.Parse("test.md", src) + if err != nil { + t.Fatalf("Parse() error: %v", err) + } + + var overview *Section + for _, s := range doc.GetSections() { + if s.Heading != nil && s.Heading.Text == "Overview" { + overview = s + } + } + if overview == nil { + t.Fatal("Overview section not found") + } + + // Two prose paragraphs; the list and blockquote paragraphs must NOT count. + if got := len(overview.Paragraphs); got != 2 { + t.Errorf("len(Paragraphs) = %d, want 2", got) + } +} + +func TestParagraphsEmptySection(t *testing.T) { + src := []byte("# Title\n\n## Empty\n\n## Next\n\ntext\n") + p := New() + doc, err := p.Parse("test.md", src) + if err != nil { + t.Fatalf("Parse() error: %v", err) + } + for _, s := range doc.GetSections() { + if s.Heading != nil && s.Heading.Text == "Empty" { + if got := len(s.Paragraphs); got != 0 { + t.Errorf("Empty section len(Paragraphs) = %d, want 0", got) + } + } + } +} diff --git a/internal/parser/parser.go b/internal/parser/parser.go index d82ed6a..4d34912 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -67,6 +67,7 @@ func (p *Parser) Parse(path string, content []byte) (*Document, error) { lists := make([]*List, 0) tables := make([]*Table, 0) images := make([]*Image, 0) + paragraphs := make([]*Paragraph, 0) // Walk the AST and extract elements if err := ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { @@ -98,6 +99,11 @@ func (p *Parser) Parse(path string, content []byte) (*Document, error) { case *ast.Image: image := extractImage(node, content) images = append(images, image) + + case *ast.Paragraph: + if node.Parent() != nil && node.Parent().Kind() == ast.KindDocument { + paragraphs = append(paragraphs, extractParagraph(node, content)) + } } return ast.WalkContinue, nil @@ -106,7 +112,7 @@ func (p *Parser) Parse(path string, content []byte) (*Document, error) { } // Build hierarchical structure - root := p.buildHierarchicalSections(headings, codeBlocks, tables, links, images, lists, content) + root := p.buildHierarchicalSections(headings, codeBlocks, tables, links, images, lists, paragraphs, content) // Create the document doc := &Document{ diff --git a/internal/parser/types.go b/internal/parser/types.go index 6512f3f..f703956 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -41,6 +41,7 @@ type Section struct { Links []*Link Images []*Image Lists []*List + Paragraphs []*Paragraph } // collectSections recursively collects all sections in document order @@ -76,6 +77,12 @@ type List struct { Column int } +// Paragraph represents a top-level prose paragraph (direct child of the document) +type Paragraph struct { + Line int + Column int +} + // Table represents a table in the document type Table struct { Headers []string @@ -118,3 +125,6 @@ func (t *Table) GetLine() int { return t.Line } // GetLine returns the line number of the image func (i *Image) GetLine() int { return i.Line } + +// GetLine returns the line number of the paragraph +func (p *Paragraph) GetLine() int { return p.Line } diff --git a/internal/parser/utils.go b/internal/parser/utils.go index 8136026..4b731a3 100644 --- a/internal/parser/utils.go +++ b/internal/parser/utils.go @@ -29,6 +29,10 @@ func getPosition(node ast.Node, content []byte) (line, col int) { if n.Lines().Len() > 0 { return calculateLineColumn(content, n.Lines().At(0).Start) } + case *ast.Paragraph: + if n.Lines().Len() > 0 { + return calculateLineColumn(content, n.Lines().At(0).Start) + } } // Fallback to finding the first text node diff --git a/internal/vast/types.go b/internal/vast/types.go index ccddab1..fafb438 100644 --- a/internal/vast/types.go +++ b/internal/vast/types.go @@ -78,6 +78,14 @@ func (n *Node) Lists() []*parser.List { return nil } +// Paragraphs returns the top-level paragraphs if bound, empty slice otherwise. +func (n *Node) Paragraphs() []*parser.Paragraph { + if n.Section != nil { + return n.Section.Paragraphs + } + return nil +} + // Location returns line/column for error reporting. func (n *Node) Location() (line, column int) { if n.Section != nil && n.Section.Heading != nil { From a9e9a1d34125ef4b8a9463922bbf9247ae44b558 Mon Sep 17 00:00:00 2001 From: jackchuka Date: Fri, 24 Jul 2026 17:47:46 +0900 Subject: [PATCH 2/6] test(parser): prove list/blockquote paragraph exclusion; init Paragraphs slice Use a loose list (blank line between items) in TestParagraphsTopLevelOnly so item content becomes ast.Paragraph with a ListItem parent, genuinely exercising the ast.KindDocument exclusion gate in parser.go. A tight list renders as ast.TextBlock and never hit that gate. Also initialize the Paragraphs slice in the root/heading Section literals in hierarchy.go for consistency with sibling fields. --- internal/parser/hierarchy.go | 2 ++ internal/parser/paragraph_test.go | 1 + 2 files changed, 3 insertions(+) diff --git a/internal/parser/hierarchy.go b/internal/parser/hierarchy.go index 95b0a57..75c0b9b 100644 --- a/internal/parser/hierarchy.go +++ b/internal/parser/hierarchy.go @@ -17,6 +17,7 @@ func (p *Parser) buildHierarchicalSections(headings []*Heading, codeBlocks []*Co Links: make([]*Link, 0), Images: make([]*Image, 0), Lists: make([]*List, 0), + Paragraphs: make([]*Paragraph, 0), } // Stack to track current nesting level @@ -34,6 +35,7 @@ func (p *Parser) buildHierarchicalSections(headings []*Heading, codeBlocks []*Co Links: make([]*Link, 0), Images: make([]*Image, 0), Lists: make([]*List, 0), + Paragraphs: make([]*Paragraph, 0), } // Find appropriate parent based on heading level diff --git a/internal/parser/paragraph_test.go b/internal/parser/paragraph_test.go index d58b762..24ed7d5 100644 --- a/internal/parser/paragraph_test.go +++ b/internal/parser/paragraph_test.go @@ -12,6 +12,7 @@ First prose paragraph. Second prose paragraph. - a bullet + - another bullet > a blockquote paragraph From c8e016639a4b671b94d3caa0db2772417eaf837d Mon Sep 17 00:00:00 2001 From: jackchuka Date: Fri, 24 Jul 2026 17:50:58 +0900 Subject: [PATCH 3/6] feat: add paragraphs section rule --- internal/jsonschema/generator.go | 1 + internal/rules/paragraph.go | 71 +++++++++++++++++++++++++++ internal/rules/paragraph_test.go | 82 ++++++++++++++++++++++++++++++++ internal/rules/rule.go | 1 + internal/schema/schema.go | 10 ++++ schema.json | 18 +++++++ 6 files changed, 183 insertions(+) create mode 100644 internal/rules/paragraph.go create mode 100644 internal/rules/paragraph_test.go diff --git a/internal/jsonschema/generator.go b/internal/jsonschema/generator.go index 26c7e6f..5b38185 100644 --- a/internal/jsonschema/generator.go +++ b/internal/jsonschema/generator.go @@ -65,6 +65,7 @@ func Generate() ([]byte, error) { {"TableRule", reflect.TypeOf(schema.TableRule{})}, {"ListRule", reflect.TypeOf(schema.ListRule{})}, {"WordCountRule", reflect.TypeOf(schema.WordCountRule{})}, + {"ParagraphRule", reflect.TypeOf(schema.ParagraphRule{})}, {"CountConstraint", reflect.TypeOf(schema.CountConstraint{})}, } diff --git a/internal/rules/paragraph.go b/internal/rules/paragraph.go new file mode 100644 index 0000000..a895be4 --- /dev/null +++ b/internal/rules/paragraph.go @@ -0,0 +1,71 @@ +package rules + +import ( + "fmt" + "strings" + + "github.com/jackchuka/mdschema/internal/schema" + "github.com/jackchuka/mdschema/internal/vast" +) + +// ParagraphRule validates paragraph-count requirements for sections +type ParagraphRule struct { +} + +var _ StructuralRule = (*ParagraphRule)(nil) + +// NewParagraphRule creates a new paragraph rule +func NewParagraphRule() *ParagraphRule { + return &ParagraphRule{} +} + +// Name returns the rule identifier +func (r *ParagraphRule) Name() string { + return "paragraph" +} + +// ValidateWithContext validates using VAST (validation-ready AST) +func (r *ParagraphRule) ValidateWithContext(ctx *vast.Context) []Violation { + violations := make([]Violation, 0) + + ctx.Tree.WalkBound(func(n *vast.Node) bool { + if n.Element.SectionRules != nil && n.Element.Paragraphs != nil { + rule := n.Element.Paragraphs + count := len(n.Paragraphs()) + line, col := n.Location() + + if rule.Min > 0 && count < rule.Min { + violations = append(violations, + NewViolation(r.Name(), fmt.Sprintf("Section '%s' has too few paragraphs (minimum %d, found %d)", n.HeadingText(), rule.Min, count), line, col)) + } + + if rule.Max > 0 && count > rule.Max { + violations = append(violations, + NewViolation(r.Name(), fmt.Sprintf("Section '%s' has too many paragraphs (maximum %d, found %d)", n.HeadingText(), rule.Max, count), line, col)) + } + } + return true + }) + + return violations +} + +// GenerateContent generates placeholder content for paragraph rules +func (r *ParagraphRule) GenerateContent(builder *strings.Builder, element schema.StructureElement) bool { + if element.SectionRules == nil || element.Paragraphs == nil { + return false + } + + rule := element.Paragraphs + + builder.WriteString("\n") + if rule.Min > 0 { + fmt.Fprintf(builder, "\n", rule.Min) + } + if rule.Max > 0 { + fmt.Fprintf(builder, "\n", rule.Max) + } + builder.WriteString("\n") + + return true +} diff --git a/internal/rules/paragraph_test.go b/internal/rules/paragraph_test.go new file mode 100644 index 0000000..59c52e8 --- /dev/null +++ b/internal/rules/paragraph_test.go @@ -0,0 +1,82 @@ +package rules + +import ( + "testing" + + "github.com/jackchuka/mdschema/internal/parser" + "github.com/jackchuka/mdschema/internal/schema" + "github.com/jackchuka/mdschema/internal/vast" +) + +// paragraphSchema builds a schema requiring "# T" > "## Overview" with the +// given paragraph min/max constraints. +func paragraphSchema(min, max int) *schema.Schema { + return &schema.Schema{ + Structure: []schema.StructureElement{ + { + Heading: schema.HeadingPattern{Pattern: "# T"}, + Children: []schema.StructureElement{ + { + Heading: schema.HeadingPattern{Pattern: "## Overview"}, + SectionRules: &schema.SectionRules{ + Paragraphs: &schema.ParagraphRule{Min: min, Max: max}, + }, + }, + }, + }, + }, + } +} + +func TestParagraphRuleMinFails(t *testing.T) { + md := "# T\n\n## Overview\n\n- just a list\n" + p := parser.New() + doc, err := p.Parse("test.md", []byte(md)) + if err != nil { + t.Fatalf("Parse() error: %v", err) + } + + sch := paragraphSchema(1, 0) // min:1, max:0(none) on "## Overview" + ctx := vast.NewContext(doc, sch, "") + + v := NewParagraphRule().ValidateWithContext(ctx) + if len(v) != 1 { + t.Fatalf("expected 1 violation, got %d: %+v", len(v), v) + } + // A bullet list contains no top-level paragraph, so found == 0. + if want := "Section 'Overview' has too few paragraphs (minimum 1, found 0)"; v[0].Message != want { + t.Errorf("message = %q, want %q", v[0].Message, want) + } +} + +func TestParagraphRuleMinPasses(t *testing.T) { + md := "# T\n\n## Overview\n\nA real prose paragraph.\n" + p := parser.New() + doc, err := p.Parse("test.md", []byte(md)) + if err != nil { + t.Fatalf("Parse() error: %v", err) + } + + ctx := vast.NewContext(doc, paragraphSchema(1, 0), "") + if v := NewParagraphRule().ValidateWithContext(ctx); len(v) != 0 { + t.Fatalf("expected 0 violations, got %d: %+v", len(v), v) + } +} + +func TestParagraphRuleMaxFails(t *testing.T) { + md := "# T\n\n## Overview\n\nOne.\n\nTwo.\n\nThree.\n" + p := parser.New() + doc, err := p.Parse("test.md", []byte(md)) + if err != nil { + t.Fatalf("Parse() error: %v", err) + } + + ctx := vast.NewContext(doc, paragraphSchema(0, 2), "") + v := NewParagraphRule().ValidateWithContext(ctx) + if len(v) != 1 { + t.Fatalf("expected 1 violation, got %d", len(v)) + } + if want := "Section 'Overview' has too many paragraphs (maximum 2, found 3)"; v[0].Message != want { + t.Errorf("message = %q, want %q", v[0].Message, want) + } +} diff --git a/internal/rules/rule.go b/internal/rules/rule.go index de342d0..fa18245 100644 --- a/internal/rules/rule.go +++ b/internal/rules/rule.go @@ -47,6 +47,7 @@ func defaultStructuralRules() []StructuralRule { NewTableRule(), NewListRule(), NewWordCountRule(), + NewParagraphRule(), } } diff --git a/internal/schema/schema.go b/internal/schema/schema.go index 2dac0e5..eb532f0 100644 --- a/internal/schema/schema.go +++ b/internal/schema/schema.go @@ -173,6 +173,7 @@ func (StructureElement) JSONSchema() *jsonschema.Schema { Items: &jsonschema.Schema{Ref: "#/$defs/ListRule"}, }) props.Set("word_count", &jsonschema.Schema{Ref: "#/$defs/WordCountRule", Description: "Word count constraints"}) + props.Set("paragraphs", &jsonschema.Schema{Ref: "#/$defs/ParagraphRule", Description: "Paragraph count constraints"}) return &jsonschema.Schema{ OneOf: []*jsonschema.Schema{ @@ -276,6 +277,9 @@ type SectionRules struct { // Word count requirements for the section WordCount *WordCountRule `yaml:"word_count,omitempty" json:"word_count,omitempty" lc:"word count constraints"` + + // Paragraph count requirements for the section + Paragraphs *ParagraphRule `yaml:"paragraphs,omitempty" json:"paragraphs,omitempty" lc:"paragraph count constraints"` } // RequiredTextPattern defines a required text pattern with optional regex support @@ -423,6 +427,12 @@ type WordCountRule struct { Max int `yaml:"max,omitempty" json:"max,omitempty" lc:"maximum words"` } +// ParagraphRule defines paragraph-count requirements for a section +type ParagraphRule struct { + Min int `yaml:"min,omitempty" json:"min,omitempty" lc:"minimum paragraphs"` + Max int `yaml:"max,omitempty" json:"max,omitempty" lc:"maximum paragraphs"` +} + // CountConstraint defines how many times a structure element can match type CountConstraint struct { Min int `yaml:"min,omitempty" json:"min,omitempty" lc:"minimum occurrences required"` diff --git a/schema.json b/schema.json index 03c3068..5ccddb1 100644 --- a/schema.json +++ b/schema.json @@ -207,6 +207,20 @@ "additionalProperties": false, "type": "object" }, + "ParagraphRule": { + "properties": { + "min": { + "type": "integer", + "description": "Minimum paragraphs" + }, + "max": { + "type": "integer", + "description": "Maximum paragraphs" + } + }, + "additionalProperties": false, + "type": "object" + }, "Schema": { "properties": { "structure": { @@ -378,6 +392,10 @@ "word_count": { "$ref": "#/$defs/WordCountRule", "description": "Word count constraints" + }, + "paragraphs": { + "$ref": "#/$defs/ParagraphRule", + "description": "Paragraph count constraints" } }, "additionalProperties": false, From 5103894a2114a42dd810a62662223c63244f74a0 Mon Sep 17 00:00:00 2001 From: jackchuka Date: Fri, 24 Jul 2026 17:56:35 +0900 Subject: [PATCH 4/6] feat: warn on unknown schema config keys --- cmd/mdschema/commands/generate.go | 4 +- cmd/mdschema/commands/utils.go | 36 ++++++--- internal/integration/common_test.go | 2 +- internal/schema/loader.go | 19 +++-- internal/schema/loader_test.go | 10 +-- internal/schema/warnings.go | 117 ++++++++++++++++++++++++++++ internal/schema/warnings_test.go | 65 ++++++++++++++++ 7 files changed, 227 insertions(+), 26 deletions(-) create mode 100644 internal/schema/warnings.go create mode 100644 internal/schema/warnings_test.go diff --git a/cmd/mdschema/commands/generate.go b/cmd/mdschema/commands/generate.go index 0dd5aa1..897821e 100644 --- a/cmd/mdschema/commands/generate.go +++ b/cmd/mdschema/commands/generate.go @@ -40,10 +40,12 @@ func runGenerate(cfg *Config, schemaFile, outputFile string) error { var err error if schemaFile != "" { - s, err = schema.Load(schemaFile) + var warnings []schema.Warning + s, warnings, err = schema.Load(schemaFile) if err != nil { return fmt.Errorf("loading schema from %s: %w", schemaFile, err) } + printSchemaWarnings(schemaFile, warnings) } else { // Load schema using existing utility s, _, err = loadSchema(cfg) diff --git a/cmd/mdschema/commands/utils.go b/cmd/mdschema/commands/utils.go index 4d2faa6..0246599 100644 --- a/cmd/mdschema/commands/utils.go +++ b/cmd/mdschema/commands/utils.go @@ -10,26 +10,38 @@ import ( // loadSchema loads a schema based on config or discovery func loadSchema(cfg *Config) (*schema.Schema, string, error) { + var loaded *schema.Schema + var warnings []schema.Warning + var path string + var err error + if cfg.SchemaFile != "" { - // Load explicitly specified schema - loaded, err := schema.Load(cfg.SchemaFile) + loaded, warnings, err = schema.Load(cfg.SchemaFile) if err != nil { return nil, "", fmt.Errorf("loading schema %s: %w", cfg.SchemaFile, err) } - return loaded, cfg.SchemaFile, nil + path = cfg.SchemaFile + } else { + schemaPath, ferr := schema.FindSchema(".") + if ferr != nil { + return nil, "", fmt.Errorf("finding schema: %w", ferr) + } + loaded, warnings, err = schema.Load(schemaPath) + if err != nil { + return nil, "", fmt.Errorf("loading discovered schema: %w", err) + } + path = schemaPath } - // Try to discover schema in current directory - schemaPath, err := schema.FindSchema(".") - if err != nil { - return nil, "", fmt.Errorf("finding schema: %w", err) - } + printSchemaWarnings(path, warnings) + return loaded, path, nil +} - loaded, err := schema.Load(schemaPath) - if err != nil { - return nil, "", fmt.Errorf("loading discovered schema: %w", err) +// printSchemaWarnings prints non-fatal schema load warnings to stderr. +func printSchemaWarnings(path string, warnings []schema.Warning) { + for _, w := range warnings { + fmt.Fprintf(os.Stderr, "warning: %s: %s at line %d\n", path, w.Message, w.Line) } - return loaded, schemaPath, nil } const ( diff --git a/internal/integration/common_test.go b/internal/integration/common_test.go index 89f02ca..37d9d00 100644 --- a/internal/integration/common_test.go +++ b/internal/integration/common_test.go @@ -25,7 +25,7 @@ func runTestCases(t *testing.T, testCases []TestCase) { for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { // Load schema - s, err := schema.Load(tc.SchemaPath) + s, _, err := schema.Load(tc.SchemaPath) if err != nil { t.Fatalf("Failed to load schema %s: %v", tc.SchemaPath, err) } diff --git a/internal/schema/loader.go b/internal/schema/loader.go index ce9e864..fe72d28 100644 --- a/internal/schema/loader.go +++ b/internal/schema/loader.go @@ -8,24 +8,29 @@ import ( "gopkg.in/yaml.v3" ) -// Load reads and parses a schema file -func Load(path string) (*Schema, error) { +// Load reads and parses a schema file, returning any non-fatal warnings. +func Load(path string) (*Schema, []Warning, error) { return simpleLoadYAML(path) } -// simpleLoadYAML loads a schema from a YAML file -func simpleLoadYAML(path string) (*Schema, error) { +// simpleLoadYAML loads a schema from a YAML file. +func simpleLoadYAML(path string) (*Schema, []Warning, error) { data, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("reading schema file: %w", err) + return nil, nil, fmt.Errorf("reading schema file: %w", err) } var schema Schema if err := yaml.Unmarshal(data, &schema); err != nil { - return nil, fmt.Errorf("parsing schema YAML: %w", err) + return nil, nil, fmt.Errorf("parsing schema YAML: %w", err) } - return &schema, nil + warnings, err := checkUnknownKeys(data) + if err != nil { + return nil, nil, fmt.Errorf("checking schema keys: %w", err) + } + + return &schema, warnings, nil } // FindSchema discovers schema files in the directory hierarchy diff --git a/internal/schema/loader_test.go b/internal/schema/loader_test.go index 97a8fc2..3c57a9f 100644 --- a/internal/schema/loader_test.go +++ b/internal/schema/loader_test.go @@ -19,7 +19,7 @@ func TestLoadValidSchema(t *testing.T) { t.Fatalf("failed to create test file: %v", err) } - schema, err := Load(schemaFile) + schema, _, err := Load(schemaFile) if err != nil { t.Fatalf("Load() error: %v", err) } @@ -56,7 +56,7 @@ func TestLoadSchemaWithRules(t *testing.T) { t.Fatalf("failed to create test file: %v", err) } - schema, err := Load(schemaFile) + schema, _, err := Load(schemaFile) if err != nil { t.Fatalf("Load() error: %v", err) } @@ -93,7 +93,7 @@ func TestLoadSchemaWithChildren(t *testing.T) { t.Fatalf("failed to create test file: %v", err) } - schema, err := Load(schemaFile) + schema, _, err := Load(schemaFile) if err != nil { t.Fatalf("Load() error: %v", err) } @@ -120,14 +120,14 @@ func TestLoadInvalidYAML(t *testing.T) { t.Fatalf("failed to create test file: %v", err) } - _, err := Load(schemaFile) + _, _, err := Load(schemaFile) if err == nil { t.Error("Load() should return error for invalid YAML") } } func TestLoadNonexistentFile(t *testing.T) { - _, err := Load("/nonexistent/path/schema.yml") + _, _, err := Load("/nonexistent/path/schema.yml") if err == nil { t.Error("Load() should return error for nonexistent file") } diff --git a/internal/schema/warnings.go b/internal/schema/warnings.go new file mode 100644 index 0000000..9c56dcb --- /dev/null +++ b/internal/schema/warnings.go @@ -0,0 +1,117 @@ +package schema + +import ( + "fmt" + "reflect" + "strings" + + "gopkg.in/yaml.v3" +) + +// Warning describes a non-fatal issue found while loading a schema. +type Warning struct { + Message string + Line int +} + +// opaqueTypes are leaf types with custom UnmarshalYAML that intentionally +// accept keys (e.g. "regex") not present as struct fields. We do not descend +// into them, to avoid false-positive "unknown key" warnings. +var opaqueTypes = map[reflect.Type]bool{ + reflect.TypeOf(HeadingPattern{}): true, + reflect.TypeOf(RequiredTextPattern{}): true, + reflect.TypeOf(ForbiddenTextPattern{}): true, +} + +// checkUnknownKeys walks the raw YAML node tree in parallel with the Schema +// type graph and reports mapping keys that have no corresponding yaml tag. +func checkUnknownKeys(data []byte) ([]Warning, error) { + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return nil, err + } + var warnings []Warning + if doc.Kind == yaml.DocumentNode && len(doc.Content) > 0 { + walkNode(doc.Content[0], reflect.TypeOf(Schema{}), &warnings) + } + return warnings, nil +} + +func walkNode(node *yaml.Node, t reflect.Type, warnings *[]Warning) { + for t.Kind() == reflect.Ptr { + t = t.Elem() + } + + switch node.Kind { + case yaml.MappingNode: + if t.Kind() != reflect.Struct || opaqueTypes[t] { + return + } + allowed := allowedKeys(t) + // Mapping content alternates key, value, key, value, ... + for i := 0; i+1 < len(node.Content); i += 2 { + keyNode := node.Content[i] + valNode := node.Content[i+1] + ft, ok := allowed[keyNode.Value] + if !ok { + *warnings = append(*warnings, Warning{ + Message: fmt.Sprintf("unknown key %q (ignored)", keyNode.Value), + Line: keyNode.Line, + }) + continue + } + walkNode(valNode, ft, warnings) + } + case yaml.SequenceNode: + if t.Kind() != reflect.Slice && t.Kind() != reflect.Array { + return + } + for _, child := range node.Content { + walkNode(child, t.Elem(), warnings) + } + } +} + +// allowedKeys returns the yaml keys a struct type accepts, mapped to the field +// type. It flattens ,inline embedded structs (following pointers). +func allowedKeys(t reflect.Type) map[string]reflect.Type { + keys := make(map[string]reflect.Type) + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + name, opts := parseYAMLTag(f.Tag.Get("yaml")) + if name == "-" { + continue + } + if hasOpt(opts, "inline") { + ft := f.Type + for ft.Kind() == reflect.Ptr { + ft = ft.Elem() + } + if ft.Kind() == reflect.Struct { + for k, v := range allowedKeys(ft) { + keys[k] = v + } + } + continue + } + if name == "" { + name = strings.ToLower(f.Name) + } + keys[name] = f.Type + } + return keys +} + +func parseYAMLTag(tag string) (name string, opts []string) { + parts := strings.Split(tag, ",") + return parts[0], parts[1:] +} + +func hasOpt(opts []string, want string) bool { + for _, o := range opts { + if o == want { + return true + } + } + return false +} diff --git a/internal/schema/warnings_test.go b/internal/schema/warnings_test.go new file mode 100644 index 0000000..0b7d359 --- /dev/null +++ b/internal/schema/warnings_test.go @@ -0,0 +1,65 @@ +package schema + +import "testing" + +func TestUnknownKeyInStructureElement(t *testing.T) { + data := []byte(`structure: + - heading: "## Overview" + paragraphs: + min: 1 + paragrafs: + min: 1 +`) + warnings, err := checkUnknownKeys(data) + if err != nil { + t.Fatalf("checkUnknownKeys() error: %v", err) + } + if len(warnings) != 1 { + t.Fatalf("expected 1 warning, got %d: %+v", len(warnings), warnings) + } + if got := warnings[0].Message; got != `unknown key "paragrafs" (ignored)` { + t.Errorf("message = %q", got) + } + if warnings[0].Line == 0 { + t.Error("expected a non-zero line number") + } +} + +func TestUnknownKeyAtTopLevel(t *testing.T) { + data := []byte("structure: []\nbogus: true\n") + warnings, err := checkUnknownKeys(data) + if err != nil { + t.Fatalf("error: %v", err) + } + if len(warnings) != 1 || warnings[0].Message != `unknown key "bogus" (ignored)` { + t.Fatalf("got %+v", warnings) + } +} + +func TestKnownKeysNoWarnings(t *testing.T) { + data := []byte(`structure: + - heading: + pattern: "## .*" + regex: true + optional: true + word_count: + min: 1 + required_text: + - pattern: "foo" + children: + - heading: "### Sub" +frontmatter: + fields: + - name: title + type: string +heading_rules: + no_skip_levels: true +`) + warnings, err := checkUnknownKeys(data) + if err != nil { + t.Fatalf("error: %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected 0 warnings, got %+v", warnings) + } +} From 3a80137449c3552337362c8b8c951e3646f56d20 Mon Sep 17 00:00:00 2001 From: jackchuka Date: Fri, 24 Jul 2026 18:06:49 +0900 Subject: [PATCH 5/6] test: add GenerateContent unit test and paragraphs integration fixtures --- internal/integration/paragraph_test.go | 27 +++++++++++++ internal/rules/paragraph_test.go | 45 ++++++++++++++++++++++ testdata/paragraph/.mdschema.yml | 11 ++++++ testdata/paragraph/invalid_no_paragraph.md | 7 ++++ testdata/paragraph/valid_has_paragraph.md | 6 +++ 5 files changed, 96 insertions(+) create mode 100644 internal/integration/paragraph_test.go create mode 100644 testdata/paragraph/.mdschema.yml create mode 100644 testdata/paragraph/invalid_no_paragraph.md create mode 100644 testdata/paragraph/valid_has_paragraph.md diff --git a/internal/integration/paragraph_test.go b/internal/integration/paragraph_test.go new file mode 100644 index 0000000..cd4a28c --- /dev/null +++ b/internal/integration/paragraph_test.go @@ -0,0 +1,27 @@ +package integration + +import "testing" + +// TestParagraphValidation tests paragraph rule validation scenarios +func TestParagraphValidation(t *testing.T) { + testCases := []TestCase{ + // Valid cases + { + Name: "valid section with paragraph", + FilePath: testdataDir + "paragraph/valid_has_paragraph.md", + SchemaPath: testdataDir + "paragraph/.mdschema.yml", + ShouldPass: true, + }, + + // Invalid cases + { + Name: "Overview with only a bullet list, no paragraph", + FilePath: testdataDir + "paragraph/invalid_no_paragraph.md", + SchemaPath: testdataDir + "paragraph/.mdschema.yml", + ShouldPass: false, + ExpectedRule: "paragraph", + }, + } + + runTestCases(t, testCases) +} diff --git a/internal/rules/paragraph_test.go b/internal/rules/paragraph_test.go index 59c52e8..76db58d 100644 --- a/internal/rules/paragraph_test.go +++ b/internal/rules/paragraph_test.go @@ -1,6 +1,7 @@ package rules import ( + "strings" "testing" "github.com/jackchuka/mdschema/internal/parser" @@ -80,3 +81,47 @@ func TestParagraphRuleMaxFails(t *testing.T) { t.Errorf("message = %q, want %q", v[0].Message, want) } } + +func TestParagraphRuleGenerateContent(t *testing.T) { + rule := NewParagraphRule() + var builder strings.Builder + + element := schema.StructureElement{ + Heading: schema.HeadingPattern{Pattern: "## Overview"}, + SectionRules: &schema.SectionRules{ + Paragraphs: &schema.ParagraphRule{Min: 1, Max: 3}, + }, + } + + result := rule.GenerateContent(&builder, element) + + if !result { + t.Error("GenerateContent() should return true when paragraph rules exist") + } + + content := builder.String() + if !strings.Contains(content, "") { + t.Error("Should generate paragraph requirements header") + } + if !strings.Contains(content, "") { + t.Error("Should generate minimum paragraphs comment") + } + if !strings.Contains(content, "") { + t.Error("Should generate maximum paragraphs comment") + } +} + +func TestParagraphRuleGenerateContentNoRules(t *testing.T) { + rule := NewParagraphRule() + var builder strings.Builder + + element := schema.StructureElement{ + Heading: schema.HeadingPattern{Pattern: "## Overview"}, + } + + result := rule.GenerateContent(&builder, element) + + if result { + t.Error("GenerateContent() should return false when no paragraph rules") + } +} diff --git a/testdata/paragraph/.mdschema.yml b/testdata/paragraph/.mdschema.yml new file mode 100644 index 0000000..e25e0bb --- /dev/null +++ b/testdata/paragraph/.mdschema.yml @@ -0,0 +1,11 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/jackchuka/mdschema/main/schema.json +# +# Schema for paragraph rule tests +structure: + - heading: + pattern: "# .*" + optional: true + children: + - heading: "## Overview" + paragraphs: + min: 1 diff --git a/testdata/paragraph/invalid_no_paragraph.md b/testdata/paragraph/invalid_no_paragraph.md new file mode 100644 index 0000000..ec6baf4 --- /dev/null +++ b/testdata/paragraph/invalid_no_paragraph.md @@ -0,0 +1,7 @@ +# My Project + +## Overview + +- First point +- Second point +- Third point diff --git a/testdata/paragraph/valid_has_paragraph.md b/testdata/paragraph/valid_has_paragraph.md new file mode 100644 index 0000000..d2fb932 --- /dev/null +++ b/testdata/paragraph/valid_has_paragraph.md @@ -0,0 +1,6 @@ +# My Project + +## Overview + +This project provides a simple way to validate markdown documents against a +schema, ensuring consistent structure across all documentation. From 7751940bf856ae2c8aaa8880a36a528bfae9afc7 Mon Sep 17 00:00:00 2001 From: jackchuka Date: Fri, 24 Jul 2026 18:56:50 +0900 Subject: [PATCH 6/6] fix(schema): use reflect.Pointer instead of deprecated reflect.Ptr golangci-lint govet flags reflect.Ptr as an alias that should be inlined to reflect.Pointer. --- internal/schema/warnings.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/schema/warnings.go b/internal/schema/warnings.go index 9c56dcb..14c8a02 100644 --- a/internal/schema/warnings.go +++ b/internal/schema/warnings.go @@ -38,7 +38,7 @@ func checkUnknownKeys(data []byte) ([]Warning, error) { } func walkNode(node *yaml.Node, t reflect.Type, warnings *[]Warning) { - for t.Kind() == reflect.Ptr { + for t.Kind() == reflect.Pointer { t = t.Elem() } @@ -84,7 +84,7 @@ func allowedKeys(t reflect.Type) map[string]reflect.Type { } if hasOpt(opts, "inline") { ft := f.Type - for ft.Kind() == reflect.Ptr { + for ft.Kind() == reflect.Pointer { ft = ft.Elem() } if ft.Kind() == reflect.Struct {