diff --git a/README.md b/README.md index b5f2e9c2..91029dec 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ DMT includes **9 specialized linters** to validate different aspects of your Dec | Linter | Purpose | Key Checks | |--------|---------|------------| | [**Container**](pkg/linters/container/README.md) | Container configuration validation | Duplicate names, env vars, security contexts, probes, resource limits | -| [**Documentation**](pkg/linters/docs/README.md) | Documentation quality | README presence, bilingual support, no cyrillic in English docs | +| [**Documentation**](pkg/linters/docs/README.md) | Documentation quality | README presence, bilingual support, no cyrillic in English docs, markdown style | | [**Hooks**](pkg/linters/hooks/README.md) | Hook validation | Hook syntax, ingress configurations | | [**Images**](pkg/linters/images/README.md) | Image build instructions | Dockerfile best practices, werf configuration | | [**Module**](pkg/linters/module/README.md) | Module structure | module.yaml format, OpenAPI conversions, oss.yaml, license files | diff --git a/go.mod b/go.mod index c6dc1389..57ac6365 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/bmatcuk/doublestar v1.3.4 github.com/deckhouse/deckhouse/pkg/log v0.2.1 github.com/fatih/color v1.19.0 + github.com/fuldaxxx/go-markdownlint v0.0.0-20260626082153-d77e827f85b0 github.com/go-openapi/spec v0.22.4 github.com/gobwas/glob v0.2.3 github.com/gogo/protobuf v1.3.2 diff --git a/go.sum b/go.sum index ed6b3716..bf61b217 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,7 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.3.2 h1:kYRSnvJju5gYVyhkij+RTJ/VR6QIUaCfWeaFm2ycsjQ= github.com/AzureAD/microsoft-authentication-library-for-go v1.3.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/Code-Hex/go-generics-cache v1.5.1 h1:6vhZGc5M7Y/YD8cIUcY8kcuQLB4cHR7U+0KMqAA0KcU= @@ -99,6 +100,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fuldaxxx/go-markdownlint v0.0.0-20260626082153-d77e827f85b0 h1:143MJp2qaqsTQajO5alStfx8rSaJlimNS+AVROx3Y2o= +github.com/fuldaxxx/go-markdownlint v0.0.0-20260626082153-d77e827f85b0/go.mod h1:cXFi4L+kE9juW9qj8GPyctu5kNvDbiq3FX8ckpX9Su0= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= diff --git a/internal/module/module.go b/internal/module/module.go index 188dd86e..446836ed 100644 --- a/internal/module/module.go +++ b/internal/module/module.go @@ -308,6 +308,7 @@ func mapDocumentationRules(linterSettings *pkg.LintersSettings, configSettings * rules.ReadmeRule.SetLevel(globalRules.ReadmeRule.Impact, fallbackImpact) rules.CyrillicInEnglishRule.SetLevel(globalRules.NoCyrillicExcludeRules.Impact, fallbackImpact) rules.NoLangKeyRule.SetLevel(globalRules.NoLangKeyRule.Impact, fallbackImpact) + rules.MarkdownlintRule.SetLevel(globalRules.MarkdownlintRule.Impact, fallbackImpact) } func mapModuleRules(linterSettings *pkg.LintersSettings, configSettings *config.LintersSettings, globalConfig *global.Linters) { diff --git a/pkg/config.go b/pkg/config.go index bf828295..dfa6361e 100644 --- a/pkg/config.go +++ b/pkg/config.go @@ -87,6 +87,7 @@ type DocumentationLinterRules struct { BilingualRule RuleConfig CyrillicInEnglishRule RuleConfig NoLangKeyRule RuleConfig + MarkdownlintRule RuleConfig } type NoCyrillicLinterConfig struct { diff --git a/pkg/config/global/global.go b/pkg/config/global/global.go index d6c969a9..0540a7f4 100644 --- a/pkg/config/global/global.go +++ b/pkg/config/global/global.go @@ -95,6 +95,7 @@ type DocumentationRules struct { ReadmeRule RuleConfig `mapstructure:"readme"` NoCyrillicExcludeRules RuleConfig `mapstructure:"cyrillic-in-english"` NoLangKeyRule RuleConfig `mapstructure:"no-lang-key"` + MarkdownlintRule RuleConfig `mapstructure:"markdownlint"` } type OpenAPILinterConfig struct { diff --git a/pkg/linters/docs/README.md b/pkg/linters/docs/README.md index c2ff6b59..6015b3e9 100644 --- a/pkg/linters/docs/README.md +++ b/pkg/linters/docs/README.md @@ -2,7 +2,7 @@ ## Overview -The **Documentation Linter** validates module documentation to ensure proper structure, completeness, and language consistency. This linter enforces bilingual documentation requirements, checks for documentation file presence, and validates that English documentation doesn't contain cyrillic characters. +The **Documentation Linter** validates module documentation to ensure proper structure, completeness, and language consistency. This linter enforces bilingual documentation requirements, checks for documentation file presence, validates that English documentation doesn't contain cyrillic characters, and ensures markdown files follow deckhouse markdown style conventions. Proper documentation is critical for Deckhouse modules as it helps users understand module features, configuration options, and usage patterns. The linter ensures documentation meets quality standards and is accessible to both English and Russian-speaking audiences. @@ -14,6 +14,7 @@ Proper documentation is critical for Deckhouse modules as it helps users underst | [bilingual](#bilingual) | Validates documentation exists in both English and Russian | ✅ | enabled | | [cyrillic-in-english](#cyrillic-in-english) | Validates English documentation doesn't contain cyrillic characters | ✅ | enabled | | [no-lang-key](#no-lang-key) | Validates documentation front matter doesn't contain `lang` key | ✅ | enabled | +| [markdownlint](#markdownlint) | Validates markdown files in docs/ follow deckhouse markdown style | ✅ | enabled | ## Rule Details @@ -390,6 +391,72 @@ linters-settings: --- +### markdownlint + +**Purpose:** Ensures markdown files in the `docs/` directory follow consistent deckhouse markdown style conventions (headings, lists, code blocks, etc.). + +**Description:** + +This rule runs the [go-markdownlint](https://github.com/ldmonster/go-markdownlint) library against every `.md` file under `docs/` (recursively, including `docs/internal/...`) and reports any markdown style violations. The built-in rule set is enabled by default; only a fixed set of deckhouse-specific overrides is applied (line-length limits, blanks-around-headings, duplicate-heading siblings, etc.). + +**What it checks:** + +1. Recursively scans all `.md` files under `docs/` (top-level and nested, e.g. `docs/internal/`) +2. Lints each file with the built-in markdownlint rules using the deckhouse configuration overrides +3. Reports the rule name(s), description, file path and line number for each violation + +**Why it matters:** + +Consistent markdown style across all modules makes the documentation easier to read, review and maintain, and keeps it aligned with the rest of the deckhouse documentation. + +**Examples:** + +❌ **Incorrect** - Duplicate top-level heading (MD025) and missing trailing newline (MD047): + +```markdown + +# My Module + +# My Module +``` +``` + +(file has no trailing newline) + +**Error:** +``` +MD025/single-title/single-h1 Multiple top-level headings in the same document +File: docs/README.md +Line: 3 + +MD047/single-trailing-newline Files should end with a single newline character +File: docs/README.md +Line: 3 +``` + +✅ **Correct** - Single top-level heading and trailing newline: + +```markdown + +# My Module +``` + +**Configuration:** + +To downgrade or disable this rule: + +```yaml +# .dmt.yaml +linters-settings: + documentation: + rules: + markdownlint: + impact: warn # report findings but don't fail the run + # impact: ignored # disable the rule entirely +``` + +--- + ## Configuration The Documentation linter can be configured at both the module level and for individual rules. diff --git a/pkg/linters/docs/documentation.go b/pkg/linters/docs/documentation.go index e05bb1c3..45b71645 100644 --- a/pkg/linters/docs/documentation.go +++ b/pkg/linters/docs/documentation.go @@ -44,6 +44,8 @@ func (l *Documentation) Run(m *module.Module) { rules.NewCyrillicInEnglishRule().CheckFiles(m, errorList.WithMaxLevel(l.cfg.Rules.CyrillicInEnglishRule.GetLevel())) rules.NewNoLangKeyRule().CheckFiles(m, errorList.WithMaxLevel(l.cfg.Rules.NoLangKeyRule.GetLevel())) + + rules.NewMarkdownRule().CheckFiles(m, errorList.WithMaxLevel(l.cfg.Rules.MarkdownlintRule.GetLevel())) } func (l *Documentation) Name() string { diff --git a/pkg/linters/docs/rules/markdown.go b/pkg/linters/docs/rules/markdown.go new file mode 100644 index 00000000..b0a16beb --- /dev/null +++ b/pkg/linters/docs/rules/markdown.go @@ -0,0 +1,206 @@ +// Copyright 2026 Flant JSC +// Licensed under the Apache License, Version 2.0 + +package rules + +import ( + "context" + "os" + "path/filepath" + "strings" + + gomarkdownlint "github.com/fuldaxxx/go-markdownlint" + + "github.com/deckhouse/dmt/internal/fsutils" + "github.com/deckhouse/dmt/pkg" + "github.com/deckhouse/dmt/pkg/errors" +) + +const ( + MarkdownlintRuleName = "markdownlint" +) + +func NewMarkdownRule() *MarkdownRule { + return &MarkdownRule{ + RuleMeta: pkg.RuleMeta{ + Name: MarkdownlintRuleName, + }, + } +} + +type MarkdownRule struct { + pkg.RuleMeta + pkg.PathRule +} + +func (r *MarkdownRule) CheckFiles(m pkg.Module, errorList *errors.LintRuleErrorsList) { + errorList = errorList.WithRule(r.GetName()) + + if !r.Enabled(m.GetName()) { + return + } + + modulePath := m.GetPath() + if modulePath == "" { + return + } + + docsPath := filepath.Join(modulePath, "docs") + if _, err := os.Stat(docsPath); err != nil { + return + } + + files := fsutils.GetFiles(docsPath, false, fsutils.FilterFileByExtensions(".md")) + + var mdFiles []string + + for _, fileName := range files { + relFromModule := fsutils.Rel(modulePath, fileName) + if !r.Enabled(relFromModule) { + continue + } + + mdFiles = append(mdFiles, fileName) + } + + r.checkFiles(modulePath, mdFiles, errorList) +} + +func (r *MarkdownRule) checkFiles(modulePath string, files []string, errorList *errors.LintRuleErrorsList) { + if len(files) == 0 { + return + } + + cfg := gomarkdownlint.ConfigFromMap(deckhouseMarkdownlintConfig()) + + results, err := gomarkdownlint.LintFiles(context.Background(), files, cfg) + if err != nil { + errorList. + WithFilePath(modulePath). + WithValue(err.Error()). + Errorf("markdownlint failed: %s", err) + + return + } + + for file, errs := range results { + relPath := fsutils.Rel(modulePath, file) + for _, mdErr := range errs { + errorList. + WithFilePath(relPath). + WithLineNumber(mdErr.LineNumber). + Errorf("%s %s", strings.Join(mdErr.RuleNames, "/"), mdErr.RuleDescription) + } + } +} + +// deckhouseMarkdownlintConfig returns the markdownlint configuration. +// go-markdownlint enables every built-in rule by default (ruleDefaultEnable is +// true when the "default" key is absent), so we do not set "default" and only +// list the rule overrides below. +func deckhouseMarkdownlintConfig() map[string]any { + return map[string]any{ + // MD002/first-heading-h1/first-header-h1 - First heading should be a top-level heading (deprecated) + "MD002": false, + + // MD004/ul-style - Unordered list style + "MD004": false, + + // MD013/line-length - Line length + "MD013": map[string]any{ + "line_length": 1000, // Number of characters + "heading_line_length": 128, // Number of characters for headings + "code_block_line_length": 400, // Number of characters for code blocks + "code_blocks": true, // Include code blocks + "tables": true, // Include tables + "headings": true, // Include headings + "headers": true, // Include headings (deprecated alias) + "strict": false, // Strict length checking + "stern": false, // Stern length checking + }, + + // MD053/link-image-reference-definitions - Link and image reference + // definitions should be needed. + "MD053": false, + + // MD059/descriptive-link-text - Link text should be descriptive. + "MD059": false, + + // MD060/table-column-style - Table column style. + "MD060": false, + + // MD022/blanks-around-headings/blanks-around-headers - Headings should be surrounded by blank lines + "MD022": map[string]any{ + "lines_above": 1, // Blank lines above heading + "lines_below": 1, // Blank lines below heading + }, + + // MD024/no-duplicate-heading/no-duplicate-header - Multiple headings with the same content + "MD024": map[string]any{ + "siblings_only": true, // Only check sibling headings + }, + + // MD026/no-trailing-punctuation - Trailing punctuation in heading + "MD026": map[string]any{ + "punctuation": ".,;:!。,;:!", // Punctuation characters + }, + + // MD029/ol-prefix - Ordered list item prefix + "MD029": map[string]any{ + "style": "one_or_ordered", // List style + }, + + // MD033/no-inline-html - Inline HTML + "MD033": false, + + // MD032/blanks-around-lists - Lists should be surrounded by blank lines + "MD032": false, + + // MD041/first-line-heading/first-line-h1 - First line in a file should be a top-level heading + "MD041": map[string]any{ + "level": 1, // Heading level + "front_matter_title": `^\s*title\s*[:=]`, // RegExp for matching title in front matter + }, + + // MD042/no-empty-links - No empty links + "MD042": true, + + // MD043/required-headings/required-headers - Required heading structure + "MD043": map[string]any{ + "headings": nil, // List of headings + "headers": nil, // List of headings (deprecated alias) + }, + + // MD044/proper-names - Proper names should have the correct capitalization + "MD044": map[string]any{ + "names": []string{}, // List of proper names + "code_blocks": true, // Include code blocks + }, + + // MD045/no-alt-text - Images should have alternate text (alt text) + "MD045": true, + + // MD046/code-block-style - Code block style + "MD046": map[string]any{ + "style": "consistent", // Block style + }, + + // MD047/single-trailing-newline - Files should end with a single newline character + "MD047": true, + + // MD048/code-fence-style - Code fence style + "MD048": map[string]any{ + "style": "consistent", // Code fence style + }, + + // MD049/emphasis-style - Emphasis style should be consistent + "MD049": map[string]any{ + "style": "consistent", // Emphasis style should be consistent + }, + + // MD050/strong-style - Strong style should be consistent + "MD050": map[string]any{ + "style": "consistent", // Strong style should be consistent + }, + } +} diff --git a/test/e2e/testdata/documentation/markdownlint-violations/expected.yaml b/test/e2e/testdata/documentation/markdownlint-violations/expected.yaml new file mode 100644 index 00000000..7fc16168 --- /dev/null +++ b/test/e2e/testdata/documentation/markdownlint-violations/expected.yaml @@ -0,0 +1,11 @@ +description: >- + A module whose docs/CONFIGURATION.md has markdownlint violations: a duplicate + top-level heading (MD025) and a missing trailing newline (MD047). The + documentation linter's markdownlint rule must report both. +expect: + - linter: documentation + rule: markdownlint + textContains: "MD025/single-title/single-h1" + - linter: documentation + rule: markdownlint + textContains: "MD047/single-trailing-newline" \ No newline at end of file diff --git a/test/e2e/testdata/documentation/markdownlint-violations/module/docs/CONFIGURATION.md b/test/e2e/testdata/documentation/markdownlint-violations/module/docs/CONFIGURATION.md new file mode 100644 index 00000000..c26c11d1 --- /dev/null +++ b/test/e2e/testdata/documentation/markdownlint-violations/module/docs/CONFIGURATION.md @@ -0,0 +1,2 @@ +# Markdownlint Violations +# Markdownlint Violations \ No newline at end of file diff --git a/test/e2e/testdata/documentation/markdownlint-violations/module/module.yaml b/test/e2e/testdata/documentation/markdownlint-violations/module/module.yaml new file mode 100644 index 00000000..1395f4e3 --- /dev/null +++ b/test/e2e/testdata/documentation/markdownlint-violations/module/module.yaml @@ -0,0 +1,3 @@ +name: test-module-markdownlint-violations +namespace: d8-test +weight: 100 \ No newline at end of file diff --git a/test/e2e/testdata/documentation/markdownlint-violations/module/openapi/config-values.yaml b/test/e2e/testdata/documentation/markdownlint-violations/module/openapi/config-values.yaml new file mode 100644 index 00000000..4ee2fb1f --- /dev/null +++ b/test/e2e/testdata/documentation/markdownlint-violations/module/openapi/config-values.yaml @@ -0,0 +1,2 @@ +type: object +properties: {} \ No newline at end of file diff --git a/test/e2e/testdata/documentation/markdownlint-violations/module/openapi/values.yaml b/test/e2e/testdata/documentation/markdownlint-violations/module/openapi/values.yaml new file mode 100644 index 00000000..4ee2fb1f --- /dev/null +++ b/test/e2e/testdata/documentation/markdownlint-violations/module/openapi/values.yaml @@ -0,0 +1,2 @@ +type: object +properties: {} \ No newline at end of file