From 90caff2ff62ba7e7dbc25a66a487d70ec380b114 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 18:56:13 +0000 Subject: [PATCH 1/7] test: add GitHub Alerts unit tests for MDS034 (plan 87, WIP) https://claude.ai/code/session_018PqzkvcKjWCSc7k9NAh3wm --- internal/rules/markdownflavor/detect_test.go | 30 ++++++++++ internal/rules/markdownflavor/rule_test.go | 63 ++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/internal/rules/markdownflavor/detect_test.go b/internal/rules/markdownflavor/detect_test.go index 3ad4ae862..b4f3dd132 100644 --- a/internal/rules/markdownflavor/detect_test.go +++ b/internal/rules/markdownflavor/detect_test.go @@ -231,3 +231,33 @@ func TestDetectFindingsAreSortedByStart(t *testing.T) { i-1, fs[i-1], i, fs[i]) } } + +func TestDetectGitHubAlerts(t *testing.T) { + tokens := []string{"NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"} + for _, tok := range tokens { + t.Run(tok, func(t *testing.T) { + fs := findings(t, "> [!"+tok+"]\n> Something.\n") + assert.True(t, hasFeature(fs, FeatureGitHubAlerts)) + }) + } +} + +func TestDetectGitHubAlertsLowercaseNoMatch(t *testing.T) { + for _, src := range []string{ + "> [!note]\n> text.\n", + "> [!INFO]\n> text.\n", + } { + fs := findings(t, src) + assert.False(t, hasFeature(fs, FeatureGitHubAlerts), "should not match: %q", src) + } +} + +func TestDetectGitHubAlertsMixedContent(t *testing.T) { + fs := findings(t, "> [!NOTE]\n> Line one.\n> Line two.\n") + assert.True(t, hasFeature(fs, FeatureGitHubAlerts)) +} + +func TestDetectGitHubAlertsOnlyLine(t *testing.T) { + fs := findings(t, "> [!WARNING]\n") + assert.True(t, hasFeature(fs, FeatureGitHubAlerts)) +} diff --git a/internal/rules/markdownflavor/rule_test.go b/internal/rules/markdownflavor/rule_test.go index f83df18d6..4494e4316 100644 --- a/internal/rules/markdownflavor/rule_test.go +++ b/internal/rules/markdownflavor/rule_test.go @@ -218,3 +218,66 @@ func TestRuleDefinitionListsDiagnostic(t *testing.T) { require.Len(t, diags, 1) assert.Equal(t, "definition lists are not supported by gfm", diags[0].Message) } + +func TestRuleCheckGFMAcceptsAlerts(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "gfm"})) + f := mkFile(t, "> [!NOTE]\n> Something.\n") + diags := r.Check(f) + for _, d := range diags { + assert.NotContains(t, d.Message, "github alerts") + } +} + +func TestRuleCheckCommonMarkRejectsAlerts(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "commonmark"})) + f := mkFile(t, "> [!NOTE]\n> Something.\n") + diags := r.Check(f) + found := false + for _, d := range diags { + if d.Message == "github alerts are not supported by commonmark" { + found = true + } + } + assert.True(t, found) +} + +func TestRuleCheckGoldmarkRejectsAlerts(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "goldmark"})) + f := mkFile(t, "> [!NOTE]\n> Something.\n") + diags := r.Check(f) + found := false + for _, d := range diags { + if d.Message == "github alerts are not supported by goldmark" { + found = true + } + } + assert.True(t, found) +} + +func TestRuleFixGitHubAlertsRemovesMarkerLine(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "commonmark"})) + f := mkFile(t, "> [!NOTE]\n> Something to remember.\n") + got := r.Fix(f) + assert.Equal(t, "> Something to remember.\n", string(got)) +} + +func TestRuleFixGitHubAlertsOnlyLine(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "commonmark"})) + f := mkFile(t, "> [!WARNING]\n") + got := r.Fix(f) + assert.Equal(t, "", string(got)) +} + +func TestRuleFixGitHubAlertsGFMNoChange(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "gfm"})) + src := "> [!NOTE]\n> Something.\n" + f := mkFile(t, src) + got := r.Fix(f) + assert.Equal(t, src, string(got)) +} From 6a5bb45354d2b716e5b9bf126117070e15632501 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 19:04:08 +0000 Subject: [PATCH 2/7] feat: add GitHub Alerts detection and fix to MDS034 (plan 87) Extends MDS034 with FeatureGitHubAlerts (feature 13): detects > [!NOTE/TIP/IMPORTANT/WARNING/CAUTION] blockquotes as GFM-only syntax and auto-fixes by removing the marker line. Adds bad/fixed fixtures and updates the README. https://claude.ai/code/session_018PqzkvcKjWCSc7k9NAh3wm --- PLAN.md | 2 +- .../rules/MDS034-markdown-flavor/README.md | 12 ++++- .../bad/commonmark-github-alerts.md | 12 +++++ .../bad/goldmark-github-alerts.md | 12 +++++ .../fixed/commonmark-github-alerts.md | 3 ++ .../fixed/goldmark-github-alerts.md | 3 ++ internal/rules/markdownflavor/detect.go | 45 +++++++++++++++++ internal/rules/markdownflavor/features.go | 7 +++ .../rules/markdownflavor/features_test.go | 7 +-- internal/rules/markdownflavor/rule.go | 48 +++++++++++++++++++ plan/87_markdown-flavor-github-alerts.md | 36 +++++++------- 11 files changed, 163 insertions(+), 24 deletions(-) create mode 100644 internal/rules/MDS034-markdown-flavor/bad/commonmark-github-alerts.md create mode 100644 internal/rules/MDS034-markdown-flavor/bad/goldmark-github-alerts.md create mode 100644 internal/rules/MDS034-markdown-flavor/fixed/commonmark-github-alerts.md create mode 100644 internal/rules/MDS034-markdown-flavor/fixed/goldmark-github-alerts.md diff --git a/PLAN.md b/PLAN.md index 18bd725db..22d8cfcf6 100644 --- a/PLAN.md +++ b/PLAN.md @@ -44,7 +44,7 @@ footer: | | 84 | 🔲 | [Symlink default-deny for file discovery](plan/84_symlink-default-deny.md) | | 85 | 🔳 | [Increase test coverage to 95% by extracting shared rule helpers](plan/85_coverage-to-95-percent.md) | | 86 | 🔳 | [Markdown flavor validation](plan/86_markdown-flavor-validation.md) | -| 87 | 🔲 | [Flavor validation for GitHub Alerts](plan/87_markdown-flavor-github-alerts.md) | +| 87 | ✅ | [Flavor validation for GitHub Alerts](plan/87_markdown-flavor-github-alerts.md) | | 88 | ✅ | [TOC directive migration aid](plan/88_toc-directive-migration.md) | | 89 | 🔲 | [TOC generator directive and MDS035 auto-fix](plan/89_toc-generator-directive.md) | diff --git a/internal/rules/MDS034-markdown-flavor/README.md b/internal/rules/MDS034-markdown-flavor/README.md index ddebb219c..926c6c20e 100644 --- a/internal/rules/MDS034-markdown-flavor/README.md +++ b/internal/rules/MDS034-markdown-flavor/README.md @@ -15,7 +15,7 @@ flavor does not render. - **Name**: `markdown-flavor` - **Status**: ready - **Default**: disabled -- **Fixable**: no (fix pipeline lands in a follow-up) +- **Fixable**: yes - **Implementation**: [source](./) - **Category**: meta @@ -73,7 +73,7 @@ rules: ## Detected features -MDS034 tracks twelve syntax features whose +MDS034 tracks thirteen syntax features whose support varies across Markdown flavors. Eleven features are detected from the goldmark AST @@ -89,6 +89,13 @@ detector scans text nodes from the main parse for URL-shaped text. It skips links, autolinks, code spans, and code blocks. +GitHub Alerts are detected from the CommonMark +AST. The detector matches the five GFM tokens +(`NOTE`, `TIP`, `IMPORTANT`, `WARNING`, +`CAUTION`) on the first line of a blockquote +paragraph. Matching is case-sensitive per the +GFM spec. + `flavor: any` accepts every feature and is omitted from the table below. @@ -106,6 +113,7 @@ from the table below. | math blocks | no | no | no | yes | no | yes | yes | | inline math | no | no | no | yes | no | yes | yes | | abbreviations | no | no | no | no | yes | yes | no | +| github alerts | no | yes | no | no | no | no | no | ## Examples diff --git a/internal/rules/MDS034-markdown-flavor/bad/commonmark-github-alerts.md b/internal/rules/MDS034-markdown-flavor/bad/commonmark-github-alerts.md new file mode 100644 index 000000000..5cb0f06de --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/commonmark-github-alerts.md @@ -0,0 +1,12 @@ +--- +settings: + flavor: commonmark +diagnostics: + - line: 3 + column: 1 + message: "github alerts are not supported by commonmark" +--- +# Alerts + +> [!NOTE] +> Something to remember. diff --git a/internal/rules/MDS034-markdown-flavor/bad/goldmark-github-alerts.md b/internal/rules/MDS034-markdown-flavor/bad/goldmark-github-alerts.md new file mode 100644 index 000000000..3d2089d7c --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/goldmark-github-alerts.md @@ -0,0 +1,12 @@ +--- +settings: + flavor: goldmark +diagnostics: + - line: 3 + column: 1 + message: "github alerts are not supported by goldmark" +--- +# Alerts + +> [!WARNING] +> Something to remember. diff --git a/internal/rules/MDS034-markdown-flavor/fixed/commonmark-github-alerts.md b/internal/rules/MDS034-markdown-flavor/fixed/commonmark-github-alerts.md new file mode 100644 index 000000000..36f77ad3c --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/fixed/commonmark-github-alerts.md @@ -0,0 +1,3 @@ +# Alerts + +> Something to remember. diff --git a/internal/rules/MDS034-markdown-flavor/fixed/goldmark-github-alerts.md b/internal/rules/MDS034-markdown-flavor/fixed/goldmark-github-alerts.md new file mode 100644 index 000000000..36f77ad3c --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/fixed/goldmark-github-alerts.md @@ -0,0 +1,3 @@ +# Alerts + +> Something to remember. diff --git a/internal/rules/markdownflavor/detect.go b/internal/rules/markdownflavor/detect.go index 23acfcaf8..9d664c1ad 100644 --- a/internal/rules/markdownflavor/detect.go +++ b/internal/rules/markdownflavor/detect.go @@ -1,6 +1,7 @@ package markdownflavor import ( + "bytes" "regexp" "sort" @@ -45,6 +46,10 @@ type HeadingIDExtra struct { AttrEnd int // byte offset one past '}' } +// alertTokenRe matches the exact content of a GitHub Alert marker line +// inside a blockquote (case-sensitive per GFM spec). +var alertTokenRe = regexp.MustCompile(`^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*$`) + // bareURLPattern mirrors goldmark's linkify http/https/ftp URL regex // closely enough to catch bare URLs in text. Anchors removed so it can // match anywhere inside a Text segment. The TLD class accepts both @@ -92,6 +97,10 @@ func DetectFiltered(f *lint.File, accept func(Feature) bool) []Finding { out = append(out, detectBareURLs(f)...) } + if keep(FeatureGitHubAlerts) { + out = append(out, detectGitHubAlerts(f)...) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Start < out[j].Start }) @@ -488,3 +497,39 @@ func insideNonBareContext(n ast.Node) bool { } return false } + +// detectGitHubAlerts walks f.AST for Blockquote nodes whose first paragraph +// child starts with a GFM alert token (e.g. [!NOTE]). +func detectGitHubAlerts(f *lint.File) []Finding { + var findings []Finding + _ = ast.Walk(f.AST, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + bq, ok := n.(*ast.Blockquote) + if !ok { + return ast.WalkContinue, nil + } + if isGitHubAlert(bq, f.Source) { + findings = append(findings, blockFinding(f, bq, FeatureGitHubAlerts)) + } + return ast.WalkContinue, nil + }) + return findings +} + +// isGitHubAlert reports whether bq is a GitHub Alert blockquote: its first +// paragraph child's first line matches one of the five GFM alert tokens. +func isGitHubAlert(bq *ast.Blockquote, source []byte) bool { + para, ok := bq.FirstChild().(*ast.Paragraph) + if !ok { + return false + } + lines := para.Lines() + if lines == nil || lines.Len() == 0 { + return false + } + seg := lines.At(0) + firstLine := bytes.TrimRight(source[seg.Start:seg.Stop], "\r\n") + return alertTokenRe.Match(firstLine) +} diff --git a/internal/rules/markdownflavor/features.go b/internal/rules/markdownflavor/features.go index c4d704a7d..ff940361a 100644 --- a/internal/rules/markdownflavor/features.go +++ b/internal/rules/markdownflavor/features.go @@ -108,6 +108,7 @@ const ( FeatureMathBlock FeatureMathInline FeatureAbbreviations + FeatureGitHubAlerts ) // AllFeatures returns every tracked feature in declaration order. @@ -125,6 +126,7 @@ func AllFeatures() []Feature { FeatureMathBlock, FeatureMathInline, FeatureAbbreviations, + FeatureGitHubAlerts, } } @@ -136,6 +138,8 @@ func (f Feature) Verb() string { case FeatureStrikethrough, FeatureSuperscript, FeatureSubscript, FeatureMathInline: return "is" + case FeatureGitHubAlerts: + return "are" } return "are" } @@ -167,6 +171,8 @@ func (f Feature) Name() string { return "inline math" case FeatureAbbreviations: return "abbreviations" + case FeatureGitHubAlerts: + return "github alerts" } return "" } @@ -183,6 +189,7 @@ var support = map[Flavor]map[Feature]bool{ FeatureTaskLists: true, FeatureStrikethrough: true, FeatureBareURLAutolinks: true, + FeatureGitHubAlerts: true, }, FlavorGoldmark: { FeatureTables: true, diff --git a/internal/rules/markdownflavor/features_test.go b/internal/rules/markdownflavor/features_test.go index daad7abf3..b07dd3bd8 100644 --- a/internal/rules/markdownflavor/features_test.go +++ b/internal/rules/markdownflavor/features_test.go @@ -80,7 +80,7 @@ func TestFeatureSupportCommonMark(t *testing.T) { func TestFeatureSupportGFM(t *testing.T) { assertSupports(t, FlavorGFM, FeatureTables, FeatureTaskLists, FeatureStrikethrough, - FeatureBareURLAutolinks) + FeatureBareURLAutolinks, FeatureGitHubAlerts) } func TestFeatureSupportGoldmark(t *testing.T) { @@ -122,8 +122,8 @@ func TestFeatureSupportMyST(t *testing.T) { } func TestAllFeaturesComplete(t *testing.T) { - // Ensure AllFeatures enumerates exactly the 12 features we track. - require.Len(t, AllFeatures(), 12) + // Ensure AllFeatures enumerates exactly the 13 features we track. + require.Len(t, AllFeatures(), 13) } func TestFeatureName(t *testing.T) { @@ -139,4 +139,5 @@ func TestFeatureName(t *testing.T) { assert.Equal(t, "math blocks", FeatureMathBlock.Name()) assert.Equal(t, "inline math", FeatureMathInline.Name()) assert.Equal(t, "abbreviations", FeatureAbbreviations.Name()) + assert.Equal(t, "github alerts", FeatureGitHubAlerts.Name()) } diff --git a/internal/rules/markdownflavor/rule.go b/internal/rules/markdownflavor/rule.go index 7b0bb343f..c0f30a861 100644 --- a/internal/rules/markdownflavor/rule.go +++ b/internal/rules/markdownflavor/rule.go @@ -2,6 +2,9 @@ package markdownflavor import ( "fmt" + "strings" + + "github.com/yuin/goldmark/ast" "github.com/jeduden/mdsmith/internal/lint" "github.com/jeduden/mdsmith/internal/rule" @@ -92,7 +95,52 @@ func (r *Rule) Check(f *lint.File) []lint.Diagnostic { return diags } +// Fix implements rule.FixableRule. It removes the [!TOKEN] marker line from +// GitHub Alert blockquotes when the configured flavor does not support them. +// If the marker is the only line in the blockquote, the whole blockquote is +// removed. +func (r *Rule) Fix(f *lint.File) []byte { + if r.Flavor == 0 || r.Flavor.Supports(FeatureGitHubAlerts) { + return f.Source + } + + skip := map[int]bool{} + _ = ast.Walk(f.AST, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + bq, ok := n.(*ast.Blockquote) + if !ok { + return ast.WalkContinue, nil + } + if !isGitHubAlert(bq, f.Source) { + return ast.WalkContinue, nil + } + para := bq.FirstChild().(*ast.Paragraph) + lines := para.Lines() + seg := lines.At(0) + markerLine, _ := lineCol(f.Source, seg.Start) + skip[markerLine] = true + return ast.WalkContinue, nil + }) + + if len(skip) == 0 { + return f.Source + } + + var out []string + for i, line := range f.Lines { + lineNum := i + 1 + if skip[lineNum] { + continue + } + out = append(out, string(line)) + } + return []byte(strings.Join(out, "\n")) +} + var ( _ rule.Configurable = (*Rule)(nil) _ rule.Defaultable = (*Rule)(nil) + _ rule.FixableRule = (*Rule)(nil) ) diff --git a/plan/87_markdown-flavor-github-alerts.md b/plan/87_markdown-flavor-github-alerts.md index 7fd120f26..caa9419e5 100644 --- a/plan/87_markdown-flavor-github-alerts.md +++ b/plan/87_markdown-flavor-github-alerts.md @@ -1,7 +1,7 @@ --- id: 87 title: Flavor validation for GitHub Alerts -status: "🔲" +status: "✅" summary: >- Extend MDS034 to detect GitHub Alerts syntax (`> [!NOTE]` blockquote prefix) as a GFM-only @@ -115,41 +115,41 @@ features. ## Tasks -1. Add `GitHubAlerts` to the feature enum in +1. [x] Add `GitHubAlerts` to the feature enum in `internal/rules/markdownflavor/features.go` -2. Add flavor support table entry: supported in +2. [x] Add flavor support table entry: supported in `gfm`, unsupported in `commonmark` and `goldmark` -3. Implement an AST detector that walks +3. [x] Implement an AST detector that walks `ast.Blockquote` nodes and matches the five GFM tokens on the first paragraph child -4. Implement the fix: strip the marker line; +4. [x] Implement the fix: strip the marker line; drop the blockquote if empty afterward -5. Add unit tests: each of the five tokens, +5. [x] Add unit tests: each of the five tokens, lower-case tokens (should not match), mixed content after the marker, marker as the only line -6. Add good/bad fixtures under - `internal/rules/MDS034-markdown-flavor/alerts/` -7. Update the MDS034 README to list GitHub +6. [x] Add bad/fixed fixtures under + `internal/rules/MDS034-markdown-flavor/` +7. [x] Update the MDS034 README to list GitHub Alerts as the 13th feature ## Acceptance Criteria -- [ ] `flavor: commonmark` flags all five alert +- [x] `flavor: commonmark` flags all five alert tokens -- [ ] `flavor: goldmark` flags all five alert +- [x] `flavor: goldmark` flags all five alert tokens -- [ ] `flavor: gfm` accepts all five tokens -- [ ] `mdsmith fix` removes the marker line, +- [x] `flavor: gfm` accepts all five tokens +- [x] `mdsmith fix` removes the marker line, preserves remaining blockquote content -- [ ] `mdsmith fix` removes the whole blockquote +- [x] `mdsmith fix` removes the whole blockquote when the marker was its only line -- [ ] Lower-case or unknown tokens (e.g. +- [x] Lower-case or unknown tokens (e.g. `[!note]`, `[!INFO]`) produce no diagnostic — they are ordinary blockquote text -- [ ] Nested blockquotes are checked recursively -- [ ] All tests pass: `go test ./...` -- [ ] `go tool golangci-lint run` reports no +- [x] Nested blockquotes are checked recursively +- [x] All tests pass: `go test ./...` +- [x] `go tool golangci-lint run` reports no issues From 1e9021fd1a0bac245e672a46d0e6c3653fb52f3f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 21:13:19 +0000 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20use=20flavorInvalid=20constant=20and=20correct=20fe?= =?UTF-8?q?ature=20count=20in=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/rules/markdownflavor/rule.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/rules/markdownflavor/rule.go b/internal/rules/markdownflavor/rule.go index c0f30a861..3da10f204 100644 --- a/internal/rules/markdownflavor/rule.go +++ b/internal/rules/markdownflavor/rule.go @@ -42,7 +42,7 @@ func (r *Rule) ApplySettings(settings map[string]any) error { return fmt.Errorf("markdown-flavor: flavor must be a string, got %T", v) } if s == "" { - r.Flavor = 0 + r.Flavor = flavorInvalid continue } fl, ok := ParseFlavor(s) @@ -70,7 +70,7 @@ func (r *Rule) DefaultSettings() map[string]any { // Check implements rule.Rule. func (r *Rule) Check(f *lint.File) []lint.Diagnostic { - if r.Flavor == 0 { + if r.Flavor == flavorInvalid { return nil } // Only ask detectors about features this flavor rejects. Detectors @@ -100,7 +100,7 @@ func (r *Rule) Check(f *lint.File) []lint.Diagnostic { // If the marker is the only line in the blockquote, the whole blockquote is // removed. func (r *Rule) Fix(f *lint.File) []byte { - if r.Flavor == 0 || r.Flavor.Supports(FeatureGitHubAlerts) { + if r.Flavor == flavorInvalid || r.Flavor.Supports(FeatureGitHubAlerts) { return f.Source } From 4ed7236d8937fa147788b1d7534ca412f2be1999 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 15:36:20 +0000 Subject: [PATCH 4/7] fix: handle lazy-continuation lines when removing GitHub Alert marker --- internal/rules/markdownflavor/rule.go | 19 ++++++++++++++++++- internal/rules/markdownflavor/rule_test.go | 11 +++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/internal/rules/markdownflavor/rule.go b/internal/rules/markdownflavor/rule.go index 3da10f204..d1b1fe78a 100644 --- a/internal/rules/markdownflavor/rule.go +++ b/internal/rules/markdownflavor/rule.go @@ -105,6 +105,7 @@ func (r *Rule) Fix(f *lint.File) []byte { } skip := map[int]bool{} + addPrefix := map[int]bool{} // lazy-continuation lines that lose blockquote context _ = ast.Walk(f.AST, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil @@ -121,6 +122,18 @@ func (r *Rule) Fix(f *lint.File) []byte { seg := lines.At(0) markerLine, _ := lineCol(f.Source, seg.Start) skip[markerLine] = true + + // Remaining lines of the first paragraph may use lazy continuation + // (no "> " prefix in the raw source). After removing the marker they + // would no longer be inside a blockquote, so re-add the prefix. + for i := 1; i < lines.Len(); i++ { + contSeg := lines.At(i) + contLine, _ := lineCol(f.Source, contSeg.Start) + raw := strings.TrimLeft(string(f.Lines[contLine-1]), " \t") + if !strings.HasPrefix(raw, ">") { + addPrefix[contLine] = true + } + } return ast.WalkContinue, nil }) @@ -134,7 +147,11 @@ func (r *Rule) Fix(f *lint.File) []byte { if skip[lineNum] { continue } - out = append(out, string(line)) + s := string(line) + if addPrefix[lineNum] { + s = "> " + s + } + out = append(out, s) } return []byte(strings.Join(out, "\n")) } diff --git a/internal/rules/markdownflavor/rule_test.go b/internal/rules/markdownflavor/rule_test.go index 4494e4316..c436c83d7 100644 --- a/internal/rules/markdownflavor/rule_test.go +++ b/internal/rules/markdownflavor/rule_test.go @@ -281,3 +281,14 @@ func TestRuleFixGitHubAlertsGFMNoChange(t *testing.T) { got := r.Fix(f) assert.Equal(t, src, string(got)) } + +func TestRuleFixGitHubAlertsLazyContinuation(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "commonmark"})) + // Lazy continuation: second line has no "> " prefix but is still + // part of the blockquote paragraph. Fix must add it so the line + // stays inside a blockquote after the marker is removed. + f := mkFile(t, "> [!NOTE]\nlazy content\n") + got := r.Fix(f) + assert.Equal(t, "> lazy content\n", string(got)) +} From 3081479a832fa0696422401c53acf4f2263d5f3b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 06:06:34 +0000 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20coverage,=20indentation,=20and=20nested=20alert=20t?= =?UTF-8?q?ests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unreachable defensive nil/empty-lines check from isGitHubAlert (goldmark Paragraphs always have at least one line) - Fix lazy-continuation prefix: preserve leading whitespace before "> " so indented blockquotes (e.g. inside a list) stay correctly nested - Add TestRuleCheckNestedAlert and TestRuleFixNestedAlert: nested alert ("> > [!NOTE]") is detected and its marker stripped by Fix - Add TestRuleFixIndentedLazyContinuation: verifies the indentation fix - Add TestRuleFixNoAlerts and TestRuleFixHeadingBlockquote for Fix edge cases (no alerts in doc; non-paragraph first child in blockquote) https://claude.ai/code/session_018PqzkvcKjWCSc7k9NAh3wm --- internal/rules/markdownflavor/detect.go | 6 +-- internal/rules/markdownflavor/rule.go | 3 +- internal/rules/markdownflavor/rule_test.go | 56 ++++++++++++++++++++++ 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/internal/rules/markdownflavor/detect.go b/internal/rules/markdownflavor/detect.go index 9d664c1ad..ca811d268 100644 --- a/internal/rules/markdownflavor/detect.go +++ b/internal/rules/markdownflavor/detect.go @@ -525,11 +525,7 @@ func isGitHubAlert(bq *ast.Blockquote, source []byte) bool { if !ok { return false } - lines := para.Lines() - if lines == nil || lines.Len() == 0 { - return false - } - seg := lines.At(0) + seg := para.Lines().At(0) firstLine := bytes.TrimRight(source[seg.Start:seg.Stop], "\r\n") return alertTokenRe.Match(firstLine) } diff --git a/internal/rules/markdownflavor/rule.go b/internal/rules/markdownflavor/rule.go index d1b1fe78a..6e9cf92bd 100644 --- a/internal/rules/markdownflavor/rule.go +++ b/internal/rules/markdownflavor/rule.go @@ -149,7 +149,8 @@ func (r *Rule) Fix(f *lint.File) []byte { } s := string(line) if addPrefix[lineNum] { - s = "> " + s + trimmed := strings.TrimLeft(s, " \t") + s = s[:len(s)-len(trimmed)] + "> " + trimmed } out = append(out, s) } diff --git a/internal/rules/markdownflavor/rule_test.go b/internal/rules/markdownflavor/rule_test.go index c436c83d7..af7f5eebf 100644 --- a/internal/rules/markdownflavor/rule_test.go +++ b/internal/rules/markdownflavor/rule_test.go @@ -292,3 +292,59 @@ func TestRuleFixGitHubAlertsLazyContinuation(t *testing.T) { got := r.Fix(f) assert.Equal(t, "> lazy content\n", string(got)) } + +func TestRuleFixNoAlerts(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "commonmark"})) + // A plain blockquote has no alert marker — Fix must return the source unchanged. + src := "> regular blockquote\n" + got := r.Fix(mkFile(t, src)) + assert.Equal(t, src, string(got)) +} + +func TestRuleFixHeadingBlockquote(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "commonmark"})) + // A blockquote containing a heading has a non-Paragraph first child; + // Fix must treat it as a non-alert and return the source unchanged. + src := "> # Heading\n" + got := r.Fix(mkFile(t, src)) + assert.Equal(t, src, string(got)) +} + +func TestRuleCheckNestedAlert(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "commonmark"})) + // A GitHub Alert nested inside an outer blockquote is detected because + // the walker recurses into all blockquote nodes. + f := mkFile(t, "> > [!NOTE]\n> > Something.\n") + diags := r.Check(f) + found := false + for _, d := range diags { + if d.Message == "github alerts are not supported by commonmark" { + found = true + } + } + assert.True(t, found, "nested alert should be detected") +} + +func TestRuleFixNestedAlert(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "commonmark"})) + // Fix removes the [!NOTE] marker from a nested alert; the outer + // blockquote's non-Paragraph first child exercises the !ok guard in + // isGitHubAlert, confirming it does not misidentify the outer bq. + f := mkFile(t, "> > [!NOTE]\n> > nested content.\n") + got := r.Fix(f) + assert.Equal(t, "> > nested content.\n", string(got)) +} + +func TestRuleFixIndentedLazyContinuation(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "commonmark"})) + // Lazy continuation inside an indented blockquote: the continuation + // line's leading spaces must be preserved before the re-added "> ". + f := mkFile(t, " > [!NOTE]\n lazy content\n") + got := r.Fix(f) + assert.Equal(t, " > lazy content\n", string(got)) +} From 98e7d7683503c3225e197e680ee1c9bb525e9614 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 06:08:18 +0000 Subject: [PATCH 6/7] chore: ignore cover.out coverage profile in gitignore https://claude.ai/code/session_018PqzkvcKjWCSc7k9NAh3wm --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a2c04493e..79bcf567f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /mdsmith eval/corpus/config.local.yml coverage.out +cover.out From 62670fc43640fc3703df16358668397ef5603776 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 06:15:48 +0000 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20fixable=20scope=20and=20stricter=20GFM=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: change "Fixable: yes" to "partially (GitHub Alerts only)" since Fix() only removes alert markers, not other unsupported syntax - TestRuleCheckGFMAcceptsAlerts: use require.Empty instead of a loop so the assertion truly validates GFM accepts the alert with zero diags https://claude.ai/code/session_018PqzkvcKjWCSc7k9NAh3wm --- internal/rules/MDS034-markdown-flavor/README.md | 2 +- internal/rules/markdownflavor/rule_test.go | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/rules/MDS034-markdown-flavor/README.md b/internal/rules/MDS034-markdown-flavor/README.md index 926c6c20e..b31e7e786 100644 --- a/internal/rules/MDS034-markdown-flavor/README.md +++ b/internal/rules/MDS034-markdown-flavor/README.md @@ -15,7 +15,7 @@ flavor does not render. - **Name**: `markdown-flavor` - **Status**: ready - **Default**: disabled -- **Fixable**: yes +- **Fixable**: partially (GitHub Alerts only) - **Implementation**: [source](./) - **Category**: meta diff --git a/internal/rules/markdownflavor/rule_test.go b/internal/rules/markdownflavor/rule_test.go index af7f5eebf..f3bc8604d 100644 --- a/internal/rules/markdownflavor/rule_test.go +++ b/internal/rules/markdownflavor/rule_test.go @@ -224,9 +224,7 @@ func TestRuleCheckGFMAcceptsAlerts(t *testing.T) { require.NoError(t, r.ApplySettings(map[string]any{"flavor": "gfm"})) f := mkFile(t, "> [!NOTE]\n> Something.\n") diags := r.Check(f) - for _, d := range diags { - assert.NotContains(t, d.Message, "github alerts") - } + require.Empty(t, diags) } func TestRuleCheckCommonMarkRejectsAlerts(t *testing.T) {