From ab5603c533d22483d4af2f47691de0c930b4721b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 19:29:46 +0000 Subject: [PATCH 01/30] Plan 86: flip status to in-progress Start work on MDS034 markdown flavor validation. --- PLAN.md | 2 +- plan/86_markdown-flavor-validation.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/PLAN.md b/PLAN.md index 93b6d98a3..18bd725db 100644 --- a/PLAN.md +++ b/PLAN.md @@ -43,7 +43,7 @@ footer: | | 83 | 🔳 | [Security hardening batch](plan/83_security-hardening-batch.md) | | 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) | +| 86 | 🔳 | [Markdown flavor validation](plan/86_markdown-flavor-validation.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/plan/86_markdown-flavor-validation.md b/plan/86_markdown-flavor-validation.md index 7acae9c01..2217e5089 100644 --- a/plan/86_markdown-flavor-validation.md +++ b/plan/86_markdown-flavor-validation.md @@ -1,7 +1,7 @@ --- id: 86 title: Markdown flavor validation -status: "🔲" +status: "🔳" summary: >- New rule MDS034 that validates Markdown files against a declared flavor (CommonMark, GFM, Goldmark, etc.) From 5284bd5c92f5cf2c43c539e4e569fbeff3286920 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 19:34:49 +0000 Subject: [PATCH 02/30] Plan 86: add flavor and feature enums for MDS034 First TDD slice of MDS034 (markdown-flavor): define the Flavor and Feature enums, the support table that maps (flavor, feature) to allowed or not, and lookup helpers with coverage for all three supported flavors (commonmark, gfm, goldmark). --- internal/rules/markdownflavor/features.go | 139 ++++++++++++++++++ .../rules/markdownflavor/features_test.go | 91 ++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 internal/rules/markdownflavor/features.go create mode 100644 internal/rules/markdownflavor/features_test.go diff --git a/internal/rules/markdownflavor/features.go b/internal/rules/markdownflavor/features.go new file mode 100644 index 000000000..14d551995 --- /dev/null +++ b/internal/rules/markdownflavor/features.go @@ -0,0 +1,139 @@ +// Package markdownflavor implements MDS034, which validates Markdown +// against a declared target flavor (commonmark, gfm, goldmark) and +// flags syntax the target renderer will not understand. +package markdownflavor + +// Flavor identifies a target Markdown flavor. +type Flavor int + +// Flavor constants. The zero value is intentionally invalid so that +// unparsed settings are caught. +const ( + flavorInvalid Flavor = iota + FlavorCommonMark + FlavorGFM + FlavorGoldmark +) + +// String returns the canonical lowercase name of the flavor. +func (f Flavor) String() string { + switch f { + case FlavorCommonMark: + return "commonmark" + case FlavorGFM: + return "gfm" + case FlavorGoldmark: + return "goldmark" + } + return "" +} + +// ParseFlavor converts a config string into a Flavor. The match is +// case-sensitive to reject typos like "GFM" that would otherwise +// silently validate against the wrong flavor. +func ParseFlavor(s string) (Flavor, bool) { + switch s { + case "commonmark": + return FlavorCommonMark, true + case "gfm": + return FlavorGFM, true + case "goldmark": + return FlavorGoldmark, true + } + return 0, false +} + +// Feature identifies one Markdown syntax feature whose support varies +// across flavors. +type Feature int + +// Feature constants. Keep in sync with AllFeatures and featureNames. +const ( + FeatureTables Feature = iota + FeatureTaskLists + FeatureStrikethrough + FeatureBareURLAutolinks + FeatureFootnotes + FeatureDefinitionLists + FeatureHeadingIDs + FeatureSuperscript + FeatureSubscript + FeatureMathBlock + FeatureMathInline + FeatureAbbreviations +) + +// AllFeatures returns every tracked feature in declaration order. +func AllFeatures() []Feature { + return []Feature{ + FeatureTables, + FeatureTaskLists, + FeatureStrikethrough, + FeatureBareURLAutolinks, + FeatureFootnotes, + FeatureDefinitionLists, + FeatureHeadingIDs, + FeatureSuperscript, + FeatureSubscript, + FeatureMathBlock, + FeatureMathInline, + FeatureAbbreviations, + } +} + +// Name returns the human-readable feature name used in diagnostics. +func (f Feature) Name() string { + switch f { + case FeatureTables: + return "tables" + case FeatureTaskLists: + return "task lists" + case FeatureStrikethrough: + return "strikethrough" + case FeatureBareURLAutolinks: + return "bare-URL autolinks" + case FeatureFootnotes: + return "footnotes" + case FeatureDefinitionLists: + return "definition lists" + case FeatureHeadingIDs: + return "heading IDs" + case FeatureSuperscript: + return "superscript" + case FeatureSubscript: + return "subscript" + case FeatureMathBlock: + return "math blocks" + case FeatureMathInline: + return "inline math" + case FeatureAbbreviations: + return "abbreviations" + } + return "" +} + +// support maps (flavor, feature) to whether the flavor accepts it. +// CommonMark rejects every tracked feature. GFM adds tables, task +// lists, strikethrough, and bare-URL autolinks. The goldmark profile +// further adds heading IDs but still rejects the optional extensions +// (footnotes, definition lists, math, sub/sup, abbreviations). +var support = map[Flavor]map[Feature]bool{ + FlavorGFM: { + FeatureTables: true, + FeatureTaskLists: true, + FeatureStrikethrough: true, + FeatureBareURLAutolinks: true, + }, + FlavorGoldmark: { + FeatureTables: true, + FeatureTaskLists: true, + FeatureStrikethrough: true, + FeatureBareURLAutolinks: true, + FeatureHeadingIDs: true, + }, +} + +// Supports reports whether the flavor accepts the given feature. +func (f Flavor) Supports(feat Feature) bool { + return support[f][feat] +} diff --git a/internal/rules/markdownflavor/features_test.go b/internal/rules/markdownflavor/features_test.go new file mode 100644 index 000000000..5ed0ce3f5 --- /dev/null +++ b/internal/rules/markdownflavor/features_test.go @@ -0,0 +1,91 @@ +package markdownflavor + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseFlavor(t *testing.T) { + tests := []struct { + in string + want Flavor + ok bool + }{ + {"commonmark", FlavorCommonMark, true}, + {"gfm", FlavorGFM, true}, + {"goldmark", FlavorGoldmark, true}, + {"GFM", 0, false}, + {"", 0, false}, + {"markdown", 0, false}, + } + for _, tc := range tests { + t.Run(tc.in, func(t *testing.T) { + got, ok := ParseFlavor(tc.in) + assert.Equal(t, tc.ok, ok) + if tc.ok { + assert.Equal(t, tc.want, got) + } + }) + } +} + +func TestFlavorString(t *testing.T) { + assert.Equal(t, "commonmark", FlavorCommonMark.String()) + assert.Equal(t, "gfm", FlavorGFM.String()) + assert.Equal(t, "goldmark", FlavorGoldmark.String()) +} + +func TestFeatureSupport(t *testing.T) { + // CommonMark rejects every feature MDS034 tracks. + for _, f := range AllFeatures() { + assert.False(t, FlavorCommonMark.Supports(f), + "CommonMark must reject %s", f.Name()) + } + + // GFM supports tables, task lists, strikethrough, bare-URL autolinks. + assert.True(t, FlavorGFM.Supports(FeatureTables)) + assert.True(t, FlavorGFM.Supports(FeatureTaskLists)) + assert.True(t, FlavorGFM.Supports(FeatureStrikethrough)) + assert.True(t, FlavorGFM.Supports(FeatureBareURLAutolinks)) + + // GFM rejects footnotes, definition lists, heading IDs, math, sub/sup, abbr. + assert.False(t, FlavorGFM.Supports(FeatureFootnotes)) + assert.False(t, FlavorGFM.Supports(FeatureDefinitionLists)) + assert.False(t, FlavorGFM.Supports(FeatureHeadingIDs)) + assert.False(t, FlavorGFM.Supports(FeatureSuperscript)) + assert.False(t, FlavorGFM.Supports(FeatureSubscript)) + assert.False(t, FlavorGFM.Supports(FeatureMathBlock)) + assert.False(t, FlavorGFM.Supports(FeatureMathInline)) + assert.False(t, FlavorGFM.Supports(FeatureAbbreviations)) + + // goldmark profile: GFM features + heading IDs. + assert.True(t, FlavorGoldmark.Supports(FeatureTables)) + assert.True(t, FlavorGoldmark.Supports(FeatureTaskLists)) + assert.True(t, FlavorGoldmark.Supports(FeatureStrikethrough)) + assert.True(t, FlavorGoldmark.Supports(FeatureBareURLAutolinks)) + assert.True(t, FlavorGoldmark.Supports(FeatureHeadingIDs)) + assert.False(t, FlavorGoldmark.Supports(FeatureFootnotes)) + assert.False(t, FlavorGoldmark.Supports(FeatureDefinitionLists)) +} + +func TestAllFeaturesComplete(t *testing.T) { + // Ensure AllFeatures enumerates exactly the 12 features we track. + require.Len(t, AllFeatures(), 12) +} + +func TestFeatureName(t *testing.T) { + assert.Equal(t, "tables", FeatureTables.Name()) + assert.Equal(t, "task lists", FeatureTaskLists.Name()) + assert.Equal(t, "strikethrough", FeatureStrikethrough.Name()) + assert.Equal(t, "bare-URL autolinks", FeatureBareURLAutolinks.Name()) + assert.Equal(t, "footnotes", FeatureFootnotes.Name()) + assert.Equal(t, "definition lists", FeatureDefinitionLists.Name()) + assert.Equal(t, "heading IDs", FeatureHeadingIDs.Name()) + assert.Equal(t, "superscript", FeatureSuperscript.Name()) + assert.Equal(t, "subscript", FeatureSubscript.Name()) + assert.Equal(t, "math blocks", FeatureMathBlock.Name()) + assert.Equal(t, "inline math", FeatureMathInline.Name()) + assert.Equal(t, "abbreviations", FeatureAbbreviations.Name()) +} From b48d8794c94cbdcc91dfcf079408b24d79bc6872 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 19:35:52 +0000 Subject: [PATCH 03/30] Plan 86: add cached goldmark dual parser for MDS034 Singleton goldmark parser with all built-in extensions (table, strikethrough, task-list, footnote, definition-list, linkify) plus the heading-ID attribute parser. Used by MDS034 to re-parse source bytes without disturbing the main AST. Detection tests cover the seven features the built-in extensions expose. Custom parsers for superscript, subscript, math blocks, inline math, and abbreviations are deferred to follow-up commits. --- internal/rules/markdownflavor/parser.go | 41 +++++++ internal/rules/markdownflavor/parser_test.go | 107 +++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 internal/rules/markdownflavor/parser.go create mode 100644 internal/rules/markdownflavor/parser_test.go diff --git a/internal/rules/markdownflavor/parser.go b/internal/rules/markdownflavor/parser.go new file mode 100644 index 000000000..4176c2f5f --- /dev/null +++ b/internal/rules/markdownflavor/parser.go @@ -0,0 +1,41 @@ +package markdownflavor + +import ( + "sync" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" +) + +var ( + parserOnce sync.Once + parserMD goldmark.Markdown +) + +// Parser returns the shared goldmark parser used for dual parsing. +// It enables every built-in goldmark extension relevant to MDS034 +// feature detection (table, strikethrough, task list, footnote, +// definition list, linkify) and the heading-ID attribute parser. +// +// The parser is detection-only: we never render its output. Storing +// it as a package-level singleton avoids rebuilding the parser on +// every rule clone. +func Parser() goldmark.Markdown { + parserOnce.Do(func() { + parserMD = goldmark.New( + goldmark.WithExtensions( + extension.Table, + extension.Strikethrough, + extension.TaskList, + extension.Footnote, + extension.DefinitionList, + extension.Linkify, + ), + goldmark.WithParserOptions( + parser.WithAttribute(), + ), + ) + }) + return parserMD +} diff --git a/internal/rules/markdownflavor/parser_test.go b/internal/rules/markdownflavor/parser_test.go new file mode 100644 index 000000000..b7e18d91e --- /dev/null +++ b/internal/rules/markdownflavor/parser_test.go @@ -0,0 +1,107 @@ +package markdownflavor + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yuin/goldmark/ast" + extast "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/text" +) + +func TestParserCachesSingleInstance(t *testing.T) { + p1 := Parser() + p2 := Parser() + assert.Same(t, p1, p2, "Parser() must return the cached instance") +} + +func TestParserDetectsTables(t *testing.T) { + src := []byte("| a | b |\n| - | - |\n| 1 | 2 |\n") + doc := parseSource(t, src) + assert.True(t, containsKind(doc, extast.KindTable), + "expected table node in dual-parser AST") +} + +func TestParserDetectsStrikethrough(t *testing.T) { + src := []byte("hello ~~world~~\n") + doc := parseSource(t, src) + assert.True(t, containsKind(doc, extast.KindStrikethrough), + "expected strikethrough node in dual-parser AST") +} + +func TestParserDetectsTaskList(t *testing.T) { + src := []byte("- [ ] todo\n- [x] done\n") + doc := parseSource(t, src) + assert.True(t, containsKind(doc, extast.KindTaskCheckBox), + "expected task-list checkbox node in dual-parser AST") +} + +func TestParserDetectsFootnote(t *testing.T) { + src := []byte("A paragraph.[^1]\n\n[^1]: footnote body\n") + doc := parseSource(t, src) + assert.True(t, containsKind(doc, extast.KindFootnoteLink), + "expected footnote link node in dual-parser AST") +} + +func TestParserDetectsDefinitionList(t *testing.T) { + src := []byte("term\n: definition\n") + doc := parseSource(t, src) + assert.True(t, containsKind(doc, extast.KindDefinitionList), + "expected definition-list node in dual-parser AST") +} + +func TestParserDetectsLinkifiedAutolink(t *testing.T) { + src := []byte("See https://example.com for details.\n") + doc := parseSource(t, src) + assert.True(t, containsKind(doc, ast.KindAutoLink), + "expected auto-link node for bare URL in dual-parser AST") +} + +func TestParserDetectsHeadingAttribute(t *testing.T) { + src := []byte("# Heading {#custom-id}\n") + doc := parseSource(t, src) + // The heading attribute parser stores {#id} as an attribute on the + // Heading node, not as a separate child. + found := false + _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + if h, ok := n.(*ast.Heading); ok && h.Attributes() != nil { + if _, ok := h.AttributeString("id"); ok { + found = true + return ast.WalkStop, nil + } + } + return ast.WalkContinue, nil + }) + assert.True(t, found, "expected heading id attribute in dual-parser AST") +} + +// parseSource invokes Parser().Parse on the given source and returns +// the resulting document node. Helper shared by parser detection tests. +func parseSource(t *testing.T, src []byte) ast.Node { + t.Helper() + p := Parser() + doc := p.Parser().Parse(text.NewReader(src)) + require.NotNil(t, doc) + return doc +} + +// containsKind walks the tree rooted at root and reports whether any +// node has the given kind. +func containsKind(root ast.Node, kind ast.NodeKind) bool { + found := false + _ = ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + if n.Kind() == kind { + found = true + return ast.WalkStop, nil + } + return ast.WalkContinue, nil + }) + return found +} From c2db6cbb43fc4011c59724b41799e356a329dbcb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 19:41:08 +0000 Subject: [PATCH 04/30] Plan 86: add AST-based feature detection for MDS034 Detect walks the dual-parser AST for tables, task lists, strikethrough, footnotes, definition lists, and heading IDs, and scans the main-parser AST for bare-URL autolinks while skipping code and link contexts. Each Finding carries feature, line, column, and byte span so the fix pipeline can apply text edits. Block features (tables, footnotes, definition lists) report column 1 of their first line; inline features report their actual source column. --- internal/rules/markdownflavor/detect.go | 274 +++++++++++++++++++ internal/rules/markdownflavor/detect_test.go | 121 ++++++++ 2 files changed, 395 insertions(+) create mode 100644 internal/rules/markdownflavor/detect.go create mode 100644 internal/rules/markdownflavor/detect_test.go diff --git a/internal/rules/markdownflavor/detect.go b/internal/rules/markdownflavor/detect.go new file mode 100644 index 000000000..53b434296 --- /dev/null +++ b/internal/rules/markdownflavor/detect.go @@ -0,0 +1,274 @@ +package markdownflavor + +import ( + "regexp" + + "github.com/yuin/goldmark/ast" + extast "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/text" + + "github.com/jeduden/mdsmith/internal/lint" +) + +// Finding records one detected feature use. +// +// Line and Column are 1-based and reference the document body +// (already adjusted to match the main lint.File line numbering). +// Start and End bound the feature in f.Source and are used by Fix. +type Finding struct { + Feature Feature + Line int + Column int + Start int + End int + // Extra carries feature-specific metadata used by Fix (e.g. the + // {#id} span inside a heading). Nil when not needed. + Extra any +} + +// HeadingIDExtra describes the byte span of a heading-attribute block +// (e.g. "{#custom-id}") inside the original source. +type HeadingIDExtra struct { + AttrStart int // byte offset of '{' + AttrEnd int // byte offset one past '}' +} + +// 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. +var bareURLPattern = regexp.MustCompile( + `(?:http|https|ftp)://[-a-zA-Z0-9@:%._+~#=]{1,256}` + + `\.[a-z]+(?::\d+)?(?:[/#?][-a-zA-Z0-9@:%_+.~#$!?&/=();,'">^{}\[\]` + + "`" + `]*)?`, +) + +// Detect runs every enabled feature detector against f and returns +// findings in document order. +func Detect(f *lint.File) []Finding { + var out []Finding + dualDoc := Parser().Parser().Parse(text.NewReader(f.Source)) + + out = append(out, detectFromDual(f, dualDoc)...) + out = append(out, detectBareURLs(f)...) + return out +} + +// detectFromDual walks the dual-parser tree for all extension-based +// features (tables, strikethrough, task lists, footnotes, definition +// lists, heading IDs). +func detectFromDual(f *lint.File, doc ast.Node) []Finding { + var findings []Finding + _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + switch node := n.(type) { + case *extast.Table: + findings = append(findings, blockFinding(f, n, FeatureTables)) + return ast.WalkSkipChildren, nil + case *extast.TaskCheckBox: + findings = append(findings, inlineFinding(f, n, FeatureTaskLists)) + case *extast.Strikethrough: + findings = append(findings, inlineFinding(f, n, FeatureStrikethrough)) + case *extast.FootnoteLink, *extast.Footnote, *extast.FootnoteList: + findings = append(findings, blockFinding(f, n, FeatureFootnotes)) + return ast.WalkSkipChildren, nil + case *extast.DefinitionList: + findings = append(findings, blockFinding(f, n, FeatureDefinitionLists)) + return ast.WalkSkipChildren, nil + case *ast.Heading: + if hf, ok := findHeadingID(f, node); ok { + findings = append(findings, hf) + } + } + return ast.WalkContinue, nil + }) + return dedupe(findings) +} + +// blockFinding reports a block-level feature starting at column 1 of +// the line containing the node's first text descendant. +func blockFinding(f *lint.File, n ast.Node, feat Feature) Finding { + start, end := nodeByteRange(n) + lineStart := lineStartOf(f.Source, start) + line, _ := lineCol(f.Source, lineStart) + return Finding{Feature: feat, Line: line, Column: 1, Start: lineStart, End: end} +} + +// inlineFinding reports an inline feature at its exact source column. +func inlineFinding(f *lint.File, n ast.Node, feat Feature) Finding { + start, end := nodeByteRange(n) + line, col := lineCol(f.Source, start) + return Finding{Feature: feat, Line: line, Column: col, Start: start, End: end} +} + +func nodeByteRange(n ast.Node) (int, int) { + if n.Type() == ast.TypeBlock { + if lines := n.Lines(); lines != nil && lines.Len() > 0 { + first := lines.At(0) + last := lines.At(lines.Len() - 1) + return first.Start, last.Stop + } + } + start := firstTextStart(n) + return start, start +} + +func lineStartOf(source []byte, offset int) int { + if offset > len(source) { + offset = len(source) + } + for i := offset - 1; i >= 0; i-- { + if source[i] == '\n' { + return i + 1 + } + } + return 0 +} + +func firstTextStart(n ast.Node) int { + for c := n.FirstChild(); c != nil; c = c.NextSibling() { + if t, ok := c.(*ast.Text); ok { + return t.Segment.Start + } + if s := firstTextStart(c); s >= 0 { + return s + } + } + if t, ok := n.(*ast.Text); ok { + return t.Segment.Start + } + return 0 +} + +// makeFinding converts a byte range to a Finding with line and column +// derived from f.Source. +func makeFinding(f *lint.File, feat Feature, start, end int) Finding { + line, col := lineCol(f.Source, start) + return Finding{Feature: feat, Line: line, Column: col, Start: start, End: end} +} + +func lineCol(source []byte, offset int) (int, int) { + if offset < 0 { + offset = 0 + } + if offset > len(source) { + offset = len(source) + } + line := 1 + lineStart := 0 + for i := 0; i < offset; i++ { + if source[i] == '\n' { + line++ + lineStart = i + 1 + } + } + return line, offset - lineStart + 1 +} + +// dedupe collapses consecutive findings of the same feature at the +// same offset (goldmark's extension nodes sometimes nest, e.g. each +// footnote child also carries FootnoteLink). +func dedupe(in []Finding) []Finding { + if len(in) < 2 { + return in + } + out := in[:1] + for _, f := range in[1:] { + last := out[len(out)-1] + if f.Feature == last.Feature && f.Start == last.Start { + continue + } + out = append(out, f) + } + return out +} + +// findHeadingID locates the trailing "{#id}" attribute block that the +// goldmark attribute parser consumed. The Heading node's Lines segment +// only covers the inner text, so we scan the raw line in f.Source from +// the segment start forward to the next newline. +func findHeadingID(f *lint.File, h *ast.Heading) (Finding, bool) { + if h.Attributes() == nil { + return Finding{}, false + } + if _, ok := h.AttributeString("id"); !ok { + return Finding{}, false + } + lines := h.Lines() + if lines == nil || lines.Len() == 0 { + return Finding{}, false + } + segStart := lines.At(0).Start + lineEnd := segStart + for lineEnd < len(f.Source) && f.Source[lineEnd] != '\n' { + lineEnd++ + } + // Find the last '{' on the line that introduces the attribute block. + brace := -1 + for i := lineEnd - 1; i >= segStart; i-- { + if f.Source[i] == '{' { + brace = i + break + } + } + if brace < 0 { + return Finding{}, false + } + attrStart := brace + attrEnd := lineEnd + // Trim trailing whitespace so fixes keep tidy line endings. + for attrEnd > attrStart && f.Source[attrEnd-1] == ' ' { + attrEnd-- + } + line, col := lineCol(f.Source, attrStart) + return Finding{ + Feature: FeatureHeadingIDs, + Line: line, + Column: col, + Start: attrStart, + End: attrEnd, + Extra: HeadingIDExtra{AttrStart: attrStart, AttrEnd: attrEnd}, + }, true +} + +// detectBareURLs scans f.AST (the main CommonMark parse, which has no +// extensions) for bare URL text. Bracketed autolinks are +// recognised by CommonMark and appear as ast.AutoLink, so only true +// bare URLs remain inside Text nodes. +func detectBareURLs(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 + } + t, ok := n.(*ast.Text) + if !ok { + return ast.WalkContinue, nil + } + if insideNonBareContext(n) { + return ast.WalkContinue, nil + } + seg := t.Segment + body := seg.Value(f.Source) + matches := bareURLPattern.FindAllIndex(body, -1) + for _, m := range matches { + start := seg.Start + m[0] + end := seg.Start + m[1] + findings = append(findings, makeFinding(f, FeatureBareURLAutolinks, start, end)) + } + return ast.WalkContinue, nil + }) + return findings +} + +func insideNonBareContext(n ast.Node) bool { + for p := n.Parent(); p != nil; p = p.Parent() { + switch p.(type) { + case *ast.Link, *ast.AutoLink, *ast.CodeSpan, *ast.FencedCodeBlock, + *ast.CodeBlock: + return true + } + } + return false +} diff --git a/internal/rules/markdownflavor/detect_test.go b/internal/rules/markdownflavor/detect_test.go new file mode 100644 index 000000000..0f651e66c --- /dev/null +++ b/internal/rules/markdownflavor/detect_test.go @@ -0,0 +1,121 @@ +package markdownflavor + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/jeduden/mdsmith/internal/lint" +) + +func mkFile(t *testing.T, src string) *lint.File { + t.Helper() + f, err := lint.NewFile("test.md", []byte(src)) + require.NoError(t, err) + return f +} + +func findings(t *testing.T, src string) []Finding { + t.Helper() + return Detect(mkFile(t, src)) +} + +func hasFeature(fs []Finding, feat Feature) bool { + for _, f := range fs { + if f.Feature == feat { + return true + } + } + return false +} + +func TestDetectTable(t *testing.T) { + fs := findings(t, "| a | b |\n| - | - |\n| 1 | 2 |\n") + require.True(t, hasFeature(fs, FeatureTables)) + for _, f := range fs { + if f.Feature == FeatureTables { + assert.Equal(t, 1, f.Line) + assert.Equal(t, 1, f.Column) + return + } + } +} + +func TestDetectStrikethrough(t *testing.T) { + fs := findings(t, "hello ~~world~~\n") + require.True(t, hasFeature(fs, FeatureStrikethrough)) +} + +func TestDetectTaskList(t *testing.T) { + fs := findings(t, "- [ ] todo\n- [x] done\n") + require.True(t, hasFeature(fs, FeatureTaskLists)) +} + +func TestDetectFootnote(t *testing.T) { + fs := findings(t, "A paragraph.[^1]\n\n[^1]: footnote body\n") + require.True(t, hasFeature(fs, FeatureFootnotes)) +} + +func TestDetectDefinitionList(t *testing.T) { + fs := findings(t, "term\n: definition\n") + require.True(t, hasFeature(fs, FeatureDefinitionLists)) +} + +func TestDetectBareURLAutolink(t *testing.T) { + fs := findings(t, "See https://example.com for details.\n") + require.True(t, hasFeature(fs, FeatureBareURLAutolinks)) +} + +func TestDetectIgnoresBracketedAutolink(t *testing.T) { + fs := findings(t, "See for details.\n") + assert.False(t, hasFeature(fs, FeatureBareURLAutolinks), + " bracketed autolinks are CommonMark; must not be flagged as bare-URL autolinks") +} + +func TestDetectIgnoresURLInsideLink(t *testing.T) { + fs := findings(t, "See [here](https://example.com).\n") + assert.False(t, hasFeature(fs, FeatureBareURLAutolinks), + "URLs inside Markdown link destinations are not bare") +} + +func TestDetectIgnoresURLInCodeSpan(t *testing.T) { + fs := findings(t, "See `https://example.com` for details.\n") + assert.False(t, hasFeature(fs, FeatureBareURLAutolinks), + "URLs inside inline code must not be flagged") +} + +func TestDetectIgnoresURLInFencedCode(t *testing.T) { + src := "```\nhttps://example.com\n```\n" + fs := findings(t, src) + assert.False(t, hasFeature(fs, FeatureBareURLAutolinks), + "URLs inside fenced code blocks must not be flagged") +} + +func TestDetectHeadingID(t *testing.T) { + fs := findings(t, "# Heading {#custom}\n") + require.True(t, hasFeature(fs, FeatureHeadingIDs)) +} + +func TestDetectMultipleFeatures(t *testing.T) { + src := "# Title {#top}\n\n- [ ] task\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n" + + "~~old~~ https://example.com\n" + fs := findings(t, src) + assert.True(t, hasFeature(fs, FeatureHeadingIDs)) + assert.True(t, hasFeature(fs, FeatureTaskLists)) + assert.True(t, hasFeature(fs, FeatureTables)) + assert.True(t, hasFeature(fs, FeatureStrikethrough)) + assert.True(t, hasFeature(fs, FeatureBareURLAutolinks)) +} + +func TestDetectEmptyDocument(t *testing.T) { + fs := findings(t, "\n") + assert.Empty(t, fs) +} + +func TestDetectPlainCommonMark(t *testing.T) { + src := "# Heading\n\nA paragraph.\n\n- bullet\n- another\n\n" + + "```go\nfmt.Println(\"hi\")\n```\n" + fs := findings(t, src) + assert.Empty(t, fs) +} From decd07e6f0fd9c6fa55f84de6bfabbd9f26648e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 19:56:42 +0000 Subject: [PATCH 05/30] Plan 86: MDS034 markdown-flavor rule (built-in features) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the MDS034 rule that validates a document against a declared target flavor (commonmark, gfm, or goldmark) and emits a warning for each unsupported feature use. Seven features ship in this slice — all reachable via built-in goldmark extensions: - tables, task lists, strikethrough, - bare-URL autolinks, - footnotes, definition lists, - heading IDs. Five further features (superscript, subscript, block and inline math, abbreviations) will land with custom goldmark extensions in a follow-up commit. The rule is opt-in and configured via: rules: markdown-flavor: flavor: gfm Register it in cmd/mdsmith and the integration test registry. Add README, good/, and bad/ fixtures. Also expose directorystructure.SilenceConfigWarningForTesting so the integration runner can pre-consume the MDS033 "no allowed patterns" once-guard; without it, the guard would fire on the first MDS034 test because MDS033's defaults-based cleanup leaves the rule in configured=true / allowed=[] state. --- cmd/mdsmith/main.go | 1 + internal/integration/rules_test.go | 11 +- .../rules/MDS034-markdown-flavor/README.md | 108 +++++++++++ .../bad/commonmark-bare-url.md | 11 ++ .../bad/commonmark-heading-id.md | 11 ++ .../bad/commonmark-strikethrough.md | 11 ++ .../bad/commonmark-table.md | 13 ++ .../bad/commonmark-task-list.md | 15 ++ .../bad/gfm-definition-list.md | 12 ++ .../bad/gfm-footnote.md | 16 ++ .../bad/gfm-heading-id.md | 11 ++ .../MDS034-markdown-flavor/good/commonmark.md | 7 + .../rules/MDS034-markdown-flavor/good/gfm.md | 10 + .../MDS034-markdown-flavor/good/goldmark.md | 10 + internal/rules/directorystructure/rule.go | 9 + internal/rules/index.md | 73 ++++---- internal/rules/markdownflavor/detect.go | 76 +++++++- internal/rules/markdownflavor/features.go | 12 ++ internal/rules/markdownflavor/rule.go | 94 ++++++++++ internal/rules/markdownflavor/rule_test.go | 174 ++++++++++++++++++ 20 files changed, 644 insertions(+), 41 deletions(-) create mode 100644 internal/rules/MDS034-markdown-flavor/README.md create mode 100644 internal/rules/MDS034-markdown-flavor/bad/commonmark-bare-url.md create mode 100644 internal/rules/MDS034-markdown-flavor/bad/commonmark-heading-id.md create mode 100644 internal/rules/MDS034-markdown-flavor/bad/commonmark-strikethrough.md create mode 100644 internal/rules/MDS034-markdown-flavor/bad/commonmark-table.md create mode 100644 internal/rules/MDS034-markdown-flavor/bad/commonmark-task-list.md create mode 100644 internal/rules/MDS034-markdown-flavor/bad/gfm-definition-list.md create mode 100644 internal/rules/MDS034-markdown-flavor/bad/gfm-footnote.md create mode 100644 internal/rules/MDS034-markdown-flavor/bad/gfm-heading-id.md create mode 100644 internal/rules/MDS034-markdown-flavor/good/commonmark.md create mode 100644 internal/rules/MDS034-markdown-flavor/good/gfm.md create mode 100644 internal/rules/MDS034-markdown-flavor/good/goldmark.md create mode 100644 internal/rules/markdownflavor/rule.go create mode 100644 internal/rules/markdownflavor/rule_test.go diff --git a/cmd/mdsmith/main.go b/cmd/mdsmith/main.go index 9c56e1795..1d725ca39 100644 --- a/cmd/mdsmith/main.go +++ b/cmd/mdsmith/main.go @@ -39,6 +39,7 @@ import ( _ "github.com/jeduden/mdsmith/internal/rules/include" _ "github.com/jeduden/mdsmith/internal/rules/linelength" _ "github.com/jeduden/mdsmith/internal/rules/listindent" + _ "github.com/jeduden/mdsmith/internal/rules/markdownflavor" _ "github.com/jeduden/mdsmith/internal/rules/maxfilelength" _ "github.com/jeduden/mdsmith/internal/rules/maxsectionlength" _ "github.com/jeduden/mdsmith/internal/rules/nobareurls" diff --git a/internal/integration/rules_test.go b/internal/integration/rules_test.go index c27520b0c..332b433e8 100644 --- a/internal/integration/rules_test.go +++ b/internal/integration/rules_test.go @@ -22,7 +22,7 @@ import ( _ "github.com/jeduden/mdsmith/internal/rules/catalog" _ "github.com/jeduden/mdsmith/internal/rules/concisenessscoring" _ "github.com/jeduden/mdsmith/internal/rules/crossfilereferenceintegrity" - _ "github.com/jeduden/mdsmith/internal/rules/directorystructure" + "github.com/jeduden/mdsmith/internal/rules/directorystructure" _ "github.com/jeduden/mdsmith/internal/rules/emptysectionbody" _ "github.com/jeduden/mdsmith/internal/rules/fencedcodelanguage" _ "github.com/jeduden/mdsmith/internal/rules/fencedcodestyle" @@ -32,6 +32,7 @@ import ( _ "github.com/jeduden/mdsmith/internal/rules/include" _ "github.com/jeduden/mdsmith/internal/rules/linelength" _ "github.com/jeduden/mdsmith/internal/rules/listindent" + _ "github.com/jeduden/mdsmith/internal/rules/markdownflavor" _ "github.com/jeduden/mdsmith/internal/rules/maxfilelength" _ "github.com/jeduden/mdsmith/internal/rules/maxsectionlength" _ "github.com/jeduden/mdsmith/internal/rules/nobareurls" @@ -143,6 +144,14 @@ func TestRuleFixtures(t *testing.T) { primeDirectoryStructureWarnOnce(t) dirs := discoverFixtureDirs(t) + // MDS033's "no allowed patterns" warning is gated by a + // process-level sync.Once. After MDS033's own fixtures run, the + // rule is left in configured=true / allowed=[] state by the + // defaults-based cleanup. That would fire the warning on the + // first checkAllRules walk in any later rule's tests. Consume + // the guard up front so the leak cannot surface. + directorystructure.SilenceConfigWarningForTesting() + for _, dir := range dirs { base := filepath.Base(dir) m := ruleIDPattern.FindStringSubmatch(base) diff --git a/internal/rules/MDS034-markdown-flavor/README.md b/internal/rules/MDS034-markdown-flavor/README.md new file mode 100644 index 000000000..f5ba38626 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/README.md @@ -0,0 +1,108 @@ +--- +id: MDS034 +name: markdown-flavor +status: ready +description: >- + Flags Markdown syntax that the declared target + flavor does not render. +--- +# MDS034: markdown-flavor + +Flags Markdown syntax that the declared target +flavor does not render. + +- **ID**: MDS034 +- **Name**: `markdown-flavor` +- **Status**: ready +- **Default**: disabled +- **Fixable**: no (fix pipeline lands in a follow-up) +- **Implementation**: + [source](./) +- **Category**: meta + +## Settings + +| Key | Type | Description | +|--------|--------|---------------------------------------------------| +| flavor | string | Target flavor: `commonmark`, `gfm`, or `goldmark` | + +The flavor name is case-sensitive. The `goldmark` +profile is mdsmith-defined. It accepts GFM features +plus heading IDs. It does not accept optional +footnote, definition-list, or math extensions. + +## Config + +Enable with a target flavor: + +```yaml +rules: + markdown-flavor: + flavor: gfm +``` + +Disable (default): + +```yaml +rules: + markdown-flavor: false +``` + +## Detected features + +MDS034 tracks seven syntax features in this +increment. Each one is detected via the goldmark +AST with a built-in extension enabled: + +| Feature | commonmark | gfm | goldmark | +|--------------------|------------|-----|----------| +| tables | no | yes | yes | +| task lists | no | yes | yes | +| strikethrough | no | yes | yes | +| bare-URL autolinks | no | yes | yes | +| footnotes | no | no | no | +| definition lists | no | no | no | +| heading IDs | no | no | yes | + +Five further features need custom goldmark +extensions: superscript, subscript, block math, +inline math, and abbreviations. They are tracked +under +[plan 86](../../../plan/86_markdown-flavor-validation.md). + +## Examples + +### Good + + + +```markdown +# Heading + +Text with ~~old~~ markup and a task list: + +- [x] done +- [ ] todo +``` + + + +### Bad + + + +```markdown +# Heading + +| a | b | +| - | - | +| 1 | 2 | +``` + + diff --git a/internal/rules/MDS034-markdown-flavor/bad/commonmark-bare-url.md b/internal/rules/MDS034-markdown-flavor/bad/commonmark-bare-url.md new file mode 100644 index 000000000..cc3f9cc2f --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/commonmark-bare-url.md @@ -0,0 +1,11 @@ +--- +settings: + flavor: commonmark +diagnostics: + - line: 3 + column: 7 + message: "bare-URL autolinks are not supported by commonmark" +--- +# Heading + +Visit https://example.com for details. diff --git a/internal/rules/MDS034-markdown-flavor/bad/commonmark-heading-id.md b/internal/rules/MDS034-markdown-flavor/bad/commonmark-heading-id.md new file mode 100644 index 000000000..a972cadb3 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/commonmark-heading-id.md @@ -0,0 +1,11 @@ +--- +settings: + flavor: commonmark +diagnostics: + - line: 1 + column: 11 + message: "heading IDs are not supported by commonmark" +--- +# Heading {#top} + +Body text. diff --git a/internal/rules/MDS034-markdown-flavor/bad/commonmark-strikethrough.md b/internal/rules/MDS034-markdown-flavor/bad/commonmark-strikethrough.md new file mode 100644 index 000000000..58c6b98d0 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/commonmark-strikethrough.md @@ -0,0 +1,11 @@ +--- +settings: + flavor: commonmark +diagnostics: + - line: 3 + column: 6 + message: "strikethrough is not supported by commonmark" +--- +# Heading + +Text ~~crossed out~~ here. diff --git a/internal/rules/MDS034-markdown-flavor/bad/commonmark-table.md b/internal/rules/MDS034-markdown-flavor/bad/commonmark-table.md new file mode 100644 index 000000000..74b4238fc --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/commonmark-table.md @@ -0,0 +1,13 @@ +--- +settings: + flavor: commonmark +diagnostics: + - line: 3 + column: 1 + message: "tables are not supported by commonmark" +--- +# Heading + +| a | b | +| - | - | +| 1 | 2 | diff --git a/internal/rules/MDS034-markdown-flavor/bad/commonmark-task-list.md b/internal/rules/MDS034-markdown-flavor/bad/commonmark-task-list.md new file mode 100644 index 000000000..2e38457ea --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/commonmark-task-list.md @@ -0,0 +1,15 @@ +--- +settings: + flavor: commonmark +diagnostics: + - line: 3 + column: 3 + message: "task lists are not supported by commonmark" + - line: 4 + column: 3 + message: "task lists are not supported by commonmark" +--- +# Heading + +- [x] done +- [ ] todo diff --git a/internal/rules/MDS034-markdown-flavor/bad/gfm-definition-list.md b/internal/rules/MDS034-markdown-flavor/bad/gfm-definition-list.md new file mode 100644 index 000000000..91706f421 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/gfm-definition-list.md @@ -0,0 +1,12 @@ +--- +settings: + flavor: gfm +diagnostics: + - line: 3 + column: 1 + message: "definition lists are not supported by gfm" +--- +# Heading + +term +: definition diff --git a/internal/rules/MDS034-markdown-flavor/bad/gfm-footnote.md b/internal/rules/MDS034-markdown-flavor/bad/gfm-footnote.md new file mode 100644 index 000000000..8fbcf0cc5 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/gfm-footnote.md @@ -0,0 +1,16 @@ +--- +settings: + flavor: gfm +diagnostics: + - line: 3 + column: 1 + message: "footnotes are not supported by gfm" + - line: 5 + column: 1 + message: "footnotes are not supported by gfm" +--- +# Heading + +A paragraph.[^1] + +[^1]: footnote body. diff --git a/internal/rules/MDS034-markdown-flavor/bad/gfm-heading-id.md b/internal/rules/MDS034-markdown-flavor/bad/gfm-heading-id.md new file mode 100644 index 000000000..5cab89b3a --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/gfm-heading-id.md @@ -0,0 +1,11 @@ +--- +settings: + flavor: gfm +diagnostics: + - line: 1 + column: 11 + message: "heading IDs are not supported by gfm" +--- +# Heading {#top} + +Body text. diff --git a/internal/rules/MDS034-markdown-flavor/good/commonmark.md b/internal/rules/MDS034-markdown-flavor/good/commonmark.md new file mode 100644 index 000000000..60413382c --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/good/commonmark.md @@ -0,0 +1,7 @@ +--- +settings: + flavor: commonmark +--- +# Heading + +A plain CommonMark paragraph with nothing special. diff --git a/internal/rules/MDS034-markdown-flavor/good/gfm.md b/internal/rules/MDS034-markdown-flavor/good/gfm.md new file mode 100644 index 000000000..49a6e48b3 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/good/gfm.md @@ -0,0 +1,10 @@ +--- +settings: + flavor: gfm +--- +# Heading + +Text with ~~old~~ markup and a task list: + +- [x] done +- [ ] todo diff --git a/internal/rules/MDS034-markdown-flavor/good/goldmark.md b/internal/rules/MDS034-markdown-flavor/good/goldmark.md new file mode 100644 index 000000000..ba6e32f07 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/good/goldmark.md @@ -0,0 +1,10 @@ +--- +settings: + flavor: goldmark +--- +# Heading {#top} + +Text with ~~old~~ markup and a task list: + +- [x] done +- [ ] todo diff --git a/internal/rules/directorystructure/rule.go b/internal/rules/directorystructure/rule.go index cfcc78a3f..dac0647fb 100644 --- a/internal/rules/directorystructure/rule.go +++ b/internal/rules/directorystructure/rule.go @@ -20,6 +20,15 @@ func init() { // clones the rule per file. var configWarned sync.Once +// SilenceConfigWarningForTesting consumes the package-level once-guard +// without emitting the config warning, so later checks will not fire +// it. Intended for tests that share a process and cannot tolerate a +// misconfigured-state leak from a previous rule's cleanup. +func SilenceConfigWarningForTesting() { + configWarned = sync.Once{} + configWarned.Do(func() {}) +} + // Rule checks that markdown files exist only in explicitly allowed directories. type Rule struct { Allowed []string diff --git a/internal/rules/index.md b/internal/rules/index.md index 91279ae11..009801bdf 100644 --- a/internal/rules/index.md +++ b/internal/rules/index.md @@ -17,41 +17,40 @@ header: | |------|------|--------|-------------| row: "| [{id}]({filename}) | `{name}` | {status} | {description} |" ?> -| Rule | Name | Status | Description | -|---------------------------------------------------------------|--------------------------------------|-----------|-----------------------------------------------------------------------------------------------| -| [MDS001](MDS001-line-length/README.md) | `line-length` | ready | Line exceeds maximum length. | -| [MDS002](MDS002-heading-style/README.md) | `heading-style` | ready | Heading style must be consistent. | -| [MDS003](MDS003-heading-increment/README.md) | `heading-increment` | ready | Heading levels should increment by one. No jumping from `#` to `###`. | -| [MDS004](MDS004-first-line-heading/README.md) | `first-line-heading` | ready | First line of the file should be a heading. | -| [MDS005](MDS005-no-duplicate-headings/README.md) | `no-duplicate-headings` | ready | No two headings should have the same text. | -| [MDS006](MDS006-no-trailing-spaces/README.md) | `no-trailing-spaces` | ready | No trailing whitespace at the end of lines. | -| [MDS007](MDS007-no-hard-tabs/README.md) | `no-hard-tabs` | ready | No tab characters. Use spaces instead. | -| [MDS008](MDS008-no-multiple-blanks/README.md) | `no-multiple-blanks` | ready | No more than one consecutive blank line. | -| [MDS009](MDS009-single-trailing-newline/README.md) | `single-trailing-newline` | ready | File must end with exactly one newline character. | -| [MDS010](MDS010-fenced-code-style/README.md) | `fenced-code-style` | ready | Fenced code blocks must use a consistent delimiter. | -| [MDS011](MDS011-fenced-code-language/README.md) | `fenced-code-language` | ready | Fenced code blocks must specify a language. | -| [MDS012](MDS012-no-bare-urls/README.md) | `no-bare-urls` | ready | URLs must be wrapped in angle brackets or as a link, not left bare. | -| [MDS013](MDS013-blank-line-around-headings/README.md) | `blank-line-around-headings` | ready | Headings must have a blank line before and after. | -| [MDS014](MDS014-blank-line-around-lists/README.md) | `blank-line-around-lists` | ready | Lists must have a blank line before and after. | -| [MDS015](MDS015-blank-line-around-fenced-code/README.md) | `blank-line-around-fenced-code` | ready | Fenced code blocks must have a blank line before and after. | -| [MDS016](MDS016-list-indent/README.md) | `list-indent` | ready | List items must use consistent indentation. | -| [MDS017](MDS017-no-trailing-punctuation-in-heading/README.md) | `no-trailing-punctuation-in-heading` | ready | Headings should not end with punctuation. | -| [MDS018](MDS018-no-emphasis-as-heading/README.md) | `no-emphasis-as-heading` | ready | Don't use bold or emphasis on a standalone line as a heading substitute. | -| [MDS019](MDS019-catalog/README.md) | `catalog` | ready | Catalog content must reflect selected front matter fields from files matching its glob. | -| [MDS020](MDS020-required-structure/README.md) | `required-structure` | ready | Document structure and front matter must match its schema. | -| [MDS021](MDS021-include/README.md) | `include` | ready | Include section content must match the referenced file. | -| [MDS022](MDS022-max-file-length/README.md) | `max-file-length` | ready | File must not exceed maximum number of lines. | -| [MDS023](MDS023-paragraph-readability/README.md) | `paragraph-readability` | ready | Paragraph readability index must not exceed a threshold. | -| [MDS024](MDS024-paragraph-structure/README.md) | `paragraph-structure` | ready | Paragraphs must not exceed sentence and word limits. | -| [MDS025](MDS025-table-format/README.md) | `table-format` | ready | Tables must have consistent column widths and padding. | -| [MDS026](MDS026-table-readability/README.md) | `table-readability` | ready | Tables must stay within readability complexity limits. | -| [MDS027](MDS027-cross-file-reference-integrity/README.md) | `cross-file-reference-integrity` | ready | Links to local files and heading anchors must resolve. | -| [MDS028](MDS028-token-budget/README.md) | `token-budget` | ready | File must not exceed a token budget. | -| [MDS029](MDS029-conciseness-scoring/README.md) | `conciseness-scoring` | not-ready | Paragraph conciseness score must not fall below a threshold. | -| [MDS030](MDS030-empty-section-body/README.md) | `empty-section-body` | ready | Section headings must include meaningful body content. | -| [MDS031](MDS031-unclosed-code-block/README.md) | `unclosed-code-block` | ready | Fenced code blocks must have a closing fence delimiter. | -| [MDS032](MDS032-no-empty-alt-text/README.md) | `no-empty-alt-text` | ready | Images must have non-empty alt text for accessibility. | -| [MDS033](MDS033-directory-structure/README.md) | `directory-structure` | ready | Markdown files must exist only in explicitly allowed directories. | -| [MDS035](MDS035-toc-directive/README.md) | `toc-directive` | ready | Flag renderer-specific TOC directives that render as literal text on CommonMark and goldmark. | -| [MDS036](MDS036-max-section-length/README.md) | `max-section-length` | ready | Section length must not exceed per-level or per-heading limits. | +| Rule | Name | Status | Description | +|---------------------------------------------------------------|--------------------------------------|-----------|-----------------------------------------------------------------------------------------| +| [MDS001](MDS001-line-length/README.md) | `line-length` | ready | Line exceeds maximum length. | +| [MDS002](MDS002-heading-style/README.md) | `heading-style` | ready | Heading style must be consistent. | +| [MDS003](MDS003-heading-increment/README.md) | `heading-increment` | ready | Heading levels should increment by one. No jumping from `#` to `###`. | +| [MDS004](MDS004-first-line-heading/README.md) | `first-line-heading` | ready | First line of the file should be a heading. | +| [MDS005](MDS005-no-duplicate-headings/README.md) | `no-duplicate-headings` | ready | No two headings should have the same text. | +| [MDS006](MDS006-no-trailing-spaces/README.md) | `no-trailing-spaces` | ready | No trailing whitespace at the end of lines. | +| [MDS007](MDS007-no-hard-tabs/README.md) | `no-hard-tabs` | ready | No tab characters. Use spaces instead. | +| [MDS008](MDS008-no-multiple-blanks/README.md) | `no-multiple-blanks` | ready | No more than one consecutive blank line. | +| [MDS009](MDS009-single-trailing-newline/README.md) | `single-trailing-newline` | ready | File must end with exactly one newline character. | +| [MDS010](MDS010-fenced-code-style/README.md) | `fenced-code-style` | ready | Fenced code blocks must use a consistent delimiter. | +| [MDS011](MDS011-fenced-code-language/README.md) | `fenced-code-language` | ready | Fenced code blocks must specify a language. | +| [MDS012](MDS012-no-bare-urls/README.md) | `no-bare-urls` | ready | URLs must be wrapped in angle brackets or as a link, not left bare. | +| [MDS013](MDS013-blank-line-around-headings/README.md) | `blank-line-around-headings` | ready | Headings must have a blank line before and after. | +| [MDS014](MDS014-blank-line-around-lists/README.md) | `blank-line-around-lists` | ready | Lists must have a blank line before and after. | +| [MDS015](MDS015-blank-line-around-fenced-code/README.md) | `blank-line-around-fenced-code` | ready | Fenced code blocks must have a blank line before and after. | +| [MDS016](MDS016-list-indent/README.md) | `list-indent` | ready | List items must use consistent indentation. | +| [MDS017](MDS017-no-trailing-punctuation-in-heading/README.md) | `no-trailing-punctuation-in-heading` | ready | Headings should not end with punctuation. | +| [MDS018](MDS018-no-emphasis-as-heading/README.md) | `no-emphasis-as-heading` | ready | Don't use bold or emphasis on a standalone line as a heading substitute. | +| [MDS019](MDS019-catalog/README.md) | `catalog` | ready | Catalog content must reflect selected front matter fields from files matching its glob. | +| [MDS020](MDS020-required-structure/README.md) | `required-structure` | ready | Document structure and front matter must match its schema. | +| [MDS021](MDS021-include/README.md) | `include` | ready | Include section content must match the referenced file. | +| [MDS022](MDS022-max-file-length/README.md) | `max-file-length` | ready | File must not exceed maximum number of lines. | +| [MDS023](MDS023-paragraph-readability/README.md) | `paragraph-readability` | ready | Paragraph readability index must not exceed a threshold. | +| [MDS024](MDS024-paragraph-structure/README.md) | `paragraph-structure` | ready | Paragraphs must not exceed sentence and word limits. | +| [MDS025](MDS025-table-format/README.md) | `table-format` | ready | Tables must have consistent column widths and padding. | +| [MDS026](MDS026-table-readability/README.md) | `table-readability` | ready | Tables must stay within readability complexity limits. | +| [MDS027](MDS027-cross-file-reference-integrity/README.md) | `cross-file-reference-integrity` | ready | Links to local files and heading anchors must resolve. | +| [MDS028](MDS028-token-budget/README.md) | `token-budget` | ready | File must not exceed a token budget. | +| [MDS029](MDS029-conciseness-scoring/README.md) | `conciseness-scoring` | not-ready | Paragraph conciseness score must not fall below a threshold. | +| [MDS030](MDS030-empty-section-body/README.md) | `empty-section-body` | ready | Section headings must include meaningful body content. | +| [MDS031](MDS031-unclosed-code-block/README.md) | `unclosed-code-block` | ready | Fenced code blocks must have a closing fence delimiter. | +| [MDS032](MDS032-no-empty-alt-text/README.md) | `no-empty-alt-text` | ready | Images must have non-empty alt text for accessibility. | +| [MDS033](MDS033-directory-structure/README.md) | `directory-structure` | ready | Markdown files must exist only in explicitly allowed directories. | +| [MDS034](MDS034-markdown-flavor/README.md) | `markdown-flavor` | ready | Flags Markdown syntax that the declared target flavor does not render. | diff --git a/internal/rules/markdownflavor/detect.go b/internal/rules/markdownflavor/detect.go index 53b434296..d34281be3 100644 --- a/internal/rules/markdownflavor/detect.go +++ b/internal/rules/markdownflavor/detect.go @@ -67,12 +67,27 @@ func detectFromDual(f *lint.File, doc ast.Node) []Finding { findings = append(findings, blockFinding(f, n, FeatureTables)) return ast.WalkSkipChildren, nil case *extast.TaskCheckBox: - findings = append(findings, inlineFinding(f, n, FeatureTaskLists)) + // TaskCheckBox has no text children, so pull position from + // the enclosing ListItem block. + findings = append(findings, taskCheckBoxFinding(f, n)) case *extast.Strikethrough: - findings = append(findings, inlineFinding(f, n, FeatureStrikethrough)) - case *extast.FootnoteLink, *extast.Footnote, *extast.FootnoteList: + // Strikethrough's first text child starts after the + // opening "~~"; back up two bytes to point at the marker. + fin := inlineFinding(f, n, FeatureStrikethrough) + if fin.Start >= 2 && f.Source[fin.Start-1] == '~' && f.Source[fin.Start-2] == '~' { + fin.Start -= 2 + fin.Column -= 2 + } + findings = append(findings, fin) + case *extast.FootnoteLink: + findings = append(findings, inlineExtFinding(f, n, FeatureFootnotes)) + case *extast.Footnote: findings = append(findings, blockFinding(f, n, FeatureFootnotes)) return ast.WalkSkipChildren, nil + case *extast.FootnoteList: + // Walk children so Footnote definitions report their own + // locations; skip emitting a wrapper finding. + return ast.WalkContinue, nil case *extast.DefinitionList: findings = append(findings, blockFinding(f, n, FeatureDefinitionLists)) return ast.WalkSkipChildren, nil @@ -95,6 +110,61 @@ func blockFinding(f *lint.File, n ast.Node, feat Feature) Finding { return Finding{Feature: feat, Line: line, Column: 1, Start: lineStart, End: end} } +// taskCheckBoxFinding synthesises a Finding for a TaskCheckBox by +// walking up to the nearest block ancestor with line info (TextBlock +// inside the containing ListItem). TaskCheckBox has no source segment +// of its own. +func taskCheckBoxFinding(f *lint.File, n ast.Node) Finding { + if p := nearestBlockAncestor(n, ast.NodeKind(0)); p != nil { + return findingFromBlock(f, p, FeatureTaskLists) + } + return Finding{Feature: FeatureTaskLists, Line: 1, Column: 1} +} + +// inlineExtFinding covers inline extension nodes that expose no +// segment (e.g. FootnoteLink). It uses the first ancestor block's +// first-line position instead of firstTextStart, which would return +// zero for a childless inline. +func inlineExtFinding(f *lint.File, n ast.Node, feat Feature) Finding { + if p := nearestBlockAncestor(n, ast.NodeKind(0)); p != nil { + return findingFromBlock(f, p, feat) + } + return Finding{Feature: feat, Line: 1, Column: 1} +} + +// nearestBlockAncestor walks up from n and returns the first ancestor +// whose kind matches want; when want is 0 the first block-typed +// ancestor with Lines() is returned. +func nearestBlockAncestor(n ast.Node, want ast.NodeKind) ast.Node { + for p := n.Parent(); p != nil; p = p.Parent() { + if want != 0 { + if p.Kind() == want { + return p + } + continue + } + if p.Type() != ast.TypeBlock { + continue + } + if lines := p.Lines(); lines != nil && lines.Len() > 0 { + return p + } + } + return nil +} + +// findingFromBlock builds an inline-style finding (exact line/col of +// the block's first line) for features emitted from a block ancestor. +func findingFromBlock(f *lint.File, block ast.Node, feat Feature) Finding { + lines := block.Lines() + if lines == nil || lines.Len() == 0 { + return Finding{Feature: feat, Line: 1, Column: 1} + } + start := lines.At(0).Start + line, col := lineCol(f.Source, start) + return Finding{Feature: feat, Line: line, Column: col, Start: start, End: start} +} + // inlineFinding reports an inline feature at its exact source column. func inlineFinding(f *lint.File, n ast.Node, feat Feature) Finding { start, end := nodeByteRange(n) diff --git a/internal/rules/markdownflavor/features.go b/internal/rules/markdownflavor/features.go index 14d551995..d96262c7a 100644 --- a/internal/rules/markdownflavor/features.go +++ b/internal/rules/markdownflavor/features.go @@ -81,6 +81,18 @@ func AllFeatures() []Feature { } } +// Verb returns "is" or "are" so diagnostic messages read naturally +// for both singular (strikethrough, inline math) and plural +// (tables, task lists) feature names. +func (f Feature) Verb() string { + switch f { + case FeatureStrikethrough, FeatureSuperscript, FeatureSubscript, + FeatureMathInline: + return "is" + } + return "are" +} + // Name returns the human-readable feature name used in diagnostics. func (f Feature) Name() string { switch f { diff --git a/internal/rules/markdownflavor/rule.go b/internal/rules/markdownflavor/rule.go new file mode 100644 index 000000000..395d2e272 --- /dev/null +++ b/internal/rules/markdownflavor/rule.go @@ -0,0 +1,94 @@ +package markdownflavor + +import ( + "fmt" + + "github.com/jeduden/mdsmith/internal/lint" + "github.com/jeduden/mdsmith/internal/rule" +) + +func init() { + rule.Register(&Rule{}) +} + +// Rule implements MDS034, validating Markdown against a declared +// target flavor and flagging syntax the renderer will reject. +type Rule struct { + Flavor Flavor +} + +// ID implements rule.Rule. +func (r *Rule) ID() string { return "MDS034" } + +// Name implements rule.Rule. +func (r *Rule) Name() string { return "markdown-flavor" } + +// Category implements rule.Rule. +func (r *Rule) Category() string { return "meta" } + +// EnabledByDefault implements rule.Defaultable. MDS034 is opt-in. +func (r *Rule) EnabledByDefault() bool { return false } + +// ApplySettings implements rule.Configurable. +func (r *Rule) ApplySettings(settings map[string]any) error { + for k, v := range settings { + switch k { + case "flavor": + s, ok := v.(string) + if !ok { + return fmt.Errorf("markdown-flavor: flavor must be a string, got %T", v) + } + if s == "" { + r.Flavor = 0 + continue + } + fl, ok := ParseFlavor(s) + if !ok { + return fmt.Errorf( + "markdown-flavor: unknown flavor %q (expected commonmark, gfm, or goldmark)", + s, + ) + } + r.Flavor = fl + default: + return fmt.Errorf("markdown-flavor: unknown setting %q", k) + } + } + return nil +} + +// DefaultSettings implements rule.Configurable. +func (r *Rule) DefaultSettings() map[string]any { + return map[string]any{ + "flavor": "", + } +} + +// Check implements rule.Rule. +func (r *Rule) Check(f *lint.File) []lint.Diagnostic { + if r.Flavor == 0 { + return nil + } + var diags []lint.Diagnostic + for _, found := range Detect(f) { + if r.Flavor.Supports(found.Feature) { + continue + } + diags = append(diags, lint.Diagnostic{ + File: f.Path, + Line: found.Line, + Column: found.Column, + RuleID: r.ID(), + RuleName: r.Name(), + Severity: lint.Warning, + Message: fmt.Sprintf("%s %s not supported by %s", + found.Feature.Name(), found.Feature.Verb(), r.Flavor), + }) + } + return diags +} + +var ( + _ rule.Configurable = (*Rule)(nil) + _ rule.Defaultable = (*Rule)(nil) +) diff --git a/internal/rules/markdownflavor/rule_test.go b/internal/rules/markdownflavor/rule_test.go new file mode 100644 index 000000000..7bffa3e28 --- /dev/null +++ b/internal/rules/markdownflavor/rule_test.go @@ -0,0 +1,174 @@ +package markdownflavor + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/jeduden/mdsmith/internal/lint" + "github.com/jeduden/mdsmith/internal/rule" +) + +func TestRuleIdentity(t *testing.T) { + r := &Rule{} + assert.Equal(t, "MDS034", r.ID()) + assert.Equal(t, "markdown-flavor", r.Name()) + assert.Equal(t, "meta", r.Category()) +} + +func TestRuleIsConfigurableAndDefaultable(t *testing.T) { + var r rule.Rule = &Rule{} + _, ok := r.(rule.Configurable) + assert.True(t, ok, "Rule must implement rule.Configurable") + _, ok = r.(rule.Defaultable) + assert.True(t, ok, "Rule must implement rule.Defaultable") +} + +func TestRuleDisabledByDefault(t *testing.T) { + r := &Rule{} + assert.False(t, r.EnabledByDefault()) +} + +func TestRuleDefaultSettings(t *testing.T) { + r := &Rule{} + ds := r.DefaultSettings() + assert.Equal(t, "", ds["flavor"]) +} + +func TestRuleApplySettingsValid(t *testing.T) { + for _, name := range []string{"commonmark", "gfm", "goldmark"} { + t.Run(name, func(t *testing.T) { + r := &Rule{} + err := r.ApplySettings(map[string]any{"flavor": name}) + require.NoError(t, err) + assert.Equal(t, name, r.Flavor.String()) + }) + } +} + +func TestRuleApplySettingsInvalid(t *testing.T) { + tests := []struct { + name string + settings map[string]any + }{ + {"unknown key", map[string]any{"unknown": "x"}}, + {"bad flavor", map[string]any{"flavor": "markdown"}}, + {"non-string flavor", map[string]any{"flavor": 42}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r := &Rule{} + err := r.ApplySettings(tc.settings) + assert.Error(t, err) + }) + } +} + +func TestRuleCheckNoFlavorConfigured(t *testing.T) { + // When the rule is enabled but no flavor is set (empty default), + // Check must be a no-op rather than flagging everything. + r := &Rule{} + require.NoError(t, r.ApplySettings(r.DefaultSettings())) + f := mkFile(t, "# Hi {#id}\n\n| a |\n| - |\n") + assert.Empty(t, r.Check(f)) +} + +func TestRuleCheckCommonMark(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "commonmark"})) + + src := "# Head {#top}\n\n- [ ] task\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n" + + "~~old~~ https://example.com\n" + diags := r.Check(mkFile(t, src)) + + got := make(map[string]bool) + for _, d := range diags { + got[d.Message] = true + } + assert.True(t, got["heading IDs are not supported by commonmark"]) + assert.True(t, got["task lists are not supported by commonmark"]) + assert.True(t, got["tables are not supported by commonmark"]) + assert.True(t, got["strikethrough is not supported by commonmark"]) + assert.True(t, got["bare-URL autolinks are not supported by commonmark"]) +} + +func TestRuleCheckGFM(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "gfm"})) + + src := "# Head {#top}\n\n- [ ] task\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n" + + "~~old~~ https://example.com\n" + diags := r.Check(mkFile(t, src)) + + // GFM accepts tables, task lists, strikethrough, bare-URL autolinks. + for _, d := range diags { + assert.NotContains(t, d.Message, "tables") + assert.NotContains(t, d.Message, "task lists") + assert.NotContains(t, d.Message, "strikethrough") + assert.NotContains(t, d.Message, "bare-URL autolinks") + } + + // GFM rejects heading IDs. + found := false + for _, d := range diags { + if d.Message == "heading IDs are not supported by gfm" { + found = true + } + } + assert.True(t, found, "GFM should flag heading IDs") +} + +func TestRuleCheckGoldmark(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "goldmark"})) + + src := "# Head {#top}\n\n- [ ] task\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n" + + "~~old~~ https://example.com\n" + diags := r.Check(mkFile(t, src)) + + // goldmark accepts tables, task lists, strikethrough, bare URLs, + // AND heading IDs. The sample should produce no diagnostics. + for _, d := range diags { + t.Errorf("unexpected diagnostic for goldmark flavor: %s", d.Message) + } +} + +func TestRuleDiagnosticFields(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "commonmark"})) + f := mkFile(t, "| a | b |\n| - | - |\n| 1 | 2 |\n") + diags := r.Check(f) + require.Len(t, diags, 1) + d := diags[0] + assert.Equal(t, "MDS034", d.RuleID) + assert.Equal(t, "markdown-flavor", d.RuleName) + assert.Equal(t, lint.Warning, d.Severity) + assert.Equal(t, 1, d.Line) + assert.Equal(t, 1, d.Column) +} + +func TestRuleFootnotesDiagnostic(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "gfm"})) + f := mkFile(t, "Text.[^1]\n\n[^1]: note body\n") + diags := r.Check(f) + require.NotEmpty(t, diags) + // First footnote-related diagnostic must name the feature. + found := false + for _, d := range diags { + if d.Message == "footnotes are not supported by gfm" { + found = true + } + } + assert.True(t, found) +} + +func TestRuleDefinitionListsDiagnostic(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "gfm"})) + f := mkFile(t, "term\n: definition\n") + diags := r.Check(f) + require.Len(t, diags, 1) + assert.Equal(t, "definition lists are not supported by gfm", diags[0].Message) +} From 5f7ea4fb36da75ef146eb3b5c2c1f9370222a109 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 19:57:31 +0000 Subject: [PATCH 06/30] Plan 86: check off tasks completed in this slice Built-in feature detection, rule struct, and fixtures are done. Custom goldmark extensions (superscript, subscript, math block, math inline, abbreviations) and the fix pipeline remain pending. --- plan/86_markdown-flavor-validation.md | 56 +++++++++++++++------------ 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/plan/86_markdown-flavor-validation.md b/plan/86_markdown-flavor-validation.md index 2217e5089..5f3b6aaee 100644 --- a/plan/86_markdown-flavor-validation.md +++ b/plan/86_markdown-flavor-validation.md @@ -164,31 +164,37 @@ math, abbreviations. ## Tasks -1. Add feature enum and flavor registry in - `internal/rules/markdownflavor/features.go` -2. Write `SuperscriptExt` inline parser in - `internal/rules/markdownflavor/ext/superscript.go` -3. Write `SubscriptExt` inline parser in - `internal/rules/markdownflavor/ext/subscript.go` -4. Write `MathBlockExt` block parser in - `internal/rules/markdownflavor/ext/mathblock.go` -5. Write `MathInlineExt` inline parser in - `internal/rules/markdownflavor/ext/mathinline.go` -6. Write `AbbreviationExt` block parser + paragraph - transformer in - `internal/rules/markdownflavor/ext/abbreviation.go` -7. Add tests for all five custom extensions -8. Build dual parser with built-in + custom extensions -9. Add AST-based detectors for all 12 features -10. Implement `rule.go` with `Check()` and `Fix()` -11. Implement `rule.Configurable` for MDS034: add - `ApplySettings` and `DefaultSettings` for `flavor` -12. Implement `rule.Defaultable` (`EnabledByDefault` - returns `false`) so the rule is opt-in -13. Register as MDS034 in category `meta` -14. Add test fixtures in - `internal/rules/MDS034-markdown-flavor/` -15. Add rule README and update docs +- [x] Add feature enum and flavor registry in + `internal/rules/markdownflavor/features.go` +- [ ] Write `SuperscriptExt` inline parser in + `internal/rules/markdownflavor/ext/superscript.go` +- [ ] Write `SubscriptExt` inline parser in + `internal/rules/markdownflavor/ext/subscript.go` +- [ ] Write `MathBlockExt` block parser in + `internal/rules/markdownflavor/ext/mathblock.go` +- [ ] Write `MathInlineExt` inline parser in + `internal/rules/markdownflavor/ext/mathinline.go` +- [ ] Write `AbbreviationExt` block parser + paragraph + transformer in + `internal/rules/markdownflavor/ext/abbreviation.go` +- [ ] Add tests for all five custom extensions +- [x] Build dual parser with built-in extensions (custom + extensions pending) +- [x] Add AST-based detectors for the seven built-in + features (tables, task lists, strikethrough, bare-URL + autolinks, footnotes, definition lists, heading IDs); + the five custom features remain pending +- [x] Implement `rule.go` with `Check()`; `Fix()` is + pending +- [x] Implement `rule.Configurable` for MDS034: add + `ApplySettings` and `DefaultSettings` for `flavor` +- [x] Implement `rule.Defaultable` (`EnabledByDefault` + returns `false`) so the rule is opt-in +- [x] Register as MDS034 in category `meta` +- [x] Add test fixtures in + `internal/rules/MDS034-markdown-flavor/` for the seven + built-in features +- [x] Add rule README and update docs ## Acceptance Criteria From 8afc5f2914ef81cab064a7c286649c4daba8299b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 20:38:30 +0000 Subject: [PATCH 07/30] fix: address Copilot review on MDS034 detect.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues, all in internal/rules/markdownflavor/detect.go: - Finding doc comment claimed Line/Column were pre-adjusted to the lint.File numbering. They are body-relative; the engine's AdjustDiagnostics adds any front-matter LineOffset later. Reword the comment so the next detector author does not double-offset. - firstTextStart returned 0 on "no Text descendant", and the recursion test was 's >= 0' — meaning the first child without a Text under it would return 0 (a valid offset at the start of the file) and any later sibling holding the actual first Text was ignored. Switch to -1 as the not-found sentinel and check 's >= 0' against that. - Detect concatenated findings from detectFromDual and detectBareURLs without merging, so a bare URL on line 1 could appear after a footnote definition on line 5. Sort the combined slice by Start and add a regression test. --- internal/rules/markdownflavor/detect.go | 35 ++++++++++++++------ internal/rules/markdownflavor/detect_test.go | 14 ++++++++ 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/internal/rules/markdownflavor/detect.go b/internal/rules/markdownflavor/detect.go index d34281be3..b2500b504 100644 --- a/internal/rules/markdownflavor/detect.go +++ b/internal/rules/markdownflavor/detect.go @@ -2,6 +2,7 @@ package markdownflavor import ( "regexp" + "sort" "github.com/yuin/goldmark/ast" extast "github.com/yuin/goldmark/extension/ast" @@ -12,8 +13,11 @@ import ( // Finding records one detected feature use. // -// Line and Column are 1-based and reference the document body -// (already adjusted to match the main lint.File line numbering). +// Line and Column are 1-based positions within the parsed document +// body in f.Source. The engine's lint.File.AdjustDiagnostics applies +// any front-matter LineOffset later, so detectors and Rule.Check +// must report body-relative positions only. +// // Start and End bound the feature in f.Source and are used by Fix. type Finding struct { Feature Feature @@ -43,13 +47,20 @@ var bareURLPattern = regexp.MustCompile( ) // Detect runs every enabled feature detector against f and returns -// findings in document order. +// findings in document order. The dual-parser and bare-URL passes +// each emit in document order on their own, but the two streams must +// be merged: a bare URL on line 3 should sort before a footnote +// definition on line 5 even though detectFromDual runs first. func Detect(f *lint.File) []Finding { var out []Finding dualDoc := Parser().Parser().Parse(text.NewReader(f.Source)) out = append(out, detectFromDual(f, dualDoc)...) out = append(out, detectBareURLs(f)...) + + sort.SliceStable(out, func(i, j int) bool { + return out[i].Start < out[j].Start + }) return out } @@ -181,6 +192,9 @@ func nodeByteRange(n ast.Node) (int, int) { } } start := firstTextStart(n) + if start < 0 { + start = 0 + } return start, start } @@ -196,19 +210,20 @@ func lineStartOf(source []byte, offset int) int { return 0 } +// firstTextStart returns the byte offset of the first descendant Text +// node, or -1 when none exists. The sentinel matters: returning 0 on +// "not found" would point at the start of the file and shift inline +// findings to line 1, column 1. func firstTextStart(n ast.Node) int { + if t, ok := n.(*ast.Text); ok { + return t.Segment.Start + } for c := n.FirstChild(); c != nil; c = c.NextSibling() { - if t, ok := c.(*ast.Text); ok { - return t.Segment.Start - } if s := firstTextStart(c); s >= 0 { return s } } - if t, ok := n.(*ast.Text); ok { - return t.Segment.Start - } - return 0 + return -1 } // makeFinding converts a byte range to a Finding with line and column diff --git a/internal/rules/markdownflavor/detect_test.go b/internal/rules/markdownflavor/detect_test.go index 0f651e66c..5207fb51b 100644 --- a/internal/rules/markdownflavor/detect_test.go +++ b/internal/rules/markdownflavor/detect_test.go @@ -119,3 +119,17 @@ func TestDetectPlainCommonMark(t *testing.T) { fs := findings(t, src) assert.Empty(t, fs) } + +// TestDetectFindingsAreSortedByStart guards the merge ordering +// between detectFromDual and detectBareURLs: a bare URL in line 1 +// must sort before a footnote definition further down the file. +func TestDetectFindingsAreSortedByStart(t *testing.T) { + src := "https://example.com paragraph.[^1]\n\n[^1]: note body\n" + fs := findings(t, src) + require.GreaterOrEqual(t, len(fs), 2) + for i := 1; i < len(fs); i++ { + assert.LessOrEqual(t, fs[i-1].Start, fs[i].Start, + "finding %d (%v) precedes finding %d (%v) but has greater Start", + i-1, fs[i-1], i, fs[i]) + } +} From 8f6ac4fa6b99835e596941f9eee567570514b4cf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 20:50:09 +0000 Subject: [PATCH 08/30] fix: address Copilot re-review on MDS034 Three threads from the second Copilot pass: - bareURLPattern accepted only lowercase TLDs, so https://example.COM slipped through. Widen the TLD class to [a-zA-Z]+ and add a regression test. - The dual parser pulled in extension.Linkify but the bare-URL detector scans the main CommonMark parse with a regex, so Linkify was extra parse cost with no consumer. Drop it from the dual parser and remove the now-stale TestParserDetectsLinkifiedAutolink guard. The README bullet that hinted at AutoLink detection is removed in the next change. - README claimed every detected feature came from the goldmark AST with a built-in extension. That was false for bare URLs. Split the section so the six AST-driven features and the bare-URL regex pass are described separately. --- internal/rules/MDS034-markdown-flavor/README.md | 13 +++++++++++-- internal/rules/markdownflavor/detect.go | 6 ++++-- internal/rules/markdownflavor/detect_test.go | 14 ++++++++++++++ internal/rules/markdownflavor/parser.go | 12 ++++++++---- internal/rules/markdownflavor/parser_test.go | 7 ------- 5 files changed, 37 insertions(+), 15 deletions(-) diff --git a/internal/rules/MDS034-markdown-flavor/README.md b/internal/rules/MDS034-markdown-flavor/README.md index f5ba38626..0e7dc48c7 100644 --- a/internal/rules/MDS034-markdown-flavor/README.md +++ b/internal/rules/MDS034-markdown-flavor/README.md @@ -51,8 +51,17 @@ rules: ## Detected features MDS034 tracks seven syntax features in this -increment. Each one is detected via the goldmark -AST with a built-in extension enabled: +increment. + +Six are detected from the goldmark AST. Each +relies on its built-in extension: table, +strikethrough, task list, footnote, definition +list, and the heading-ID attribute parser. + +Bare-URL autolinks are detected separately. The +detector scans text nodes from the main parse for +URL-shaped text. It skips links, autolinks, code +spans, and code blocks. | Feature | commonmark | gfm | goldmark | |--------------------|------------|-----|----------| diff --git a/internal/rules/markdownflavor/detect.go b/internal/rules/markdownflavor/detect.go index b2500b504..c1c402a82 100644 --- a/internal/rules/markdownflavor/detect.go +++ b/internal/rules/markdownflavor/detect.go @@ -39,10 +39,12 @@ type HeadingIDExtra struct { // 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. +// match anywhere inside a Text segment. The TLD class accepts both +// upper- and lowercase ASCII so URLs like https://example.COM are +// flagged the same way as their lowercase form. var bareURLPattern = regexp.MustCompile( `(?:http|https|ftp)://[-a-zA-Z0-9@:%._+~#=]{1,256}` + - `\.[a-z]+(?::\d+)?(?:[/#?][-a-zA-Z0-9@:%_+.~#$!?&/=();,'">^{}\[\]` + + `\.[a-zA-Z]+(?::\d+)?(?:[/#?][-a-zA-Z0-9@:%_+.~#$!?&/=();,'">^{}\[\]` + "`" + `]*)?`, ) diff --git a/internal/rules/markdownflavor/detect_test.go b/internal/rules/markdownflavor/detect_test.go index 5207fb51b..386d4cc8d 100644 --- a/internal/rules/markdownflavor/detect_test.go +++ b/internal/rules/markdownflavor/detect_test.go @@ -67,6 +67,20 @@ func TestDetectBareURLAutolink(t *testing.T) { require.True(t, hasFeature(fs, FeatureBareURLAutolinks)) } +// TestDetectBareURLAutolinkUppercaseTLD guards the regex character +// class for the TLD: matches must be case-insensitive so SHOUTY +// domains and mixed-case TLDs are still flagged. +func TestDetectBareURLAutolinkUppercaseTLD(t *testing.T) { + for _, src := range []string{ + "See https://example.COM for details.\n", + "See https://EXAMPLE.CoM for details.\n", + } { + fs := findings(t, src) + assert.True(t, hasFeature(fs, FeatureBareURLAutolinks), + "uppercase TLD should be flagged: %q", src) + } +} + func TestDetectIgnoresBracketedAutolink(t *testing.T) { fs := findings(t, "See for details.\n") assert.False(t, hasFeature(fs, FeatureBareURLAutolinks), diff --git a/internal/rules/markdownflavor/parser.go b/internal/rules/markdownflavor/parser.go index 4176c2f5f..bf61e7f1a 100644 --- a/internal/rules/markdownflavor/parser.go +++ b/internal/rules/markdownflavor/parser.go @@ -14,9 +14,14 @@ var ( ) // Parser returns the shared goldmark parser used for dual parsing. -// It enables every built-in goldmark extension relevant to MDS034 -// feature detection (table, strikethrough, task list, footnote, -// definition list, linkify) and the heading-ID attribute parser. +// It enables every built-in goldmark extension MDS034 actually needs +// for AST-based feature detection (table, strikethrough, task list, +// footnote, definition list) and the heading-ID attribute parser. +// +// Linkify is intentionally not enabled here: bare-URL autolinks are +// detected separately in detectBareURLs by scanning Text nodes from +// the main CommonMark parse, so adding Linkify would only duplicate +// work without changing the result. // // The parser is detection-only: we never render its output. Storing // it as a package-level singleton avoids rebuilding the parser on @@ -30,7 +35,6 @@ func Parser() goldmark.Markdown { extension.TaskList, extension.Footnote, extension.DefinitionList, - extension.Linkify, ), goldmark.WithParserOptions( parser.WithAttribute(), diff --git a/internal/rules/markdownflavor/parser_test.go b/internal/rules/markdownflavor/parser_test.go index b7e18d91e..83495aa5e 100644 --- a/internal/rules/markdownflavor/parser_test.go +++ b/internal/rules/markdownflavor/parser_test.go @@ -51,13 +51,6 @@ func TestParserDetectsDefinitionList(t *testing.T) { "expected definition-list node in dual-parser AST") } -func TestParserDetectsLinkifiedAutolink(t *testing.T) { - src := []byte("See https://example.com for details.\n") - doc := parseSource(t, src) - assert.True(t, containsKind(doc, ast.KindAutoLink), - "expected auto-link node for bare URL in dual-parser AST") -} - func TestParserDetectsHeadingAttribute(t *testing.T) { src := []byte("# Heading {#custom-id}\n") doc := parseSource(t, src) From 00331b0ad5179ae5b5a26cee0fb111a7bd335c90 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 13:29:00 +0000 Subject: [PATCH 09/30] fix: address Copilot review round 3 on MDS034 Two threads from the third Copilot pass: - features.go: the Feature constants doc comment told readers to keep the list in sync with a \`featureNames\` table that does not exist. Feature.Name and Feature.Verb are the actual sources of truth; point the comment at them so future edits stay aligned. - detect.go / rule.go: Rule.Check used to ask Detect for every feature, then discard the ones the configured flavor already supports. For files with flavor: gfm or goldmark that meant the bare-URL regex scan ran over every Text node for no reason. Expose DetectFiltered(f, accept) and have Rule.Check pass \`!flavor.Supports\` so unsupported detectors are the only ones invoked. When the filter rejects every dual-parse feature the goldmark re-parse is now skipped too. Added two regression tests covering both skip paths. --- internal/rules/markdownflavor/detect.go | 56 +++++++++++++++++--- internal/rules/markdownflavor/detect_test.go | 37 +++++++++++++ internal/rules/markdownflavor/features.go | 3 +- internal/rules/markdownflavor/rule.go | 11 ++-- 4 files changed, 94 insertions(+), 13 deletions(-) diff --git a/internal/rules/markdownflavor/detect.go b/internal/rules/markdownflavor/detect.go index c1c402a82..088f67dd6 100644 --- a/internal/rules/markdownflavor/detect.go +++ b/internal/rules/markdownflavor/detect.go @@ -48,17 +48,41 @@ var bareURLPattern = regexp.MustCompile( "`" + `]*)?`, ) -// Detect runs every enabled feature detector against f and returns -// findings in document order. The dual-parser and bare-URL passes -// each emit in document order on their own, but the two streams must -// be merged: a bare URL on line 3 should sort before a footnote -// definition on line 5 even though detectFromDual runs first. +// Detect runs every feature detector against f and returns findings +// in document order. Use DetectFiltered to skip detectors for +// features the caller is not interested in. func Detect(f *lint.File) []Finding { + return DetectFiltered(f, nil) +} + +// DetectFiltered is Detect with an optional accept predicate. When +// accept is non-nil, only features for which accept(feat) returns +// true are detected; whole-file scans are skipped when none of their +// features are accepted. Passing nil accepts every feature. +// +// The dual-parser and bare-URL passes each emit in document order +// on their own, but the two streams must be merged: a bare URL on +// line 3 should sort before a footnote definition on line 5 even +// though detectFromDual runs first. +func DetectFiltered(f *lint.File, accept func(Feature) bool) []Finding { + keep := func(feat Feature) bool { + return accept == nil || accept(feat) + } + var out []Finding - dualDoc := Parser().Parser().Parse(text.NewReader(f.Source)) - out = append(out, detectFromDual(f, dualDoc)...) - out = append(out, detectBareURLs(f)...) + if anyDualFeatureAccepted(keep) { + dualDoc := Parser().Parser().Parse(text.NewReader(f.Source)) + for _, fin := range detectFromDual(f, dualDoc) { + if keep(fin.Feature) { + out = append(out, fin) + } + } + } + + if keep(FeatureBareURLAutolinks) { + out = append(out, detectBareURLs(f)...) + } sort.SliceStable(out, func(i, j int) bool { return out[i].Start < out[j].Start @@ -66,6 +90,22 @@ func Detect(f *lint.File) []Finding { return out } +// anyDualFeatureAccepted reports whether any feature detected by the +// dual-parser pass is wanted. Lets DetectFiltered skip the goldmark +// re-parse when every feature it would detect is already supported +// by the target flavor. +func anyDualFeatureAccepted(keep func(Feature) bool) bool { + for _, feat := range []Feature{ + FeatureTables, FeatureTaskLists, FeatureStrikethrough, + FeatureFootnotes, FeatureDefinitionLists, FeatureHeadingIDs, + } { + if keep(feat) { + return true + } + } + return false +} + // detectFromDual walks the dual-parser tree for all extension-based // features (tables, strikethrough, task lists, footnotes, definition // lists, heading IDs). diff --git a/internal/rules/markdownflavor/detect_test.go b/internal/rules/markdownflavor/detect_test.go index 386d4cc8d..989fac9d9 100644 --- a/internal/rules/markdownflavor/detect_test.go +++ b/internal/rules/markdownflavor/detect_test.go @@ -134,6 +134,43 @@ func TestDetectPlainCommonMark(t *testing.T) { assert.Empty(t, fs) } +// TestDetectFilteredSkipsBareURLs asserts that DetectFiltered skips +// the bare-URL regex scan when the caller marks FeatureBareURLAutolinks +// as accepted — which is what Rule.Check does for flavor: gfm or +// flavor: goldmark. Without the skip the scan would run on every file +// even though its findings would be discarded. +func TestDetectFilteredSkipsBareURLs(t *testing.T) { + src := "See https://example.com for details.\n\n~~old~~\n" + // Accept every feature except bare-URL autolinks — mirrors + // Rule.Check under flavor: gfm or flavor: goldmark. + accept := func(feat Feature) bool { + return feat != FeatureBareURLAutolinks + } + fs := DetectFiltered(mkFile(t, src), accept) + for _, f := range fs { + assert.NotEqual(t, FeatureBareURLAutolinks, f.Feature, + "bare-URL findings must be suppressed when caller skips them") + } + assert.True(t, hasFeature(fs, FeatureStrikethrough), + "accepted features are still returned") +} + +// TestDetectFilteredSkipsDualParseWhenAllSupported verifies that +// DetectFiltered avoids the goldmark re-parse entirely when every +// feature the dual pass could emit is accepted by the caller. +func TestDetectFilteredSkipsDualParseWhenAllSupported(t *testing.T) { + src := "# Title {#id}\n\n| a |\n| - |\n| 1 |\n\n~~x~~ and [^1]\n\n[^1]: note\n" + // Accept every dual-parser feature; ask only for bare URLs. + accept := func(feat Feature) bool { + return feat == FeatureBareURLAutolinks + } + fs := DetectFiltered(mkFile(t, src), accept) + for _, f := range fs { + assert.Equal(t, FeatureBareURLAutolinks, f.Feature, + "dual-parser features must be suppressed when all are accepted") + } +} + // TestDetectFindingsAreSortedByStart guards the merge ordering // between detectFromDual and detectBareURLs: a bare URL in line 1 // must sort before a footnote definition further down the file. diff --git a/internal/rules/markdownflavor/features.go b/internal/rules/markdownflavor/features.go index d96262c7a..d2a05f5e4 100644 --- a/internal/rules/markdownflavor/features.go +++ b/internal/rules/markdownflavor/features.go @@ -47,7 +47,8 @@ func ParseFlavor(s string) (Flavor, bool) { // across flavors. type Feature int -// Feature constants. Keep in sync with AllFeatures and featureNames. +// Feature constants. Keep in sync with AllFeatures, Feature.Name, +// and Feature.Verb. const ( FeatureTables Feature = iota FeatureTaskLists diff --git a/internal/rules/markdownflavor/rule.go b/internal/rules/markdownflavor/rule.go index 395d2e272..60d43410a 100644 --- a/internal/rules/markdownflavor/rule.go +++ b/internal/rules/markdownflavor/rule.go @@ -69,11 +69,14 @@ func (r *Rule) Check(f *lint.File) []lint.Diagnostic { if r.Flavor == 0 { return nil } + // Only ask detectors about features this flavor rejects. Detectors + // like the bare-URL regex scan then skip large files entirely when + // the flavor (gfm, goldmark) accepts them. + unsupported := func(feat Feature) bool { + return !r.Flavor.Supports(feat) + } var diags []lint.Diagnostic - for _, found := range Detect(f) { - if r.Flavor.Supports(found.Feature) { - continue - } + for _, found := range DetectFiltered(f, unsupported) { diags = append(diags, lint.Diagnostic{ File: f.Path, Line: found.Line, From 8fec493ce8f0742881fbf1ac366efcbc71111156 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 15:48:45 +0000 Subject: [PATCH 10/30] fix: trim all ASCII whitespace after heading attribute Copilot round 4: findHeadingID trimmed only ASCII space, so a heading ending with a tab or CRLF left whitespace inside the attribute-block End offset. Accept space, tab, CR, vertical tab, and form feed via a small isASCIISpace helper and add a regression test covering tab and CRLF trailers. --- internal/rules/markdownflavor/detect.go | 16 ++++++++++++++-- internal/rules/markdownflavor/detect_test.go | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/internal/rules/markdownflavor/detect.go b/internal/rules/markdownflavor/detect.go index 088f67dd6..a68c3e981 100644 --- a/internal/rules/markdownflavor/detect.go +++ b/internal/rules/markdownflavor/detect.go @@ -275,6 +275,17 @@ func makeFinding(f *lint.File, feat Feature, start, end int) Finding { return Finding{Feature: feat, Line: line, Column: col, Start: start, End: end} } +// isASCIISpace reports whether b is one of the ASCII whitespace +// bytes that can legitimately appear after a heading's attribute +// block before the line's newline. +func isASCIISpace(b byte) bool { + switch b { + case ' ', '\t', '\r', '\v', '\f': + return true + } + return false +} + func lineCol(source []byte, offset int) (int, int) { if offset < 0 { offset = 0 @@ -344,8 +355,9 @@ func findHeadingID(f *lint.File, h *ast.Heading) (Finding, bool) { } attrStart := brace attrEnd := lineEnd - // Trim trailing whitespace so fixes keep tidy line endings. - for attrEnd > attrStart && f.Source[attrEnd-1] == ' ' { + // Trim trailing ASCII whitespace so fixes keep tidy line endings + // even when the heading line ends with a tab or CRLF. + for attrEnd > attrStart && isASCIISpace(f.Source[attrEnd-1]) { attrEnd-- } line, col := lineCol(f.Source, attrStart) diff --git a/internal/rules/markdownflavor/detect_test.go b/internal/rules/markdownflavor/detect_test.go index 989fac9d9..a2c196864 100644 --- a/internal/rules/markdownflavor/detect_test.go +++ b/internal/rules/markdownflavor/detect_test.go @@ -111,6 +111,25 @@ func TestDetectHeadingID(t *testing.T) { require.True(t, hasFeature(fs, FeatureHeadingIDs)) } +// TestDetectHeadingIDTrimsAllASCIIWhitespace guards the trailing- +// whitespace trim in findHeadingID: a heading that ends with a tab +// or CRLF \r before the newline must produce an End offset that +// stops at '}' rather than swallowing the whitespace. +func TestDetectHeadingIDTrimsAllASCIIWhitespace(t *testing.T) { + for _, trailer := range []string{" \n", "\t\n", " \r\n", "\t \r\n"} { + src := "# Heading {#custom}" + trailer + fs := findings(t, src) + require.True(t, hasFeature(fs, FeatureHeadingIDs), "trailer=%q", trailer) + for _, f := range fs { + if f.Feature != FeatureHeadingIDs { + continue + } + assert.Equal(t, byte('}'), src[f.End-1], + "trailer=%q: End should stop at '}'", trailer) + } + } +} + func TestDetectMultipleFeatures(t *testing.T) { src := "# Title {#top}\n\n- [ ] task\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n" + "~~old~~ https://example.com\n" From be8d633a813cc34320433e1ada99c3340192ac16 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 15:55:09 +0000 Subject: [PATCH 11/30] chore: regenerate rule catalog after rebase The rebase onto main merged MDS034 alongside MDS035 and MDS036. Running \`mdsmith fix\` rebuilds the directory table so all three appear in sort order. --- internal/rules/index.md | 74 +++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/internal/rules/index.md b/internal/rules/index.md index 009801bdf..3a2f673e6 100644 --- a/internal/rules/index.md +++ b/internal/rules/index.md @@ -17,40 +17,42 @@ header: | |------|------|--------|-------------| row: "| [{id}]({filename}) | `{name}` | {status} | {description} |" ?> -| Rule | Name | Status | Description | -|---------------------------------------------------------------|--------------------------------------|-----------|-----------------------------------------------------------------------------------------| -| [MDS001](MDS001-line-length/README.md) | `line-length` | ready | Line exceeds maximum length. | -| [MDS002](MDS002-heading-style/README.md) | `heading-style` | ready | Heading style must be consistent. | -| [MDS003](MDS003-heading-increment/README.md) | `heading-increment` | ready | Heading levels should increment by one. No jumping from `#` to `###`. | -| [MDS004](MDS004-first-line-heading/README.md) | `first-line-heading` | ready | First line of the file should be a heading. | -| [MDS005](MDS005-no-duplicate-headings/README.md) | `no-duplicate-headings` | ready | No two headings should have the same text. | -| [MDS006](MDS006-no-trailing-spaces/README.md) | `no-trailing-spaces` | ready | No trailing whitespace at the end of lines. | -| [MDS007](MDS007-no-hard-tabs/README.md) | `no-hard-tabs` | ready | No tab characters. Use spaces instead. | -| [MDS008](MDS008-no-multiple-blanks/README.md) | `no-multiple-blanks` | ready | No more than one consecutive blank line. | -| [MDS009](MDS009-single-trailing-newline/README.md) | `single-trailing-newline` | ready | File must end with exactly one newline character. | -| [MDS010](MDS010-fenced-code-style/README.md) | `fenced-code-style` | ready | Fenced code blocks must use a consistent delimiter. | -| [MDS011](MDS011-fenced-code-language/README.md) | `fenced-code-language` | ready | Fenced code blocks must specify a language. | -| [MDS012](MDS012-no-bare-urls/README.md) | `no-bare-urls` | ready | URLs must be wrapped in angle brackets or as a link, not left bare. | -| [MDS013](MDS013-blank-line-around-headings/README.md) | `blank-line-around-headings` | ready | Headings must have a blank line before and after. | -| [MDS014](MDS014-blank-line-around-lists/README.md) | `blank-line-around-lists` | ready | Lists must have a blank line before and after. | -| [MDS015](MDS015-blank-line-around-fenced-code/README.md) | `blank-line-around-fenced-code` | ready | Fenced code blocks must have a blank line before and after. | -| [MDS016](MDS016-list-indent/README.md) | `list-indent` | ready | List items must use consistent indentation. | -| [MDS017](MDS017-no-trailing-punctuation-in-heading/README.md) | `no-trailing-punctuation-in-heading` | ready | Headings should not end with punctuation. | -| [MDS018](MDS018-no-emphasis-as-heading/README.md) | `no-emphasis-as-heading` | ready | Don't use bold or emphasis on a standalone line as a heading substitute. | -| [MDS019](MDS019-catalog/README.md) | `catalog` | ready | Catalog content must reflect selected front matter fields from files matching its glob. | -| [MDS020](MDS020-required-structure/README.md) | `required-structure` | ready | Document structure and front matter must match its schema. | -| [MDS021](MDS021-include/README.md) | `include` | ready | Include section content must match the referenced file. | -| [MDS022](MDS022-max-file-length/README.md) | `max-file-length` | ready | File must not exceed maximum number of lines. | -| [MDS023](MDS023-paragraph-readability/README.md) | `paragraph-readability` | ready | Paragraph readability index must not exceed a threshold. | -| [MDS024](MDS024-paragraph-structure/README.md) | `paragraph-structure` | ready | Paragraphs must not exceed sentence and word limits. | -| [MDS025](MDS025-table-format/README.md) | `table-format` | ready | Tables must have consistent column widths and padding. | -| [MDS026](MDS026-table-readability/README.md) | `table-readability` | ready | Tables must stay within readability complexity limits. | -| [MDS027](MDS027-cross-file-reference-integrity/README.md) | `cross-file-reference-integrity` | ready | Links to local files and heading anchors must resolve. | -| [MDS028](MDS028-token-budget/README.md) | `token-budget` | ready | File must not exceed a token budget. | -| [MDS029](MDS029-conciseness-scoring/README.md) | `conciseness-scoring` | not-ready | Paragraph conciseness score must not fall below a threshold. | -| [MDS030](MDS030-empty-section-body/README.md) | `empty-section-body` | ready | Section headings must include meaningful body content. | -| [MDS031](MDS031-unclosed-code-block/README.md) | `unclosed-code-block` | ready | Fenced code blocks must have a closing fence delimiter. | -| [MDS032](MDS032-no-empty-alt-text/README.md) | `no-empty-alt-text` | ready | Images must have non-empty alt text for accessibility. | -| [MDS033](MDS033-directory-structure/README.md) | `directory-structure` | ready | Markdown files must exist only in explicitly allowed directories. | -| [MDS034](MDS034-markdown-flavor/README.md) | `markdown-flavor` | ready | Flags Markdown syntax that the declared target flavor does not render. | +| Rule | Name | Status | Description | +|---------------------------------------------------------------|--------------------------------------|-----------|-----------------------------------------------------------------------------------------------| +| [MDS001](MDS001-line-length/README.md) | `line-length` | ready | Line exceeds maximum length. | +| [MDS002](MDS002-heading-style/README.md) | `heading-style` | ready | Heading style must be consistent. | +| [MDS003](MDS003-heading-increment/README.md) | `heading-increment` | ready | Heading levels should increment by one. No jumping from `#` to `###`. | +| [MDS004](MDS004-first-line-heading/README.md) | `first-line-heading` | ready | First line of the file should be a heading. | +| [MDS005](MDS005-no-duplicate-headings/README.md) | `no-duplicate-headings` | ready | No two headings should have the same text. | +| [MDS006](MDS006-no-trailing-spaces/README.md) | `no-trailing-spaces` | ready | No trailing whitespace at the end of lines. | +| [MDS007](MDS007-no-hard-tabs/README.md) | `no-hard-tabs` | ready | No tab characters. Use spaces instead. | +| [MDS008](MDS008-no-multiple-blanks/README.md) | `no-multiple-blanks` | ready | No more than one consecutive blank line. | +| [MDS009](MDS009-single-trailing-newline/README.md) | `single-trailing-newline` | ready | File must end with exactly one newline character. | +| [MDS010](MDS010-fenced-code-style/README.md) | `fenced-code-style` | ready | Fenced code blocks must use a consistent delimiter. | +| [MDS011](MDS011-fenced-code-language/README.md) | `fenced-code-language` | ready | Fenced code blocks must specify a language. | +| [MDS012](MDS012-no-bare-urls/README.md) | `no-bare-urls` | ready | URLs must be wrapped in angle brackets or as a link, not left bare. | +| [MDS013](MDS013-blank-line-around-headings/README.md) | `blank-line-around-headings` | ready | Headings must have a blank line before and after. | +| [MDS014](MDS014-blank-line-around-lists/README.md) | `blank-line-around-lists` | ready | Lists must have a blank line before and after. | +| [MDS015](MDS015-blank-line-around-fenced-code/README.md) | `blank-line-around-fenced-code` | ready | Fenced code blocks must have a blank line before and after. | +| [MDS016](MDS016-list-indent/README.md) | `list-indent` | ready | List items must use consistent indentation. | +| [MDS017](MDS017-no-trailing-punctuation-in-heading/README.md) | `no-trailing-punctuation-in-heading` | ready | Headings should not end with punctuation. | +| [MDS018](MDS018-no-emphasis-as-heading/README.md) | `no-emphasis-as-heading` | ready | Don't use bold or emphasis on a standalone line as a heading substitute. | +| [MDS019](MDS019-catalog/README.md) | `catalog` | ready | Catalog content must reflect selected front matter fields from files matching its glob. | +| [MDS020](MDS020-required-structure/README.md) | `required-structure` | ready | Document structure and front matter must match its schema. | +| [MDS021](MDS021-include/README.md) | `include` | ready | Include section content must match the referenced file. | +| [MDS022](MDS022-max-file-length/README.md) | `max-file-length` | ready | File must not exceed maximum number of lines. | +| [MDS023](MDS023-paragraph-readability/README.md) | `paragraph-readability` | ready | Paragraph readability index must not exceed a threshold. | +| [MDS024](MDS024-paragraph-structure/README.md) | `paragraph-structure` | ready | Paragraphs must not exceed sentence and word limits. | +| [MDS025](MDS025-table-format/README.md) | `table-format` | ready | Tables must have consistent column widths and padding. | +| [MDS026](MDS026-table-readability/README.md) | `table-readability` | ready | Tables must stay within readability complexity limits. | +| [MDS027](MDS027-cross-file-reference-integrity/README.md) | `cross-file-reference-integrity` | ready | Links to local files and heading anchors must resolve. | +| [MDS028](MDS028-token-budget/README.md) | `token-budget` | ready | File must not exceed a token budget. | +| [MDS029](MDS029-conciseness-scoring/README.md) | `conciseness-scoring` | not-ready | Paragraph conciseness score must not fall below a threshold. | +| [MDS030](MDS030-empty-section-body/README.md) | `empty-section-body` | ready | Section headings must include meaningful body content. | +| [MDS031](MDS031-unclosed-code-block/README.md) | `unclosed-code-block` | ready | Fenced code blocks must have a closing fence delimiter. | +| [MDS032](MDS032-no-empty-alt-text/README.md) | `no-empty-alt-text` | ready | Images must have non-empty alt text for accessibility. | +| [MDS033](MDS033-directory-structure/README.md) | `directory-structure` | ready | Markdown files must exist only in explicitly allowed directories. | +| [MDS034](MDS034-markdown-flavor/README.md) | `markdown-flavor` | ready | Flags Markdown syntax that the declared target flavor does not render. | +| [MDS035](MDS035-toc-directive/README.md) | `toc-directive` | ready | Flag renderer-specific TOC directives that render as literal text on CommonMark and goldmark. | +| [MDS036](MDS036-max-section-length/README.md) | `max-section-length` | ready | Section length must not exceed per-level or per-heading limits. | From 7f0384c59dc9ca0063f59175b2cfd1adb17fc2de Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 15:59:59 +0000 Subject: [PATCH 12/30] fix: correct MDS034 Finding doc + test comment accuracy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Copilot threads from the post-rebase review: - The Finding doc claimed Start/End "bound the feature in f.Source" and were "used by Fix". Several constructors deliberately emit convenience anchors: blockFinding widens Start to the line start, findingFromBlock emits End == Start, and nodeByteRange returns (start, start) for inline containers without a segment. Reword the comment so a future Fix author knows the anchors are best- effort and must be re-derived from f.Source if an exact span is needed. - The TestDetectFilteredSkipsBareURLs comment said the predicate "mirrors Rule.Check under flavor: gfm / goldmark" — but \`feat != FeatureBareURLAutolinks\` still accepts strikethrough etc., which \`!flavor.Supports\` under those flavors would reject. Clarify that the test exercises the skip path for detectBareURLs in isolation and is narrower than any specific flavor. --- internal/rules/markdownflavor/detect.go | 9 ++++++++- internal/rules/markdownflavor/detect_test.go | 17 ++++++++++------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/internal/rules/markdownflavor/detect.go b/internal/rules/markdownflavor/detect.go index a68c3e981..8f65c9833 100644 --- a/internal/rules/markdownflavor/detect.go +++ b/internal/rules/markdownflavor/detect.go @@ -18,7 +18,14 @@ import ( // any front-matter LineOffset later, so detectors and Rule.Check // must report body-relative positions only. // -// Start and End bound the feature in f.Source and are used by Fix. +// Start and End are best-effort byte anchors in f.Source. They cover +// the feature span precisely only for features whose Fix needs an +// exact range (currently heading IDs via Extra, and bare URLs). +// Other findings use convenience anchors: block features widen Start +// to the start of the containing line, and inline extension nodes +// without a source segment emit a zero-length anchor (End == Start). +// Any future Fix implementation that needs a precise span must +// recompute it from f.Source rather than trusting End - Start. type Finding struct { Feature Feature Line int diff --git a/internal/rules/markdownflavor/detect_test.go b/internal/rules/markdownflavor/detect_test.go index a2c196864..b1a478252 100644 --- a/internal/rules/markdownflavor/detect_test.go +++ b/internal/rules/markdownflavor/detect_test.go @@ -153,15 +153,18 @@ func TestDetectPlainCommonMark(t *testing.T) { assert.Empty(t, fs) } -// TestDetectFilteredSkipsBareURLs asserts that DetectFiltered skips -// the bare-URL regex scan when the caller marks FeatureBareURLAutolinks -// as accepted — which is what Rule.Check does for flavor: gfm or -// flavor: goldmark. Without the skip the scan would run on every file -// even though its findings would be discarded. +// TestDetectFilteredSkipsBareURLs exercises the skip path for the +// bare-URL regex scan: when the caller rejects +// FeatureBareURLAutolinks, detectBareURLs must not run even though +// other features (here strikethrough) are still accepted. The scenario +// is narrower than any specific flavor; Rule.Check under flavor: gfm or +// goldmark passes a different predicate (!flavor.Supports) that would +// also reject the strikethrough branch. func TestDetectFilteredSkipsBareURLs(t *testing.T) { src := "See https://example.com for details.\n\n~~old~~\n" - // Accept every feature except bare-URL autolinks — mirrors - // Rule.Check under flavor: gfm or flavor: goldmark. + // Reject bare-URL autolinks; keep every other feature so the + // strikethrough assertion can verify the non-bare-URL path + // still runs. accept := func(feat Feature) bool { return feat != FeatureBareURLAutolinks } From 6307a4eed2cacdeac855c0cee156593ccac032cb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 18:46:31 +0000 Subject: [PATCH 13/30] fix: drop redundant MDS033 warn priming helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round: the test harness did two things back-to- back that both neutralize MDS033's warn-once sync.Once — the main-side helper that fires the warning before running fixtures, and the in-PR helper that consumes the guard directly. Remove the priming helper and keep only SilenceConfigWarningForTesting; the comment in TestRuleFixtures now reflects the single path. --- internal/integration/rules_test.go | 28 ++-------------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/internal/integration/rules_test.go b/internal/integration/rules_test.go index 332b433e8..5a7acde7d 100644 --- a/internal/integration/rules_test.go +++ b/internal/integration/rules_test.go @@ -141,15 +141,13 @@ func applySettingsToRule( } func TestRuleFixtures(t *testing.T) { - primeDirectoryStructureWarnOnce(t) dirs := discoverFixtureDirs(t) // MDS033's "no allowed patterns" warning is gated by a // process-level sync.Once. After MDS033's own fixtures run, the // rule is left in configured=true / allowed=[] state by the - // defaults-based cleanup. That would fire the warning on the - // first checkAllRules walk in any later rule's tests. Consume - // the guard up front so the leak cannot surface. + // defaults-based cleanup. Silence the guard explicitly so later + // checkAllRules walks do not depend on warning emission order. directorystructure.SilenceConfigWarningForTesting() for _, dir := range dirs { @@ -397,28 +395,6 @@ func runFixSingleFile( // --- shared helpers --- -// primeDirectoryStructureWarnOnce fires MDS033's "no allowed patterns" -// sync.Once warning up front. Without this, test cleanup in -// applySettingsToRule leaves MDS033 in a configured-but-empty state, -// so the warning would fire the first time any post-MDS033 fixture -// calls checkAllRules, producing a spurious diagnostic. -func primeDirectoryStructureWarnOnce(t *testing.T) { - t.Helper() - r := rule.ByID("MDS033") - if r == nil { - return - } - cr, ok := r.(rule.Configurable) - if !ok { - return - } - require.NoError(t, cr.ApplySettings(map[string]any{"allowed": []any{}})) - f, err := lint.NewFile("prime.md", []byte("# x\n")) - require.NoError(t, err) - _ = r.Check(f) - require.NoError(t, cr.ApplySettings(cr.DefaultSettings())) -} - func discoverFixtureDirs(t *testing.T) []string { t.Helper() dirs, err := filepath.Glob("../../internal/rules/MDS*-*") From 5877d4fe61bbdf84db45f2ec95216f6bb4a94326 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 18:58:35 +0000 Subject: [PATCH 14/30] Plan 86: mark acceptance criteria actually verified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check off the eight criteria that the current slice satisfies: error message format, opt-in default, config-error on bad flavor, non-fixable-only diagnostics, bare-URL behaviour under all three flavors, test-suite green, lint clean. Leave the three per-flavor feature-set criteria and the Fix-pipeline criterion unchecked, with inline notes recording which features are covered today and which remain pending (the five custom-extension features and auto-fix). Plan status stays 🔳 because the outstanding work still blocks ✅. --- plan/86_markdown-flavor-validation.md | 32 +++++++++++++++++++-------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/plan/86_markdown-flavor-validation.md b/plan/86_markdown-flavor-validation.md index 5f3b6aaee..5c66d94d6 100644 --- a/plan/86_markdown-flavor-validation.md +++ b/plan/86_markdown-flavor-validation.md @@ -203,30 +203,44 @@ math, abbreviations. definition lists, heading IDs, superscript, subscript, math blocks, math inline, and abbreviations + - [ ] `flavor: gfm` accepts tables, task lists, strikethrough, and bare-URL autolinks; flags footnotes, definition lists, heading IDs, superscript, subscript, math blocks, math inline, and abbreviations + - [ ] `flavor: goldmark` accepts tables, task lists, strikethrough, bare-URL autolinks, and heading IDs; flags footnotes, definition lists, superscript, subscript, math blocks, math inline, and abbreviations -- [ ] Error messages name the unsupported feature and + +- [x] Error messages name the unsupported feature and the configured flavor - [ ] `mdsmith fix` auto-fixes fixable features -- [ ] Non-fixable features produce diagnostics only -- [ ] Invalid flavor name produces a config error -- [ ] Rule is disabled by default (opt-in) -- [ ] With `flavor: commonmark`, MDS034 reports bare + +- [x] Non-fixable features produce diagnostics only +- [x] Invalid flavor name produces a config error +- [x] Rule is disabled by default (opt-in) +- [x] With `flavor: commonmark`, MDS034 reports bare URLs as unsupported autolinks -- [ ] With `flavor: gfm` or `flavor: goldmark`, +- [x] With `flavor: gfm` or `flavor: goldmark`, MDS034 treats bare URLs as supported syntax and does not emit a flavor diagnostic for them -- [ ] MDS034 does not emit a duplicate bare-URL +- [x] MDS034 does not emit a duplicate bare-URL diagnostic when the configured flavor supports bare URLs, even if MDS012 still enforces its own bare-URL style rule -- [ ] All tests pass: `go test ./...` -- [ ] `go tool golangci-lint run` reports no issues +- [x] All tests pass: `go test ./...` +- [x] `go tool golangci-lint run` reports no issues From 15aa7977467023667b2a54bce6500a50335f0c6e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 19:06:22 +0000 Subject: [PATCH 15/30] Plan 86: add SuperscriptExt custom goldmark extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detection-only inline parser for \`^text^\` spans using goldmark's delimiter framework. A run of exactly one \`^\` opens or closes a superscript; longer runs (\`^^\`, \`^^^\`) stay literal, and a parser call that starts immediately after another \`^\` is also rejected so a stray single \`^\` inside a longer run cannot start a bogus span. No HTML renderer is registered — MDS034 only walks the dual-parser AST to detect usage. --- .../rules/markdownflavor/ext/superscript.go | 102 ++++++++++++++++++ .../markdownflavor/ext/superscript_test.go | 65 +++++++++++ 2 files changed, 167 insertions(+) create mode 100644 internal/rules/markdownflavor/ext/superscript.go create mode 100644 internal/rules/markdownflavor/ext/superscript_test.go diff --git a/internal/rules/markdownflavor/ext/superscript.go b/internal/rules/markdownflavor/ext/superscript.go new file mode 100644 index 000000000..99aaeb2ab --- /dev/null +++ b/internal/rules/markdownflavor/ext/superscript.go @@ -0,0 +1,102 @@ +// Package ext implements detection-only goldmark extensions used by +// MDS034 (markdown-flavor) to flag syntax that varies across Markdown +// flavors. Each extension parses its feature's syntax into a custom +// AST node; the rule walks the dual parser's tree and emits +// diagnostics. There is no HTML renderer — the nodes exist purely so +// the main rule can detect them by kind. +package ext + +import ( + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// SuperscriptNode is the AST node produced by the superscript parser +// for a `^text^` span. It carries no extra state; the surrounding +// content is stored as inline children. +type SuperscriptNode struct { + ast.BaseInline +} + +// KindSuperscript is the NodeKind of SuperscriptNode. +var KindSuperscript = ast.NewNodeKind("Superscript") + +// Kind implements ast.Node. +func (n *SuperscriptNode) Kind() ast.NodeKind { return KindSuperscript } + +// Dump implements ast.Node for debug output. +func (n *SuperscriptNode) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, nil, nil) +} + +// superscriptDelimiter drives goldmark's delimiter pairing over `^`. +// A `^` run of length 1 can open or close a superscript span; longer +// runs (e.g. `^^`) are rejected so they remain literal text. +type superscriptDelimiter struct{} + +func (p *superscriptDelimiter) IsDelimiter(b byte) bool { return b == '^' } + +func (p *superscriptDelimiter) CanOpenCloser(opener, closer *parser.Delimiter) bool { + return opener.Char == '^' && closer.Char == '^' +} + +func (p *superscriptDelimiter) OnMatch(consumes int) ast.Node { + return &SuperscriptNode{} +} + +var defaultSuperscriptDelimiter = &superscriptDelimiter{} + +// superscriptParser is the InlineParser registered with goldmark. +type superscriptParser struct{} + +// Trigger implements parser.InlineParser. +func (p *superscriptParser) Trigger() []byte { return []byte{'^'} } + +// Parse implements parser.InlineParser. It rejects `^^` and longer +// runs so they remain literal, and pushes a length-1 delimiter that +// goldmark's delimiter framework pairs with the next `^` in the same +// inline context. Spans containing whitespace (`^ x ^`) are rejected +// via CanOpen/CanClose, matching the emphasis-style left/right-flank +// rules that parser.ScanDelimiter computes. +func (p *superscriptParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { + before := block.PrecendingCharacter() + // `^^` / `^^^` and longer runs must stay literal. When goldmark + // advances into the middle of such a run, the `before` rune is + // `^` — reject those positions too so a stray single `^` inside + // a longer run does not start a bogus span. + if before == '^' { + return nil + } + line, segment := block.PeekLine() + node := parser.ScanDelimiter(line, before, 1, defaultSuperscriptDelimiter) + if node == nil || node.OriginalLength != 1 { + return nil + } + node.Segment = segment.WithStop(segment.Start + node.OriginalLength) + block.Advance(node.OriginalLength) + pc.PushDelimiter(node) + return node +} + +// CloseBlock implements parser.InlineParser. +func (p *superscriptParser) CloseBlock(parent ast.Node, pc parser.Context) {} + +// superscriptExt wires the parser into goldmark. It registers only +// the parser; MDS034 does not render, so no HTML renderer is added. +type superscriptExt struct{} + +// Superscript is the goldmark Extender that installs the superscript +// inline parser with a priority higher (numerically smaller) than +// emphasis (100) — `^` is otherwise literal, so no ambiguity, but +// keeping it tight avoids surprises. +var Superscript goldmark.Extender = &superscriptExt{} + +// Extend implements goldmark.Extender. +func (e *superscriptExt) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithInlineParsers( + util.Prioritized(&superscriptParser{}, 500), + )) +} diff --git a/internal/rules/markdownflavor/ext/superscript_test.go b/internal/rules/markdownflavor/ext/superscript_test.go new file mode 100644 index 000000000..d544b587b --- /dev/null +++ b/internal/rules/markdownflavor/ext/superscript_test.go @@ -0,0 +1,65 @@ +package ext + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/text" +) + +func walkFindKind(root ast.Node, kind ast.NodeKind) ast.Node { + var found ast.Node + _ = ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering && n.Kind() == kind { + found = n + return ast.WalkStop, nil + } + return ast.WalkContinue, nil + }) + return found +} + +func parseWith(t *testing.T, src string, exts ...goldmark.Extender) ast.Node { + t.Helper() + md := goldmark.New(goldmark.WithExtensions(exts...)) + doc := md.Parser().Parse(text.NewReader([]byte(src))) + require.NotNil(t, doc) + return doc +} + +func TestSuperscriptParses(t *testing.T) { + doc := parseWith(t, "x^2^ is fine.\n", Superscript) + assert.NotNil(t, walkFindKind(doc, KindSuperscript), + "expected Superscript node for x^2^") +} + +func TestSuperscriptSingleCharOnly(t *testing.T) { + // Double carets must not produce a Superscript node. + doc := parseWith(t, "x^^2^^\n", Superscript) + assert.Nil(t, walkFindKind(doc, KindSuperscript), + "^^...^^ must not match superscript") +} + +func TestSuperscriptUnbalancedCaret(t *testing.T) { + // A lone `^` with no closing pair must not produce a node. + doc := parseWith(t, "a^b c\n", Superscript) + assert.Nil(t, walkFindKind(doc, KindSuperscript)) +} + +func TestSuperscriptContainsContent(t *testing.T) { + doc := parseWith(t, "E = mc^2^\n", Superscript) + node := walkFindKind(doc, KindSuperscript) + require.NotNil(t, node) + // The child should carry the "2" text. + require.NotNil(t, node.FirstChild()) + assert.Equal(t, "2", string(node.Text([]byte("E = mc^2^\n")))) +} + +func TestSuperscriptInsideCodeIsIgnored(t *testing.T) { + doc := parseWith(t, "see `x^2^` here.\n", Superscript) + assert.Nil(t, walkFindKind(doc, KindSuperscript), + "content inside a code span must not be parsed as superscript") +} From de63460cf7a6874e92897832d7f4fea571f12a61 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 19:08:23 +0000 Subject: [PATCH 16/30] Plan 86: add SubscriptExt custom goldmark extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detection-only inline parser for single-tilde \`~text~\` spans. Registers at priority 400 — higher than built-in Strikethrough (500) — so goldmark's shared-trigger dispatch offers the position to subscript first. Subscript accepts only length-1 runs, so \`~~x~~\` falls through to Strikethrough. Tests also cover the coexistence case (\`~x~ + ~~y~~\` with both extensions enabled) and the usual boundary cases (unbalanced, inside code spans). --- .../rules/markdownflavor/ext/subscript.go | 97 +++++++++++++++++++ .../markdownflavor/ext/subscript_test.go | 53 ++++++++++ 2 files changed, 150 insertions(+) create mode 100644 internal/rules/markdownflavor/ext/subscript.go create mode 100644 internal/rules/markdownflavor/ext/subscript_test.go diff --git a/internal/rules/markdownflavor/ext/subscript.go b/internal/rules/markdownflavor/ext/subscript.go new file mode 100644 index 000000000..5c4e7d918 --- /dev/null +++ b/internal/rules/markdownflavor/ext/subscript.go @@ -0,0 +1,97 @@ +package ext + +import ( + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// SubscriptNode is the AST node produced by the subscript parser for +// a single-tilde `~text~` span. +type SubscriptNode struct { + ast.BaseInline +} + +// KindSubscript is the NodeKind of SubscriptNode. +var KindSubscript = ast.NewNodeKind("Subscript") + +// Kind implements ast.Node. +func (n *SubscriptNode) Kind() ast.NodeKind { return KindSubscript } + +// Dump implements ast.Node for debug output. +func (n *SubscriptNode) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, nil, nil) +} + +// subscriptDelimiter drives the delimiter framework over a single- +// tilde span. Goldmark's built-in strikethrough uses the same char +// but matches `~~…~~`; see subscriptParser.Parse for the length +// partition that keeps the two extensions from stepping on each +// other. +type subscriptDelimiter struct{} + +func (p *subscriptDelimiter) IsDelimiter(b byte) bool { return b == '~' } + +func (p *subscriptDelimiter) CanOpenCloser(opener, closer *parser.Delimiter) bool { + return opener.Char == '~' && closer.Char == '~' +} + +func (p *subscriptDelimiter) OnMatch(consumes int) ast.Node { return &SubscriptNode{} } + +var defaultSubscriptDelimiter = &subscriptDelimiter{} + +// subscriptParser is the InlineParser registered with goldmark. +type subscriptParser struct{} + +// Trigger implements parser.InlineParser. +func (p *subscriptParser) Trigger() []byte { return []byte{'~'} } + +// Parse implements parser.InlineParser. +// +// Subscript shares its trigger byte with goldmark's strikethrough +// extension. The two coexist through two complementary rules: +// +// - Subscript accepts only an exactly length-1 `~` run (`~text~`). +// `~~...~~` is rejected here so strikethrough — registered at +// a lower priority — gets to handle it next. +// - A Parse call that starts immediately after another `~` is +// rejected, so goldmark advancing one byte into the middle of +// `~~` cannot trigger a spurious subscript span. +func (p *subscriptParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { + before := block.PrecendingCharacter() + if before == '~' { + return nil + } + line, segment := block.PeekLine() + node := parser.ScanDelimiter(line, before, 1, defaultSubscriptDelimiter) + if node == nil || node.OriginalLength != 1 { + return nil + } + node.Segment = segment.WithStop(segment.Start + node.OriginalLength) + block.Advance(node.OriginalLength) + pc.PushDelimiter(node) + return node +} + +// CloseBlock implements parser.InlineParser. +func (p *subscriptParser) CloseBlock(parent ast.Node, pc parser.Context) {} + +// subscriptExt wires the parser into goldmark. +type subscriptExt struct{} + +// Subscript is the goldmark Extender that installs the subscript +// inline parser with a priority (400) higher — i.e. numerically +// smaller — than built-in strikethrough (500). Goldmark tries inline +// parsers for a shared trigger byte in priority order and stops at +// the first non-nil result, so subscript takes length-1 runs and +// strikethrough still handles length-2 runs. +var Subscript goldmark.Extender = &subscriptExt{} + +// Extend implements goldmark.Extender. +func (e *subscriptExt) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithInlineParsers( + util.Prioritized(&subscriptParser{}, 400), + )) +} diff --git a/internal/rules/markdownflavor/ext/subscript_test.go b/internal/rules/markdownflavor/ext/subscript_test.go new file mode 100644 index 000000000..4605bea4b --- /dev/null +++ b/internal/rules/markdownflavor/ext/subscript_test.go @@ -0,0 +1,53 @@ +package ext + +import ( + "testing" + + "github.com/stretchr/testify/assert" + extast "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/extension" +) + +func TestSubscriptParsesSingleTilde(t *testing.T) { + doc := parseWith(t, "H~2~O is water.\n", Subscript) + assert.NotNil(t, walkFindKind(doc, KindSubscript), + "expected Subscript node for H~2~O") +} + +// When both the built-in strikethrough extension and our subscript +// extension are enabled, `~x~` must be subscript (not strikethrough) +// and `~~x~~` must remain strikethrough. The subscript parser is +// registered with a higher priority (smaller number) so it gets the +// first chance at each `~` run. +func TestSubscriptCoexistsWithStrikethrough(t *testing.T) { + doc := parseWith(t, "H~2~O and ~~old~~ text.\n", Subscript, extension.Strikethrough) + assert.NotNil(t, walkFindKind(doc, KindSubscript), + "single-tilde span must become Subscript") + assert.NotNil(t, walkFindKind(doc, extast.KindStrikethrough), + "double-tilde span must still become Strikethrough") +} + +func TestSubscriptDoubleTildeIsNotSubscript(t *testing.T) { + doc := parseWith(t, "a~~b~~c\n", Subscript) + assert.Nil(t, walkFindKind(doc, KindSubscript), + "`~~...~~` must not match subscript") +} + +func TestSubscriptUnbalancedTilde(t *testing.T) { + doc := parseWith(t, "a~b c\n", Subscript) + assert.Nil(t, walkFindKind(doc, KindSubscript)) +} + +func TestSubscriptContent(t *testing.T) { + src := "H~2~O\n" + doc := parseWith(t, src, Subscript) + node := walkFindKind(doc, KindSubscript) + if assert.NotNil(t, node) { + assert.Equal(t, "2", string(node.Text([]byte(src)))) + } +} + +func TestSubscriptInsideCodeIgnored(t *testing.T) { + doc := parseWith(t, "see `H~2~O` here.\n", Subscript) + assert.Nil(t, walkFindKind(doc, KindSubscript)) +} From d4d0dce28a9d6c7a55c1a8189f72e3c0ce685c86 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 19:09:33 +0000 Subject: [PATCH 17/30] Plan 86: add MathBlockExt custom goldmark extension Detection-only block parser for display-math fences: \$\$ a^2 + b^2 = c^2 \$\$ Open fires when a line starts with \`\$\$\` at up to three columns of indent; a second \`\$\$\` on the same line closes the block immediately, otherwise Continue appends lines until a standalone \`\$\$\` line closes the fence. The node is raw (no inline children are parsed inside), so \`\$\$\`-adjacent markup cannot spill into or out of the block. MathBlock's body is unused by MDS034 today; the AST node only signals feature use. Tests cover single-line closure, multi-line fence, unclosed blocks (HasClosure stays false), mid-paragraph \`\$\$\` (ignored), and a four-space indent (which stays a code block). --- .../rules/markdownflavor/ext/mathblock.go | 129 ++++++++++++++++++ .../markdownflavor/ext/mathblock_test.go | 55 ++++++++ 2 files changed, 184 insertions(+) create mode 100644 internal/rules/markdownflavor/ext/mathblock.go create mode 100644 internal/rules/markdownflavor/ext/mathblock_test.go diff --git a/internal/rules/markdownflavor/ext/mathblock.go b/internal/rules/markdownflavor/ext/mathblock.go new file mode 100644 index 000000000..c461c7a5e --- /dev/null +++ b/internal/rules/markdownflavor/ext/mathblock.go @@ -0,0 +1,129 @@ +package ext + +import ( + "bytes" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// MathBlockNode is the AST node produced by the math-block parser +// for a `$$...$$` fenced display-math block. Detection-only; body +// bytes are recorded via BaseBlock.Lines so a future Fix could slice +// them out of the source, but MDS034 only inspects the node's kind. +type MathBlockNode struct { + ast.BaseBlock + // closed tracks whether a closing `$$` fence was observed. + closed bool +} + +// KindMathBlock is the NodeKind of MathBlockNode. +var KindMathBlock = ast.NewNodeKind("MathBlock") + +// Kind implements ast.Node. +func (n *MathBlockNode) Kind() ast.NodeKind { return KindMathBlock } + +// IsRaw implements ast.Node. Math-block content is raw — no inline +// children are parsed inside. +func (n *MathBlockNode) IsRaw() bool { return true } + +// HasClosure reports whether the block observed a closing fence. +func (n *MathBlockNode) HasClosure() bool { return n.closed } + +// Dump implements ast.Node for debug output. +func (n *MathBlockNode) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, nil, nil) +} + +// mathBlockParser is the BlockParser registered with goldmark. +type mathBlockParser struct{} + +// Trigger implements parser.BlockParser. +func (p *mathBlockParser) Trigger() []byte { return []byte{'$'} } + +var mathFence = []byte("$$") + +// Open implements parser.BlockParser. A line that starts with `$$` +// (after up to three spaces of indent) at the document root opens a +// math-block node. If the same line also contains a closing `$$`, +// the block is closed immediately. +func (p *mathBlockParser) Open( + parent ast.Node, reader text.Reader, pc parser.Context, +) (ast.Node, parser.State) { + line, seg := reader.PeekLine() + if line == nil { + return nil, parser.NoChildren + } + trimmed := bytes.TrimLeft(line, " ") + indent := len(line) - len(trimmed) + if indent >= 4 { + return nil, parser.NoChildren + } + if !bytes.HasPrefix(trimmed, mathFence) { + return nil, parser.NoChildren + } + node := &MathBlockNode{} + node.Lines().Append(seg) + + // Look for a closing `$$` on the same line. The rest of the line + // (after the opening `$$`) is searched for a standalone `$$`. + rest := bytes.TrimRight(trimmed[len(mathFence):], "\r\n") + if bytes.Contains(rest, mathFence) { + node.closed = true + } + reader.AdvanceToEOL() + return node, parser.NoChildren +} + +// Continue implements parser.BlockParser. Each subsequent line is +// appended to the block. A line whose trimmed content is exactly +// `$$` closes the block. +func (p *mathBlockParser) Continue( + n ast.Node, reader text.Reader, pc parser.Context, +) parser.State { + mb := n.(*MathBlockNode) + if mb.closed { + return parser.Close + } + line, seg := reader.PeekLine() + if line == nil { + return parser.Close + } + mb.Lines().Append(seg) + if bytes.Equal(bytes.TrimSpace(line), mathFence) { + mb.closed = true + reader.AdvanceToEOL() + return parser.Close + } + reader.AdvanceToEOL() + return parser.Continue | parser.NoChildren +} + +// Close implements parser.BlockParser. +func (p *mathBlockParser) Close(n ast.Node, reader text.Reader, pc parser.Context) {} + +// CanInterruptParagraph implements parser.BlockParser. Math fences +// behave like fenced code — they can start a block on a new line +// but do not interrupt an existing paragraph. +func (p *mathBlockParser) CanInterruptParagraph() bool { return false } + +// CanAcceptIndentedLine implements parser.BlockParser. +func (p *mathBlockParser) CanAcceptIndentedLine() bool { return false } + +// mathBlockExt wires the parser into goldmark. +type mathBlockExt struct{} + +// MathBlock is the goldmark Extender that installs the math-block +// block parser at priority 700 — lower than goldmark's fenced-code +// parser (700) — so fenced-code still wins on backtick runs. +var MathBlock goldmark.Extender = &mathBlockExt{} + +// Extend implements goldmark.Extender. +func (e *mathBlockExt) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithBlockParsers( + util.Prioritized(&mathBlockParser{}, 700), + )) +} diff --git a/internal/rules/markdownflavor/ext/mathblock_test.go b/internal/rules/markdownflavor/ext/mathblock_test.go new file mode 100644 index 000000000..e32106a94 --- /dev/null +++ b/internal/rules/markdownflavor/ext/mathblock_test.go @@ -0,0 +1,55 @@ +package ext + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMathBlockParses(t *testing.T) { + src := "before\n\n$$\na^2 + b^2 = c^2\n$$\n\nafter\n" + doc := parseWith(t, src, MathBlock) + assert.NotNil(t, walkFindKind(doc, KindMathBlock), + "expected MathBlock node for $$...$$ fence") +} + +func TestMathBlockClosingOnSameLine(t *testing.T) { + // A single-line block like `$$...$$` is also valid. + src := "$$E=mc^2$$\n" + doc := parseWith(t, src, MathBlock) + assert.NotNil(t, walkFindKind(doc, KindMathBlock)) +} + +func TestMathBlockUnclosedIsNotMatched(t *testing.T) { + // If no closing `$$` appears, the block must not leak into the + // AST as a MathBlock — it stays as regular paragraph content. + src := "$$\nno close here\nparagraph\n" + doc := parseWith(t, src, MathBlock) + // Unclosed block may still create a node; verify it is flagged + // closed=false so detect can decide how to report it. The plan + // does not require matching unclosed blocks, so either "no node" + // or "node with HasClosure()==false" is acceptable. Assert the + // latter if a node was produced. + if n := walkFindKind(doc, KindMathBlock); n != nil { + mb, ok := n.(*MathBlockNode) + if assert.True(t, ok) { + assert.False(t, mb.HasClosure()) + } + } +} + +func TestMathBlockInsideParagraphIsIgnored(t *testing.T) { + // `$$` in the middle of paragraph text must not start a block. + src := "text $$inline$$ here\n" + doc := parseWith(t, src, MathBlock) + assert.Nil(t, walkFindKind(doc, KindMathBlock), + "mid-paragraph `$$` must not open a math block") +} + +func TestMathBlockIndentedDoesNotOpen(t *testing.T) { + // Four spaces of indent makes the line a code block, not a math + // fence. + src := " $$\n x + y\n $$\n" + doc := parseWith(t, src, MathBlock) + assert.Nil(t, walkFindKind(doc, KindMathBlock)) +} From 88e9597fa73d8f423f2704c8271e89470c71b340 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 06:02:11 +0000 Subject: [PATCH 18/30] Plan 86: add MathInlineExt custom goldmark extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detection-only inline parser for Pandoc-style \`\$...\$\` math spans. The tex_math_dollars rules don't fit goldmark's delimiter framework (the closing \`\$\` depends on the byte that follows), so the parser scans the current line byte by byte: - Opening \`\$\` requires a non-whitespace, non-dollar successor — so \`\$20\` and \`\$\$\` do not open. - Closing \`\$\` requires a non-whitespace predecessor and a non-digit successor — so \`foo \$x\$\` and \`(\$x\$)\` match, while \`\$ x \$\` and \`pay \$20\$30\` do not. Also replace two deprecated \`node.Text(...)\` calls in the super/subscript tests with segment-based reads to silence staticcheck. --- .../rules/markdownflavor/ext/mathinline.go | 123 ++++++++++++++++++ .../markdownflavor/ext/mathinline_test.go | 56 ++++++++ .../markdownflavor/ext/subscript_test.go | 12 +- .../markdownflavor/ext/superscript_test.go | 8 +- 4 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 internal/rules/markdownflavor/ext/mathinline.go create mode 100644 internal/rules/markdownflavor/ext/mathinline_test.go diff --git a/internal/rules/markdownflavor/ext/mathinline.go b/internal/rules/markdownflavor/ext/mathinline.go new file mode 100644 index 000000000..110005e7c --- /dev/null +++ b/internal/rules/markdownflavor/ext/mathinline.go @@ -0,0 +1,123 @@ +package ext + +import ( + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// MathInlineNode is the AST node produced by the inline-math parser +// for a `$...$` span using Pandoc's tex_math_dollars rules. +type MathInlineNode struct { + ast.BaseInline +} + +// KindMathInline is the NodeKind of MathInlineNode. +var KindMathInline = ast.NewNodeKind("MathInline") + +// Kind implements ast.Node. +func (n *MathInlineNode) Kind() ast.NodeKind { return KindMathInline } + +// Dump implements ast.Node for debug output. +func (n *MathInlineNode) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, nil, nil) +} + +// mathInlineParser is the InlineParser registered with goldmark. +// It walks the current line byte-by-byte to apply Pandoc's +// tex_math_dollars rules, which are not expressible through the +// delimiter-pairing framework because the closing `$` depends on +// the character that *follows* it. +type mathInlineParser struct{} + +// Trigger implements parser.InlineParser. +func (p *mathInlineParser) Trigger() []byte { return []byte{'$'} } + +// Parse implements parser.InlineParser. +// +// Pandoc's tex_math_dollars: +// - The opening `$` must be immediately followed by a character +// that is not whitespace and not another `$` (the latter rule +// keeps `$$` from looking like a zero-length inline span). +// - A closing `$` is any `$` on the same line whose preceding +// character is not whitespace and whose following character is +// not a digit. +// +// This matches `$x$`, `($x$)`, and `foo $x+1$ bar` while rejecting +// `$ x $`, `$x $`, `$20`, and `$$`. +func (p *mathInlineParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { + before := block.PrecendingCharacter() + if before == '$' { + return nil + } + line, segment := block.PeekLine() + if len(line) < 2 || line[0] != '$' { + return nil + } + next := line[1] + if next == '$' || isSpaceByte(next) { + return nil + } + // Find a closing `$` on the line. + closeIdx := -1 + for i := 2; i < len(line); i++ { + if line[i] != '$' { + continue + } + prev := line[i-1] + if isSpaceByte(prev) { + continue + } + // If followed by another `$`, this is a `$$` fence marker, + // not a valid math-inline closer. + if i+1 < len(line) && line[i+1] == '$' { + continue + } + if i+1 < len(line) && isDigitByte(line[i+1]) { + continue + } + closeIdx = i + break + } + if closeIdx < 0 { + return nil + } + node := &MathInlineNode{} + contentSeg := segment.WithStart(segment.Start + 1) + contentSeg = contentSeg.WithStop(segment.Start + closeIdx) + node.AppendChild(node, ast.NewTextSegment(contentSeg)) + block.Advance(closeIdx + 1) + return node +} + +// CloseBlock implements parser.InlineParser. +func (p *mathInlineParser) CloseBlock(parent ast.Node, pc parser.Context) {} + +// isSpaceByte reports whether b is an ASCII whitespace byte the +// Pandoc rule treats as "space" (space, tab, newline, CR). +func isSpaceByte(b byte) bool { + return b == ' ' || b == '\t' || b == '\n' || b == '\r' +} + +// isDigitByte reports whether b is an ASCII decimal digit. +func isDigitByte(b byte) bool { return b >= '0' && b <= '9' } + +// mathInlineExt wires the parser into goldmark. +type mathInlineExt struct{} + +// MathInline is the goldmark Extender that installs the math-inline +// parser at priority 200 — higher than emphasis (100 is reserved; +// CommonMark emphasis registers at 100) — well, actually a lower +// number means higher priority in goldmark. Use 200 so Pandoc-style +// math beats plain-text handling but does not interfere with any +// other `$`-using parser (there is none by default). +var MathInline goldmark.Extender = &mathInlineExt{} + +// Extend implements goldmark.Extender. +func (e *mathInlineExt) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithInlineParsers( + util.Prioritized(&mathInlineParser{}, 200), + )) +} diff --git a/internal/rules/markdownflavor/ext/mathinline_test.go b/internal/rules/markdownflavor/ext/mathinline_test.go new file mode 100644 index 000000000..0842037ac --- /dev/null +++ b/internal/rules/markdownflavor/ext/mathinline_test.go @@ -0,0 +1,56 @@ +package ext + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMathInlineParses(t *testing.T) { + doc := parseWith(t, "foo $x+1$ bar\n", MathInline) + assert.NotNil(t, walkFindKind(doc, KindMathInline), + "expected MathInline node for $x+1$") +} + +func TestMathInlineParensWrapped(t *testing.T) { + doc := parseWith(t, "area is ($x$)\n", MathInline) + assert.NotNil(t, walkFindKind(doc, KindMathInline)) +} + +func TestMathInlineRejectsLeadingSpace(t *testing.T) { + // Opening `$` must be followed by a non-space character. + doc := parseWith(t, "$ x $\n", MathInline) + assert.Nil(t, walkFindKind(doc, KindMathInline), + "'$ x $' has space after opening $ — no match") +} + +func TestMathInlineRejectsTrailingSpace(t *testing.T) { + // Closing `$` must be preceded by a non-space character. + doc := parseWith(t, "a $x $\n", MathInline) + assert.Nil(t, walkFindKind(doc, KindMathInline)) +} + +func TestMathInlineRejectsDollarAmount(t *testing.T) { + // Closing `$` must not be followed by a digit. This rejects + // currency-style text like `$20$30`. + doc := parseWith(t, "pay $20$30 dollars\n", MathInline) + assert.Nil(t, walkFindKind(doc, KindMathInline), + "digit after closing $ prevents the match") +} + +func TestMathInlineUnbalanced(t *testing.T) { + doc := parseWith(t, "this costs $5 maybe\n", MathInline) + assert.Nil(t, walkFindKind(doc, KindMathInline)) +} + +func TestMathInlineInsideCodeIgnored(t *testing.T) { + doc := parseWith(t, "see `$x+1$` here.\n", MathInline) + assert.Nil(t, walkFindKind(doc, KindMathInline)) +} + +func TestMathInlineDoesNotMatchDoubleDollar(t *testing.T) { + // `$$` is the start of a math block; the inline parser must not + // fire on consecutive dollars. + doc := parseWith(t, "before $$ not inline $$ after\n", MathInline) + assert.Nil(t, walkFindKind(doc, KindMathInline)) +} diff --git a/internal/rules/markdownflavor/ext/subscript_test.go b/internal/rules/markdownflavor/ext/subscript_test.go index 4605bea4b..04af299f4 100644 --- a/internal/rules/markdownflavor/ext/subscript_test.go +++ b/internal/rules/markdownflavor/ext/subscript_test.go @@ -4,8 +4,9 @@ import ( "testing" "github.com/stretchr/testify/assert" - extast "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/extension" + extast "github.com/yuin/goldmark/extension/ast" ) func TestSubscriptParsesSingleTilde(t *testing.T) { @@ -39,11 +40,14 @@ func TestSubscriptUnbalancedTilde(t *testing.T) { } func TestSubscriptContent(t *testing.T) { - src := "H~2~O\n" - doc := parseWith(t, src, Subscript) + src := []byte("H~2~O\n") + doc := parseWith(t, string(src), Subscript) node := walkFindKind(doc, KindSubscript) if assert.NotNil(t, node) { - assert.Equal(t, "2", string(node.Text([]byte(src)))) + child, ok := node.FirstChild().(*ast.Text) + if assert.True(t, ok, "subscript first child should be a Text node") { + assert.Equal(t, "2", string(child.Segment.Value(src))) + } } } diff --git a/internal/rules/markdownflavor/ext/superscript_test.go b/internal/rules/markdownflavor/ext/superscript_test.go index d544b587b..5f5f3aa55 100644 --- a/internal/rules/markdownflavor/ext/superscript_test.go +++ b/internal/rules/markdownflavor/ext/superscript_test.go @@ -50,12 +50,14 @@ func TestSuperscriptUnbalancedCaret(t *testing.T) { } func TestSuperscriptContainsContent(t *testing.T) { - doc := parseWith(t, "E = mc^2^\n", Superscript) + src := []byte("E = mc^2^\n") + doc := parseWith(t, string(src), Superscript) node := walkFindKind(doc, KindSuperscript) require.NotNil(t, node) // The child should carry the "2" text. - require.NotNil(t, node.FirstChild()) - assert.Equal(t, "2", string(node.Text([]byte("E = mc^2^\n")))) + child, ok := node.FirstChild().(*ast.Text) + require.True(t, ok, "superscript first child should be a Text node") + assert.Equal(t, "2", string(child.Segment.Value(src))) } func TestSuperscriptInsideCodeIsIgnored(t *testing.T) { From 9d7f8ad80dfdd17cc19636b61e6926d2bb62209d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 06:02:25 +0000 Subject: [PATCH 19/30] fix(MDS034): align dual parser with lint.NewFile PI parser Copilot review: the dual parser was \`goldmark.New()\` with defaults, but \`lint.NewFile\` registers \`lint.PIBlockParserPrioritized\` so a \`\` block is a ProcessingInstruction node, not an HTML block. Without matching setup, MDS034 could detect extension features inside PI blocks that every other rule ignores. Pass \`parser.WithBlockParsers(lint.PIBlockParserPrioritized())\` to the dual parser so both parse trees agree on what is a PI. Add a regression test that asserts a \`\` block becomes a \`lint.KindProcessingInstruction\` in the dual AST. --- internal/rules/markdownflavor/parser.go | 14 +++++++++++- internal/rules/markdownflavor/parser_test.go | 24 ++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/internal/rules/markdownflavor/parser.go b/internal/rules/markdownflavor/parser.go index bf61e7f1a..c0f4abcd9 100644 --- a/internal/rules/markdownflavor/parser.go +++ b/internal/rules/markdownflavor/parser.go @@ -6,6 +6,8 @@ import ( "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" + + "github.com/jeduden/mdsmith/internal/lint" ) var ( @@ -18,7 +20,14 @@ var ( // for AST-based feature detection (table, strikethrough, task list, // footnote, definition list) and the heading-ID attribute parser. // -// Linkify is intentionally not enabled here: bare-URL autolinks are +// To keep MDS034 aligned with the rest of mdsmith, the dual parser +// also registers lint.PIBlockParserPrioritized so a +// block is treated as a processing-instruction +// node here — just as lint.NewFile does — rather than as an HTML +// block. Without this, a table fixture embedded inside a PI block +// would be flagged by MDS034 but invisible to every other rule. +// +// Linkify is intentionally not enabled: bare-URL autolinks are // detected separately in detectBareURLs by scanning Text nodes from // the main CommonMark parse, so adding Linkify would only duplicate // work without changing the result. @@ -38,6 +47,9 @@ func Parser() goldmark.Markdown { ), goldmark.WithParserOptions( parser.WithAttribute(), + parser.WithBlockParsers( + lint.PIBlockParserPrioritized(), + ), ), ) }) diff --git a/internal/rules/markdownflavor/parser_test.go b/internal/rules/markdownflavor/parser_test.go index 83495aa5e..ff77fd055 100644 --- a/internal/rules/markdownflavor/parser_test.go +++ b/internal/rules/markdownflavor/parser_test.go @@ -8,6 +8,8 @@ import ( "github.com/yuin/goldmark/ast" extast "github.com/yuin/goldmark/extension/ast" "github.com/yuin/goldmark/text" + + "github.com/jeduden/mdsmith/internal/lint" ) func TestParserCachesSingleInstance(t *testing.T) { @@ -51,6 +53,28 @@ func TestParserDetectsDefinitionList(t *testing.T) { "expected definition-list node in dual-parser AST") } +// TestParserRecognisesPIBlocks guards that the dual parser uses the +// same processing-instruction block parser as lint.NewFile so table +// / list markup embedded inside a block is not +// detected as real document markup by MDS034. +func TestParserRecognisesPIBlocks(t *testing.T) { + src := []byte("\n") + doc := parseSource(t, src) + found := false + _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + if n.Kind() == lint.KindProcessingInstruction { + found = true + return ast.WalkStop, nil + } + return ast.WalkContinue, nil + }) + assert.True(t, found, + "expected ProcessingInstruction node in dual-parser AST") +} + func TestParserDetectsHeadingAttribute(t *testing.T) { src := []byte("# Heading {#custom-id}\n") doc := parseSource(t, src) From 003917c7e83df8f842702688c3f9a8bcc911aa41 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 06:11:01 +0000 Subject: [PATCH 20/30] fix: address Copilot round on MDS034 custom extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six threads from the latest Copilot pass: Priority-comment inaccuracies (3): - superscript.go claimed the parser registered "higher than emphasis" — goldmark uses smaller numbers for higher priority and the code uses 500, so reword to say emphasis (100) still wins on its own delimiters. - mathinline.go comment said "priority 200 — higher than emphasis" which contradicted the same comment's next sentence. Clarify: lower number runs earlier; emphasis at 100 still wins; 200 leaves room for a future \`$\`-using parser at a lower number. - mathblock.go asserted a "lower priority than fenced code" at the same value (700) and fenced code triggers on different chars. Drop the misleading comparison and just describe the choice. Plan checkbox reconciliation (3): - superscript, subscript, and math block/inline extensions are now implemented and tested, so check off the four task items. Add a note on the "tests for all five extensions" item that abbreviation tests arrive with that extension. --- .../rules/markdownflavor/ext/mathblock.go | 7 +++++-- .../rules/markdownflavor/ext/mathinline.go | 10 +++++----- .../rules/markdownflavor/ext/superscript.go | 7 ++++--- plan/86_markdown-flavor-validation.md | 19 ++++++++++++------- 4 files changed, 26 insertions(+), 17 deletions(-) diff --git a/internal/rules/markdownflavor/ext/mathblock.go b/internal/rules/markdownflavor/ext/mathblock.go index c461c7a5e..d221dc3c6 100644 --- a/internal/rules/markdownflavor/ext/mathblock.go +++ b/internal/rules/markdownflavor/ext/mathblock.go @@ -117,8 +117,11 @@ func (p *mathBlockParser) CanAcceptIndentedLine() bool { return false } type mathBlockExt struct{} // MathBlock is the goldmark Extender that installs the math-block -// block parser at priority 700 — lower than goldmark's fenced-code -// parser (700) — so fenced-code still wins on backtick runs. +// block parser at priority 700. `$` is not a default block trigger, +// so no other parser competes; the value is chosen to match +// goldmark's own fenced-block precedent without claiming any +// ordering relationship to fenced-code (which triggers on backtick +// or tilde, not `$`). var MathBlock goldmark.Extender = &mathBlockExt{} // Extend implements goldmark.Extender. diff --git a/internal/rules/markdownflavor/ext/mathinline.go b/internal/rules/markdownflavor/ext/mathinline.go index 110005e7c..df8b17fd8 100644 --- a/internal/rules/markdownflavor/ext/mathinline.go +++ b/internal/rules/markdownflavor/ext/mathinline.go @@ -108,11 +108,11 @@ func isDigitByte(b byte) bool { return b >= '0' && b <= '9' } type mathInlineExt struct{} // MathInline is the goldmark Extender that installs the math-inline -// parser at priority 200 — higher than emphasis (100 is reserved; -// CommonMark emphasis registers at 100) — well, actually a lower -// number means higher priority in goldmark. Use 200 so Pandoc-style -// math beats plain-text handling but does not interfere with any -// other `$`-using parser (there is none by default). +// parser at priority 200. In goldmark a lower priority number runs +// earlier, so CommonMark emphasis (100) still wins on its own +// delimiters. Using 200 lets Pandoc-style math beat plain-text +// handling and leaves room for a future `$`-using inline parser to +// take precedence by registering at a lower number. var MathInline goldmark.Extender = &mathInlineExt{} // Extend implements goldmark.Extender. diff --git a/internal/rules/markdownflavor/ext/superscript.go b/internal/rules/markdownflavor/ext/superscript.go index 99aaeb2ab..dbc97c644 100644 --- a/internal/rules/markdownflavor/ext/superscript.go +++ b/internal/rules/markdownflavor/ext/superscript.go @@ -89,9 +89,10 @@ func (p *superscriptParser) CloseBlock(parent ast.Node, pc parser.Context) {} type superscriptExt struct{} // Superscript is the goldmark Extender that installs the superscript -// inline parser with a priority higher (numerically smaller) than -// emphasis (100) — `^` is otherwise literal, so no ambiguity, but -// keeping it tight avoids surprises. +// inline parser at priority 500. In goldmark a lower priority number +// runs earlier, so CommonMark emphasis (100) still wins on its own +// delimiters; `^` has no other default parser, so the ordering here +// does not introduce ambiguity. var Superscript goldmark.Extender = &superscriptExt{} // Extend implements goldmark.Extender. diff --git a/plan/86_markdown-flavor-validation.md b/plan/86_markdown-flavor-validation.md index 5c66d94d6..2249e6fde 100644 --- a/plan/86_markdown-flavor-validation.md +++ b/plan/86_markdown-flavor-validation.md @@ -166,24 +166,29 @@ math, abbreviations. - [x] Add feature enum and flavor registry in `internal/rules/markdownflavor/features.go` -- [ ] Write `SuperscriptExt` inline parser in +- [x] Write `SuperscriptExt` inline parser in `internal/rules/markdownflavor/ext/superscript.go` -- [ ] Write `SubscriptExt` inline parser in +- [x] Write `SubscriptExt` inline parser in `internal/rules/markdownflavor/ext/subscript.go` -- [ ] Write `MathBlockExt` block parser in +- [x] Write `MathBlockExt` block parser in `internal/rules/markdownflavor/ext/mathblock.go` -- [ ] Write `MathInlineExt` inline parser in +- [x] Write `MathInlineExt` inline parser in `internal/rules/markdownflavor/ext/mathinline.go` - [ ] Write `AbbreviationExt` block parser + paragraph transformer in `internal/rules/markdownflavor/ext/abbreviation.go` - [ ] Add tests for all five custom extensions + + - [x] Build dual parser with built-in extensions (custom extensions pending) - [x] Add AST-based detectors for the seven built-in - features (tables, task lists, strikethrough, bare-URL - autolinks, footnotes, definition lists, heading IDs); - the five custom features remain pending + features. Covered here: tables, task lists, + strikethrough, bare-URL autolinks, footnotes, + definition lists, and heading IDs. The five custom + features remain pending. - [x] Implement `rule.go` with `Check()`; `Fix()` is pending - [x] Implement `rule.Configurable` for MDS034: add From fad6165f02e42f46478caa37e2496cdeef85f342 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 06:18:21 +0000 Subject: [PATCH 21/30] Plan 86: add AbbreviationExt custom goldmark extension Implements the last of the five custom extensions. Block parser: a line of the form \`*[TERM]: EXPANSION\` (up to 3 spaces of indent) creates an AbbreviationDefinition node and records the term in a parser-context table keyed by the term's bytes. AST transformer: after all blocks have been parsed, walk the document, skip CodeSpan / FencedCodeBlock / CodeBlock / AbbreviationDefinition subtrees, and rewrite each Text node around whole-word matches of any defined term. Each hit becomes an AbbreviationReference node containing a Text with the term's source span; gaps and prefix/suffix remain as plain Text siblings. The transformer runs at the document level so a \`*[TERM]: ...\` definition placed after the paragraph that uses it still marks every inline reference, matching PHP Markdown Extra / MyST behaviour. Tests cover: definition recognition, reference marking, missing-definition no-op, whole-word boundary (XHTML does not match HTML), multiple occurrences, and inline-code exclusion. --- .../rules/markdownflavor/ext/abbreviation.go | 366 ++++++++++++++++++ .../markdownflavor/ext/abbreviation_test.go | 53 +++ .../markdownflavor/ext/superscript_test.go | 13 + plan/86_markdown-flavor-validation.md | 12 +- 4 files changed, 438 insertions(+), 6 deletions(-) create mode 100644 internal/rules/markdownflavor/ext/abbreviation.go create mode 100644 internal/rules/markdownflavor/ext/abbreviation_test.go diff --git a/internal/rules/markdownflavor/ext/abbreviation.go b/internal/rules/markdownflavor/ext/abbreviation.go new file mode 100644 index 000000000..64fbde882 --- /dev/null +++ b/internal/rules/markdownflavor/ext/abbreviation.go @@ -0,0 +1,366 @@ +package ext + +import ( + "bytes" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// AbbreviationDefinition is the AST node produced by the +// abbreviation block parser for a `*[term]: expansion` line. The +// node is raw so its content is not re-parsed as inline markup. +type AbbreviationDefinition struct { + ast.BaseBlock + Term []byte + Expansion []byte +} + +// KindAbbreviationDefinition is the NodeKind of AbbreviationDefinition. +var KindAbbreviationDefinition = ast.NewNodeKind("AbbreviationDefinition") + +// Kind implements ast.Node. +func (n *AbbreviationDefinition) Kind() ast.NodeKind { return KindAbbreviationDefinition } + +// IsRaw implements ast.Node. +func (n *AbbreviationDefinition) IsRaw() bool { return true } + +// Dump implements ast.Node. +func (n *AbbreviationDefinition) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, map[string]string{ + "Term": string(n.Term), + "Expansion": string(n.Expansion), + }, nil) +} + +// AbbreviationReference marks an inline occurrence of a defined +// abbreviation term. The referenced term text lives in the child +// Text node. +type AbbreviationReference struct { + ast.BaseInline + Term []byte +} + +// KindAbbreviationReference is the NodeKind of AbbreviationReference. +var KindAbbreviationReference = ast.NewNodeKind("AbbreviationReference") + +// Kind implements ast.Node. +func (n *AbbreviationReference) Kind() ast.NodeKind { return KindAbbreviationReference } + +// Dump implements ast.Node. +func (n *AbbreviationReference) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, map[string]string{ + "Term": string(n.Term), + }, nil) +} + +// abbrTableKey is the parser-context key under which the +// abbreviation-definition table is stored during a single parse so +// the transformer can look it up after all blocks have been parsed. +var abbrTableKey = parser.NewContextKey() + +// abbrTable is the in-parse table of defined abbreviations. Keys are +// canonical term byte sequences; values are expansion bytes. +type abbrTable map[string][]byte + +// getAbbrTable returns the abbreviation table from the context, +// creating it on first access. +func getAbbrTable(pc parser.Context) abbrTable { + if v := pc.Get(abbrTableKey); v != nil { + return v.(abbrTable) + } + t := abbrTable{} + pc.Set(abbrTableKey, t) + return t +} + +// --- block parser ----------------------------------------------------- + +// abbrDefPrefix is the literal `*[` that starts every definition. +var abbrDefPrefix = []byte("*[") + +// abbreviationBlockParser parses `*[term]: expansion` lines as +// block-level AbbreviationDefinition nodes and records the term in +// the parse context so the transformer can mark references. +type abbreviationBlockParser struct{} + +// Trigger implements parser.BlockParser. +func (p *abbreviationBlockParser) Trigger() []byte { return []byte{'*'} } + +// Open implements parser.BlockParser. +// +// A definition line has the shape `*[TERM]: EXPANSION` with no +// leading indent beyond three spaces. TERM must be non-empty and +// may not contain a literal `]`. EXPANSION may be empty. +func (p *abbreviationBlockParser) Open( + parent ast.Node, reader text.Reader, pc parser.Context, +) (ast.Node, parser.State) { + line, _ := reader.PeekLine() + if line == nil { + return nil, parser.NoChildren + } + trimmed := bytes.TrimLeft(line, " ") + indent := len(line) - len(trimmed) + if indent >= 4 { + return nil, parser.NoChildren + } + if !bytes.HasPrefix(trimmed, abbrDefPrefix) { + return nil, parser.NoChildren + } + rest := trimmed[len(abbrDefPrefix):] + rbracket := bytes.IndexByte(rest, ']') + if rbracket <= 0 { + return nil, parser.NoChildren + } + term := rest[:rbracket] + afterBracket := rest[rbracket+1:] + if len(afterBracket) == 0 || afterBracket[0] != ':' { + return nil, parser.NoChildren + } + expansion := bytes.TrimSpace(afterBracket[1:]) + // Copy byte slices — the reader's line buffer is reused between + // calls, so holding references to it would be unsafe. + termCopy := append([]byte(nil), term...) + expCopy := append([]byte(nil), expansion...) + + node := &AbbreviationDefinition{Term: termCopy, Expansion: expCopy} + tbl := getAbbrTable(pc) + tbl[string(termCopy)] = expCopy + + reader.AdvanceToEOL() + return node, parser.NoChildren +} + +// Continue implements parser.BlockParser. +// +// Definitions are always single-line; the parser closes immediately +// after Open consumes the line. +func (p *abbreviationBlockParser) Continue( + n ast.Node, reader text.Reader, pc parser.Context, +) parser.State { + return parser.Close +} + +// Close implements parser.BlockParser. +func (p *abbreviationBlockParser) Close(n ast.Node, reader text.Reader, pc parser.Context) {} + +// CanInterruptParagraph implements parser.BlockParser. +func (p *abbreviationBlockParser) CanInterruptParagraph() bool { return false } + +// CanAcceptIndentedLine implements parser.BlockParser. +func (p *abbreviationBlockParser) CanAcceptIndentedLine() bool { return false } + +// --- AST transformer -------------------------------------------------- + +// abbreviationTransformer runs after block parsing and rewrites +// inline Text nodes to mark whole-word occurrences of defined terms +// as AbbreviationReference nodes. +type abbreviationTransformer struct{} + +// Transform implements parser.ASTTransformer. +func (t *abbreviationTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) { + raw := pc.Get(abbrTableKey) + if raw == nil { + return + } + table := raw.(abbrTable) + if len(table) == 0 { + return + } + source := reader.Source() + + // Walk paragraphs and rewrite their Text descendants. Inline + // code spans and raw inlines are skipped so their contents stay + // literal. + _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + switch node := n.(type) { + case *ast.CodeSpan, *ast.FencedCodeBlock, *ast.CodeBlock, + *AbbreviationDefinition: + return ast.WalkSkipChildren, nil + case *ast.Text: + rewriteText(node, table, source) + return ast.WalkSkipChildren, nil + } + return ast.WalkContinue, nil + }) +} + +// abbrMatch is an internal record of one abbreviation hit inside a +// Text node body. Offsets are relative to the body, not to f.Source. +type abbrMatch struct { + start, end int + term string +} + +// rewriteText splits a Text node around whole-word term matches and +// inserts AbbreviationReference nodes in their place. The original +// Text node's segment is shrunk to the prefix before the first +// match; subsequent content is appended as sibling nodes. +func rewriteText(t *ast.Text, table abbrTable, source []byte) { + seg := t.Segment + body := seg.Value(source) + if len(body) == 0 { + return + } + matches := findMatches(body, table) + if len(matches) == 0 { + return + } + parent := t.Parent() + if parent == nil { + return + } + applyMatches(parent, t, seg, body, matches) +} + +// findMatches scans body for the longest whole-word match of any +// defined term at each word-boundary position and advances past each +// hit so occurrences never overlap. +func findMatches(body []byte, table abbrTable) []abbrMatch { + var matches []abbrMatch + for i := 0; i < len(body); { + if i > 0 && isWordByte(body[i-1]) { + i++ + continue + } + m, ok := bestMatchAt(body, i, table) + if !ok { + i++ + continue + } + matches = append(matches, m) + i = m.end + } + return matches +} + +// bestMatchAt returns the longest term in table that matches body +// starting at i, requiring a word boundary after the term. +func bestMatchAt(body []byte, i int, table abbrTable) (abbrMatch, bool) { + best := abbrMatch{start: -1} + for term := range table { + tb := []byte(term) + if !bytes.HasPrefix(body[i:], tb) { + continue + } + endIdx := i + len(tb) + if endIdx < len(body) && isWordByte(body[endIdx]) { + continue + } + if len(tb) > best.end-best.start { + best = abbrMatch{start: i, end: endIdx, term: term} + } + } + if best.start < 0 { + return abbrMatch{}, false + } + return best, true +} + +// applyMatches rewrites the AST around t with the given matches. +// The original Text is either shrunk to the prefix (common case) +// or replaced entirely when the first match starts at offset 0. +func applyMatches(parent ast.Node, t *ast.Text, seg text.Segment, body []byte, matches []abbrMatch) { + first := matches[0] + var anchor ast.Node + if first.start == 0 { + ref := buildReference(seg, first, nil) + parent.ReplaceChild(parent, t, ref) + anchor = ref + } else { + t.Segment = seg.WithStop(seg.Start + first.start) + ref := buildReference(seg, first, nil) + parent.InsertAfter(parent, t, ref) + anchor = ref + } + anchor = appendRestAfter(parent, anchor, seg, matches[1:], first.end) + _ = appendTail(parent, anchor, seg, body, lastEnd(first, matches[1:])) +} + +// appendRestAfter inserts gap-text and reference nodes for each +// remaining match after anchor, returning the last inserted node. +func appendRestAfter(parent, anchor ast.Node, seg text.Segment, rest []abbrMatch, prev int) ast.Node { + for _, m := range rest { + if m.start > prev { + gap := ast.NewTextSegment(subSeg(seg, prev, m.start)) + parent.InsertAfter(parent, anchor, gap) + anchor = gap + } + ref := buildReference(seg, m, nil) + parent.InsertAfter(parent, anchor, ref) + anchor = ref + prev = m.end + } + return anchor +} + +// appendTail inserts the final Text node for body content after the +// last match, returning the inserted node (or anchor when no tail). +func appendTail(parent, anchor ast.Node, seg text.Segment, body []byte, prev int) ast.Node { + if prev >= len(body) { + return anchor + } + tail := ast.NewTextSegment(seg.WithStart(seg.Start + prev)) + parent.InsertAfter(parent, anchor, tail) + return tail +} + +// lastEnd returns the end offset of the final match in first+rest. +func lastEnd(first abbrMatch, rest []abbrMatch) int { + if len(rest) == 0 { + return first.end + } + return rest[len(rest)-1].end +} + +// buildReference constructs an AbbreviationReference whose child +// Text covers the term's byte span. +func buildReference(parentSeg text.Segment, m abbrMatch, _ []byte) ast.Node { + ref := &AbbreviationReference{Term: []byte(m.term)} + ref.AppendChild(ref, ast.NewTextSegment(subSeg(parentSeg, m.start, m.end))) + return ref +} + +// isWordByte reports whether b is part of a word for abbreviation +// boundary purposes: ASCII alphanumeric or underscore. +func isWordByte(b byte) bool { + return (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || + (b >= '0' && b <= '9') || b == '_' +} + +// subSeg returns a new Segment that starts at parent.Start + start +// and stops at parent.Start + stop. Avoids chained WithStart / +// WithStop calls, which cannot be inlined because both are pointer +// methods on text.Segment. +func subSeg(parent text.Segment, start, stop int) text.Segment { + s := parent.WithStart(parent.Start + start) + return s.WithStop(parent.Start + stop) +} + +// --- extender --------------------------------------------------------- + +type abbreviationExt struct{} + +// Abbreviation is the goldmark Extender that installs the block +// parser (priority 900 — later than most block parsers so it runs +// only when nothing else has claimed the line) and the AST +// transformer (priority 800 — runs after all blocks are finalized). +var Abbreviation goldmark.Extender = &abbreviationExt{} + +// Extend implements goldmark.Extender. +func (e *abbreviationExt) Extend(m goldmark.Markdown) { + m.Parser().AddOptions( + parser.WithBlockParsers( + util.Prioritized(&abbreviationBlockParser{}, 900), + ), + parser.WithASTTransformers( + util.Prioritized(&abbreviationTransformer{}, 800), + ), + ) +} diff --git a/internal/rules/markdownflavor/ext/abbreviation_test.go b/internal/rules/markdownflavor/ext/abbreviation_test.go new file mode 100644 index 000000000..ebf2c021d --- /dev/null +++ b/internal/rules/markdownflavor/ext/abbreviation_test.go @@ -0,0 +1,53 @@ +package ext + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAbbreviationDefinitionRecognised(t *testing.T) { + src := "*[HTML]: Hyper Text Markup Language\n\nUse HTML here.\n" + doc := parseWith(t, src, Abbreviation) + assert.NotNil(t, walkFindKind(doc, KindAbbreviationDefinition), + "expected AbbreviationDefinition for `*[HTML]: ...`") +} + +func TestAbbreviationReferenceMarkedInParagraph(t *testing.T) { + src := "*[HTML]: Hyper Text Markup Language\n\nUse HTML here.\n" + doc := parseWith(t, src, Abbreviation) + assert.NotNil(t, walkFindKind(doc, KindAbbreviationReference), + "expected AbbreviationReference for the word 'HTML' inside the paragraph") +} + +func TestAbbreviationRequiresDefinedTerm(t *testing.T) { + // Without a `*[term]: ...` definition, an occurrence in text + // must not produce an AbbreviationReference node. + src := "Use HTML here.\n" + doc := parseWith(t, src, Abbreviation) + assert.Nil(t, walkFindKind(doc, KindAbbreviationReference)) + assert.Nil(t, walkFindKind(doc, KindAbbreviationDefinition)) +} + +func TestAbbreviationDoesNotMatchSubstring(t *testing.T) { + // "HTML" must not match as a suffix of "XHTML" — only whole- + // word tokens count. + src := "*[HTML]: Hyper Text Markup Language\n\nUse XHTML here.\n" + doc := parseWith(t, src, Abbreviation) + assert.Nil(t, walkFindKind(doc, KindAbbreviationReference), + "XHTML must not be matched as HTML") +} + +func TestAbbreviationMultipleOccurrences(t *testing.T) { + src := "*[API]: Application Programming Interface\n\nAPI here; API there.\n" + doc := parseWith(t, src, Abbreviation) + assert.Equal(t, 2, countKind(doc, KindAbbreviationReference), + "both API occurrences should be marked") +} + +func TestAbbreviationInsideCodeIgnored(t *testing.T) { + // Occurrences inside inline code must not be marked. + src := "*[HTML]: Hyper Text Markup Language\n\nUse `HTML` here.\n" + doc := parseWith(t, src, Abbreviation) + assert.Nil(t, walkFindKind(doc, KindAbbreviationReference)) +} diff --git a/internal/rules/markdownflavor/ext/superscript_test.go b/internal/rules/markdownflavor/ext/superscript_test.go index 5f5f3aa55..a9879aa8e 100644 --- a/internal/rules/markdownflavor/ext/superscript_test.go +++ b/internal/rules/markdownflavor/ext/superscript_test.go @@ -30,6 +30,19 @@ func parseWith(t *testing.T, src string, exts ...goldmark.Extender) ast.Node { return doc } +// countKind returns the number of nodes of the given kind anywhere +// in the tree rooted at root. +func countKind(root ast.Node, kind ast.NodeKind) int { + n := 0 + _ = ast.Walk(root, func(node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering && node.Kind() == kind { + n++ + } + return ast.WalkContinue, nil + }) + return n +} + func TestSuperscriptParses(t *testing.T) { doc := parseWith(t, "x^2^ is fine.\n", Superscript) assert.NotNil(t, walkFindKind(doc, KindSuperscript), diff --git a/plan/86_markdown-flavor-validation.md b/plan/86_markdown-flavor-validation.md index 2249e6fde..5b9495e6c 100644 --- a/plan/86_markdown-flavor-validation.md +++ b/plan/86_markdown-flavor-validation.md @@ -174,14 +174,14 @@ math, abbreviations. `internal/rules/markdownflavor/ext/mathblock.go` - [x] Write `MathInlineExt` inline parser in `internal/rules/markdownflavor/ext/mathinline.go` -- [ ] Write `AbbreviationExt` block parser + paragraph +- [x] Write `AbbreviationExt` block parser + paragraph transformer in `internal/rules/markdownflavor/ext/abbreviation.go` -- [ ] Add tests for all five custom extensions - - + +- [x] Add tests for all five custom extensions - [x] Build dual parser with built-in extensions (custom extensions pending) - [x] Add AST-based detectors for the seven built-in From 1af4c3ee0dbb7e8f409e665f96edea1b534ae9c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 06:26:43 +0000 Subject: [PATCH 22/30] Plan 86: wire 5 custom extensions into MDS034 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parser() now enables Superscript, Subscript, MathBlock, MathInline, and Abbreviation alongside the built-in goldmark extensions and the PI block parser. The shared singleton now covers all twelve MDS034 features from one dual parse. detectFromDual gained handlers for the new AST node kinds: - SuperscriptNode / SubscriptNode / MathInlineNode use a shared markerInlineFinding that backs up the single opening marker byte so the diagnostic points at \`^\` / \`~\` / \`$\` rather than the first content character. - MathBlockNode / AbbreviationDefinition report at the block's first-line column. - AbbreviationReference uses inlineFinding so the diagnostic lands on the exact source column of the abbreviated term within its paragraph. Switch dispatch split into builtinFindingFor and customFindingFor to keep each under the funlen threshold and make the mapping easy to scan. anyDualFeatureAccepted now enumerates all 11 dual- parse features so DetectFiltered can still skip the re-parse when none are wanted. Fixtures: add five commonmark bad fixtures covering superscript, subscript, math block, inline math, and abbreviation (definition + reference). Plan: check off the dual-parser, detector, ACs 1–3, and update the MDS034 README's "Detected features" section to list all twelve features. --- .../rules/MDS034-markdown-flavor/README.md | 26 +-- .../bad/commonmark-abbreviation.md | 16 ++ .../bad/commonmark-math-block.md | 13 ++ .../bad/commonmark-math-inline.md | 11 ++ .../bad/commonmark-subscript.md | 11 ++ .../bad/commonmark-superscript.md | 11 ++ internal/rules/markdownflavor/detect.go | 151 +++++++++++++----- internal/rules/markdownflavor/detect_test.go | 25 +++ .../rules/markdownflavor/ext/abbreviation.go | 4 + internal/rules/markdownflavor/parser.go | 14 +- plan/86_markdown-flavor-validation.md | 33 ++-- 11 files changed, 241 insertions(+), 74 deletions(-) create mode 100644 internal/rules/MDS034-markdown-flavor/bad/commonmark-abbreviation.md create mode 100644 internal/rules/MDS034-markdown-flavor/bad/commonmark-math-block.md create mode 100644 internal/rules/MDS034-markdown-flavor/bad/commonmark-math-inline.md create mode 100644 internal/rules/MDS034-markdown-flavor/bad/commonmark-subscript.md create mode 100644 internal/rules/MDS034-markdown-flavor/bad/commonmark-superscript.md diff --git a/internal/rules/MDS034-markdown-flavor/README.md b/internal/rules/MDS034-markdown-flavor/README.md index 0e7dc48c7..2c7a172e0 100644 --- a/internal/rules/MDS034-markdown-flavor/README.md +++ b/internal/rules/MDS034-markdown-flavor/README.md @@ -50,13 +50,16 @@ rules: ## Detected features -MDS034 tracks seven syntax features in this -increment. +MDS034 tracks twelve syntax features whose +support varies across Markdown flavors. -Six are detected from the goldmark AST. Each -relies on its built-in extension: table, -strikethrough, task list, footnote, definition -list, and the heading-ID attribute parser. +Eleven features are detected from the goldmark AST +of a dual parse. That parse enables five built-in +extensions: table, strikethrough, task list, +footnote, and definition list. It also enables the +heading-ID attribute parser. Five custom parsers +add superscript, subscript, math block, inline +math, and abbreviations. Bare-URL autolinks are detected separately. The detector scans text nodes from the main parse for @@ -72,12 +75,11 @@ spans, and code blocks. | footnotes | no | no | no | | definition lists | no | no | no | | heading IDs | no | no | yes | - -Five further features need custom goldmark -extensions: superscript, subscript, block math, -inline math, and abbreviations. They are tracked -under -[plan 86](../../../plan/86_markdown-flavor-validation.md). +| superscript | no | no | no | +| subscript | no | no | no | +| math blocks | no | no | no | +| inline math | no | no | no | +| abbreviations | no | no | no | ## Examples diff --git a/internal/rules/MDS034-markdown-flavor/bad/commonmark-abbreviation.md b/internal/rules/MDS034-markdown-flavor/bad/commonmark-abbreviation.md new file mode 100644 index 000000000..03b1b4f06 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/commonmark-abbreviation.md @@ -0,0 +1,16 @@ +--- +settings: + flavor: commonmark +diagnostics: + - line: 3 + column: 1 + message: "abbreviations are not supported by commonmark" + - line: 5 + column: 5 + message: "abbreviations are not supported by commonmark" +--- +# Heading + +*[HTML]: Hyper Text Markup Language + +Use HTML here. diff --git a/internal/rules/MDS034-markdown-flavor/bad/commonmark-math-block.md b/internal/rules/MDS034-markdown-flavor/bad/commonmark-math-block.md new file mode 100644 index 000000000..88ad7d7de --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/commonmark-math-block.md @@ -0,0 +1,13 @@ +--- +settings: + flavor: commonmark +diagnostics: + - line: 3 + column: 1 + message: "math blocks are not supported by commonmark" +--- +# Heading + +$$ +a^2 + b^2 = c^2 +$$ diff --git a/internal/rules/MDS034-markdown-flavor/bad/commonmark-math-inline.md b/internal/rules/MDS034-markdown-flavor/bad/commonmark-math-inline.md new file mode 100644 index 000000000..dd12bc228 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/commonmark-math-inline.md @@ -0,0 +1,11 @@ +--- +settings: + flavor: commonmark +diagnostics: + - line: 3 + column: 5 + message: "inline math is not supported by commonmark" +--- +# Heading + +See $x+1$ above. diff --git a/internal/rules/MDS034-markdown-flavor/bad/commonmark-subscript.md b/internal/rules/MDS034-markdown-flavor/bad/commonmark-subscript.md new file mode 100644 index 000000000..ef7940105 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/commonmark-subscript.md @@ -0,0 +1,11 @@ +--- +settings: + flavor: commonmark +diagnostics: + - line: 3 + column: 2 + message: "subscript is not supported by commonmark" +--- +# Heading + +H~2~O is water. diff --git a/internal/rules/MDS034-markdown-flavor/bad/commonmark-superscript.md b/internal/rules/MDS034-markdown-flavor/bad/commonmark-superscript.md new file mode 100644 index 000000000..673792ac5 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/commonmark-superscript.md @@ -0,0 +1,11 @@ +--- +settings: + flavor: commonmark +diagnostics: + - line: 3 + column: 7 + message: "superscript is not supported by commonmark" +--- +# Heading + +E = mc^2^ is famous. diff --git a/internal/rules/markdownflavor/detect.go b/internal/rules/markdownflavor/detect.go index 8f65c9833..ed77c03ad 100644 --- a/internal/rules/markdownflavor/detect.go +++ b/internal/rules/markdownflavor/detect.go @@ -9,6 +9,7 @@ import ( "github.com/yuin/goldmark/text" "github.com/jeduden/mdsmith/internal/lint" + "github.com/jeduden/mdsmith/internal/rules/markdownflavor/ext" ) // Finding records one detected feature use. @@ -105,6 +106,8 @@ func anyDualFeatureAccepted(keep func(Feature) bool) bool { for _, feat := range []Feature{ FeatureTables, FeatureTaskLists, FeatureStrikethrough, FeatureFootnotes, FeatureDefinitionLists, FeatureHeadingIDs, + FeatureSuperscript, FeatureSubscript, + FeatureMathBlock, FeatureMathInline, FeatureAbbreviations, } { if keep(feat) { return true @@ -113,54 +116,128 @@ func anyDualFeatureAccepted(keep func(Feature) bool) bool { return false } -// detectFromDual walks the dual-parser tree for all extension-based -// features (tables, strikethrough, task lists, footnotes, definition -// lists, heading IDs). +// detectFromDual walks the dual-parser tree for every feature that +// has an AST representation: the six built-in extensions (tables, +// strikethrough, task lists, footnotes, definition lists, heading +// IDs) plus the five MDS034 custom extensions (superscript, +// subscript, math block, math inline, abbreviations). func detectFromDual(f *lint.File, doc ast.Node) []Finding { var findings []Finding _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } - switch node := n.(type) { - case *extast.Table: - findings = append(findings, blockFinding(f, n, FeatureTables)) - return ast.WalkSkipChildren, nil - case *extast.TaskCheckBox: - // TaskCheckBox has no text children, so pull position from - // the enclosing ListItem block. - findings = append(findings, taskCheckBoxFinding(f, n)) - case *extast.Strikethrough: - // Strikethrough's first text child starts after the - // opening "~~"; back up two bytes to point at the marker. - fin := inlineFinding(f, n, FeatureStrikethrough) - if fin.Start >= 2 && f.Source[fin.Start-1] == '~' && f.Source[fin.Start-2] == '~' { - fin.Start -= 2 - fin.Column -= 2 - } - findings = append(findings, fin) - case *extast.FootnoteLink: - findings = append(findings, inlineExtFinding(f, n, FeatureFootnotes)) - case *extast.Footnote: - findings = append(findings, blockFinding(f, n, FeatureFootnotes)) - return ast.WalkSkipChildren, nil - case *extast.FootnoteList: - // Walk children so Footnote definitions report their own - // locations; skip emitting a wrapper finding. - return ast.WalkContinue, nil - case *extast.DefinitionList: - findings = append(findings, blockFinding(f, n, FeatureDefinitionLists)) - return ast.WalkSkipChildren, nil - case *ast.Heading: - if hf, ok := findHeadingID(f, node); ok { - findings = append(findings, hf) - } + fin, status := featureFindingFor(f, n) + if fin != nil { + findings = append(findings, *fin) } - return ast.WalkContinue, nil + return status, nil }) return dedupe(findings) } +// featureFindingFor maps an AST node to at most one Finding plus +// the walk-status to return for the rest of the walk. A nil pointer +// means "no finding for this node". +func featureFindingFor(f *lint.File, n ast.Node) (*Finding, ast.WalkStatus) { + if fin, status, ok := builtinFindingFor(f, n); ok { + return fin, status + } + if fin, status, ok := customFindingFor(f, n); ok { + return fin, status + } + return nil, ast.WalkContinue +} + +// builtinFindingFor handles the six features detected via goldmark's +// built-in extensions plus the heading-ID attribute parser. +func builtinFindingFor(f *lint.File, n ast.Node) (*Finding, ast.WalkStatus, bool) { + switch node := n.(type) { + case *extast.Table: + fin := blockFinding(f, n, FeatureTables) + return &fin, ast.WalkSkipChildren, true + case *extast.TaskCheckBox: + fin := taskCheckBoxFinding(f, n) + return &fin, ast.WalkContinue, true + case *extast.Strikethrough: + fin := strikethroughFinding(f, n) + return &fin, ast.WalkContinue, true + case *extast.FootnoteLink: + fin := inlineExtFinding(f, n, FeatureFootnotes) + return &fin, ast.WalkContinue, true + case *extast.Footnote: + fin := blockFinding(f, n, FeatureFootnotes) + return &fin, ast.WalkSkipChildren, true + case *extast.FootnoteList: + // Walk children so Footnote definitions report their own + // locations; skip emitting a wrapper finding. + return nil, ast.WalkContinue, true + case *extast.DefinitionList: + fin := blockFinding(f, n, FeatureDefinitionLists) + return &fin, ast.WalkSkipChildren, true + case *ast.Heading: + if hf, ok := findHeadingID(f, node); ok { + return &hf, ast.WalkContinue, true + } + return nil, ast.WalkContinue, true + } + return nil, ast.WalkContinue, false +} + +// customFindingFor handles the five features covered by MDS034 +// custom extensions: superscript, subscript, math block / inline, +// and abbreviations (both definition and reference). +func customFindingFor(f *lint.File, n ast.Node) (*Finding, ast.WalkStatus, bool) { + switch n.(type) { + case *ext.SuperscriptNode: + fin := markerInlineFinding(f, n, FeatureSuperscript, '^') + return &fin, ast.WalkContinue, true + case *ext.SubscriptNode: + fin := markerInlineFinding(f, n, FeatureSubscript, '~') + return &fin, ast.WalkContinue, true + case *ext.MathBlockNode: + fin := blockFinding(f, n, FeatureMathBlock) + return &fin, ast.WalkSkipChildren, true + case *ext.MathInlineNode: + fin := markerInlineFinding(f, n, FeatureMathInline, '$') + return &fin, ast.WalkContinue, true + case *ext.AbbreviationDefinition: + fin := blockFinding(f, n, FeatureAbbreviations) + return &fin, ast.WalkSkipChildren, true + case *ext.AbbreviationReference: + // The reference carries a child Text with the term's exact + // source segment, so inlineFinding pulls the real column + // rather than the enclosing paragraph start. + fin := inlineFinding(f, n, FeatureAbbreviations) + return &fin, ast.WalkContinue, true + } + return nil, ast.WalkContinue, false +} + +// strikethroughFinding backs up past the opening "~~" so the +// diagnostic points at the marker, not at the content character. +func strikethroughFinding(f *lint.File, n ast.Node) Finding { + fin := inlineFinding(f, n, FeatureStrikethrough) + if fin.Start >= 2 && f.Source[fin.Start-1] == '~' && f.Source[fin.Start-2] == '~' { + fin.Start -= 2 + fin.Column -= 2 + } + return fin +} + +// markerInlineFinding backs up a single opening marker byte before +// the first text descendant. Used for superscript / subscript / +// inline-math spans where the first child text starts after the +// single-byte marker. +func markerInlineFinding(f *lint.File, n ast.Node, feat Feature, marker byte) Finding { + fin := inlineFinding(f, n, feat) + if fin.Start >= 1 && f.Source[fin.Start-1] == marker { + fin.Start-- + fin.Column-- + } + return fin +} + // blockFinding reports a block-level feature starting at column 1 of // the line containing the node's first text descendant. func blockFinding(f *lint.File, n ast.Node, feat Feature) Finding { diff --git a/internal/rules/markdownflavor/detect_test.go b/internal/rules/markdownflavor/detect_test.go index b1a478252..3ad4ae862 100644 --- a/internal/rules/markdownflavor/detect_test.go +++ b/internal/rules/markdownflavor/detect_test.go @@ -146,6 +146,31 @@ func TestDetectEmptyDocument(t *testing.T) { assert.Empty(t, fs) } +func TestDetectSuperscript(t *testing.T) { + fs := findings(t, "E = mc^2^\n") + require.True(t, hasFeature(fs, FeatureSuperscript)) +} + +func TestDetectSubscript(t *testing.T) { + fs := findings(t, "H~2~O\n") + require.True(t, hasFeature(fs, FeatureSubscript)) +} + +func TestDetectMathBlock(t *testing.T) { + fs := findings(t, "$$\na^2 + b^2 = c^2\n$$\n") + require.True(t, hasFeature(fs, FeatureMathBlock)) +} + +func TestDetectMathInline(t *testing.T) { + fs := findings(t, "foo $x+1$ bar\n") + require.True(t, hasFeature(fs, FeatureMathInline)) +} + +func TestDetectAbbreviations(t *testing.T) { + fs := findings(t, "*[API]: Application Programming Interface\n\nUse API here.\n") + require.True(t, hasFeature(fs, FeatureAbbreviations)) +} + func TestDetectPlainCommonMark(t *testing.T) { src := "# Heading\n\nA paragraph.\n\n- bullet\n- another\n\n" + "```go\nfmt.Println(\"hi\")\n```\n" diff --git a/internal/rules/markdownflavor/ext/abbreviation.go b/internal/rules/markdownflavor/ext/abbreviation.go index 64fbde882..605d9f706 100644 --- a/internal/rules/markdownflavor/ext/abbreviation.go +++ b/internal/rules/markdownflavor/ext/abbreviation.go @@ -127,6 +127,10 @@ func (p *abbreviationBlockParser) Open( expCopy := append([]byte(nil), expansion...) node := &AbbreviationDefinition{Term: termCopy, Expansion: expCopy} + // Record the raw source span so detectors (and a future Fix) can + // locate this definition without re-scanning. + _, seg := reader.PeekLine() + node.Lines().Append(seg) tbl := getAbbrTable(pc) tbl[string(termCopy)] = expCopy diff --git a/internal/rules/markdownflavor/parser.go b/internal/rules/markdownflavor/parser.go index c0f4abcd9..046f80ecb 100644 --- a/internal/rules/markdownflavor/parser.go +++ b/internal/rules/markdownflavor/parser.go @@ -8,6 +8,7 @@ import ( "github.com/yuin/goldmark/parser" "github.com/jeduden/mdsmith/internal/lint" + "github.com/jeduden/mdsmith/internal/rules/markdownflavor/ext" ) var ( @@ -16,9 +17,11 @@ var ( ) // Parser returns the shared goldmark parser used for dual parsing. -// It enables every built-in goldmark extension MDS034 actually needs -// for AST-based feature detection (table, strikethrough, task list, -// footnote, definition list) and the heading-ID attribute parser. +// It enables the built-in goldmark extensions for the seven +// AST-detected core features (table, strikethrough, task list, +// footnote, definition list) plus the heading-ID attribute parser, +// and the five custom MDS034 extensions that cover superscript, +// subscript, math block, math inline, and abbreviations. // // To keep MDS034 aligned with the rest of mdsmith, the dual parser // also registers lint.PIBlockParserPrioritized so a @@ -44,6 +47,11 @@ func Parser() goldmark.Markdown { extension.TaskList, extension.Footnote, extension.DefinitionList, + ext.Superscript, + ext.Subscript, + ext.MathBlock, + ext.MathInline, + ext.Abbreviation, ), goldmark.WithParserOptions( parser.WithAttribute(), diff --git a/plan/86_markdown-flavor-validation.md b/plan/86_markdown-flavor-validation.md index 5b9495e6c..de1c64e41 100644 --- a/plan/86_markdown-flavor-validation.md +++ b/plan/86_markdown-flavor-validation.md @@ -182,13 +182,14 @@ math, abbreviations. anywhere in the document; it reads the term table the block parser built during Open. --> - [x] Add tests for all five custom extensions -- [x] Build dual parser with built-in extensions (custom - extensions pending) -- [x] Add AST-based detectors for the seven built-in - features. Covered here: tables, task lists, - strikethrough, bare-URL autolinks, footnotes, - definition lists, and heading IDs. The five custom - features remain pending. +- [x] Build dual parser with built-in + custom + extensions (superscript, subscript, math block, + math inline, abbreviations) +- [x] Add AST-based detectors for all 12 features. + Covered: tables, task lists, strikethrough, + bare-URL autolinks, footnotes, definition lists, + heading IDs, superscript, subscript, math blocks, + inline math, and abbreviations. - [x] Implement `rule.go` with `Check()`; `Fix()` is pending - [x] Implement `rule.Configurable` for MDS034: add @@ -203,33 +204,21 @@ math, abbreviations. ## Acceptance Criteria -- [ ] `flavor: commonmark` flags tables, task lists, +- [x] `flavor: commonmark` flags tables, task lists, strikethrough, bare-URL autolinks, footnotes, definition lists, heading IDs, superscript, subscript, math blocks, math inline, and abbreviations - -- [ ] `flavor: gfm` accepts tables, task lists, +- [x] `flavor: gfm` accepts tables, task lists, strikethrough, and bare-URL autolinks; flags footnotes, definition lists, heading IDs, superscript, subscript, math blocks, math inline, and abbreviations - -- [ ] `flavor: goldmark` accepts tables, task lists, +- [x] `flavor: goldmark` accepts tables, task lists, strikethrough, bare-URL autolinks, and heading IDs; flags footnotes, definition lists, superscript, subscript, math blocks, math inline, and abbreviations - - [x] Error messages name the unsupported feature and the configured flavor - [ ] `mdsmith fix` auto-fixes fixable features From 4b3edf567ad86deb53ca9d89ad7c663bf6efa2ff Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 06:43:03 +0000 Subject: [PATCH 23/30] fix: address post-wiring Copilot round on MDS034 extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three threads: - mathblock.go Open() doc said a \`\$\$\` fence only opens "at the document root" but the code doesn't check the parent block. Reword the comment so it describes the real behaviour: opens regardless of parent so long as the line starts with \`\$\$\` at up to 3 columns of indent. - abbreviation.go Transform() doc promised "paragraph"- only rewriting, but the walk visits every Text node outside code / def nodes. Reword to match: code spans, fenced / indented code blocks, and AbbreviationDefinition nodes are skipped; everything else (paragraphs, headings, list items, …) is eligible. - abbreviation.go buildReference had an unused source parameter that every caller passed nil for. Drop the parameter so the helper no longer pretends it might need the source bytes. --- .../rules/markdownflavor/ext/abbreviation.go | 16 +++++++++------- internal/rules/markdownflavor/ext/mathblock.go | 6 +++--- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/internal/rules/markdownflavor/ext/abbreviation.go b/internal/rules/markdownflavor/ext/abbreviation.go index 605d9f706..e4e7690ee 100644 --- a/internal/rules/markdownflavor/ext/abbreviation.go +++ b/internal/rules/markdownflavor/ext/abbreviation.go @@ -176,9 +176,11 @@ func (t *abbreviationTransformer) Transform(doc *ast.Document, reader text.Reade } source := reader.Source() - // Walk paragraphs and rewrite their Text descendants. Inline - // code spans and raw inlines are skipped so their contents stay - // literal. + // Walk every Text descendant in the document and rewrite + // whole-word term matches. Code spans, fenced and indented code + // blocks, and AbbreviationDefinition nodes are skipped so their + // contents stay literal; everything else (paragraphs, headings, + // list items, etc.) is eligible for marking. _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil @@ -274,12 +276,12 @@ func applyMatches(parent ast.Node, t *ast.Text, seg text.Segment, body []byte, m first := matches[0] var anchor ast.Node if first.start == 0 { - ref := buildReference(seg, first, nil) + ref := buildReference(seg, first) parent.ReplaceChild(parent, t, ref) anchor = ref } else { t.Segment = seg.WithStop(seg.Start + first.start) - ref := buildReference(seg, first, nil) + ref := buildReference(seg, first) parent.InsertAfter(parent, t, ref) anchor = ref } @@ -296,7 +298,7 @@ func appendRestAfter(parent, anchor ast.Node, seg text.Segment, rest []abbrMatch parent.InsertAfter(parent, anchor, gap) anchor = gap } - ref := buildReference(seg, m, nil) + ref := buildReference(seg, m) parent.InsertAfter(parent, anchor, ref) anchor = ref prev = m.end @@ -325,7 +327,7 @@ func lastEnd(first abbrMatch, rest []abbrMatch) int { // buildReference constructs an AbbreviationReference whose child // Text covers the term's byte span. -func buildReference(parentSeg text.Segment, m abbrMatch, _ []byte) ast.Node { +func buildReference(parentSeg text.Segment, m abbrMatch) ast.Node { ref := &AbbreviationReference{Term: []byte(m.term)} ref.AppendChild(ref, ast.NewTextSegment(subSeg(parentSeg, m.start, m.end))) return ref diff --git a/internal/rules/markdownflavor/ext/mathblock.go b/internal/rules/markdownflavor/ext/mathblock.go index d221dc3c6..69db0b446 100644 --- a/internal/rules/markdownflavor/ext/mathblock.go +++ b/internal/rules/markdownflavor/ext/mathblock.go @@ -47,9 +47,9 @@ func (p *mathBlockParser) Trigger() []byte { return []byte{'$'} } var mathFence = []byte("$$") // Open implements parser.BlockParser. A line that starts with `$$` -// (after up to three spaces of indent) at the document root opens a -// math-block node. If the same line also contains a closing `$$`, -// the block is closed immediately. +// after up to three spaces of indent opens a math-block node, +// regardless of its parent block. If the same line also contains a +// closing `$$`, the block is closed immediately. func (p *mathBlockParser) Open( parent ast.Node, reader text.Reader, pc parser.Context, ) (ast.Node, parser.State) { From ac4f2b98e40311fcdd150bad3c3f01cd172759ae Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 15:33:20 +0000 Subject: [PATCH 24/30] Plan 86: add pandoc, phpextra, multimarkdown, myst, any flavors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per user request: support four additional common Markdown dialects plus a permissive "accept all" flavor so MDS034 can be applied to a wider range of docs without toggling off each feature by hand. New flavors and their support sets: - any — accepts every tracked feature; use when the target renderer is unknown or permissive. - pandoc — GFM + footnotes, definition lists, heading IDs, superscript, subscript, math block, and inline math. Rejects abbreviations (not a default Pandoc extension). - phpextra — PHP Markdown Extra: tables, footnotes, definition lists, heading IDs, and abbreviations. Rejects GFM features and math. - multimarkdown — PHP Extra + math block + inline math. - myst — MyST (Sphinx flavor): tables, strikethrough, footnotes, definition lists, heading IDs, math block, and inline math. Flavor.Supports short-circuits on FlavorAny so its matrix stays empty; every other flavor consults the explicit support table. Tests split into one per-flavor case plus a rule-level assertion that flavor: any silences all diagnostics. A phpextra integration test exercises a mixed document to verify selective rejection. Good fixtures land for every new flavor and four bad fixtures cover flavor-specific rejection paths (pandoc + abbreviation, phpextra + strikethrough, multimarkdown + task list, myst + abbreviation). README "Settings" section expands with the per- flavor explanation; "Detected features" table adds columns for the four new named flavors (any is documented separately to keep the table within MDS026's 8-column limit). --- .../rules/MDS034-markdown-flavor/README.md | 70 ++++++++----- .../bad/multimarkdown-task-list.md | 15 +++ .../bad/myst-abbreviation.md | 16 +++ .../bad/pandoc-abbreviation.md | 16 +++ .../bad/phpextra-strikethrough.md | 11 +++ .../rules/MDS034-markdown-flavor/good/any.md | 33 +++++++ .../good/multimarkdown.md | 26 +++++ .../rules/MDS034-markdown-flavor/good/myst.md | 24 +++++ .../MDS034-markdown-flavor/good/pandoc.md | 25 +++++ .../MDS034-markdown-flavor/good/phpextra.md | 20 ++++ internal/rules/markdownflavor/features.go | 98 ++++++++++++++++++- .../rules/markdownflavor/features_test.go | 95 +++++++++++++----- internal/rules/markdownflavor/rule.go | 3 +- internal/rules/markdownflavor/rule_test.go | 48 ++++++++- 14 files changed, 445 insertions(+), 55 deletions(-) create mode 100644 internal/rules/MDS034-markdown-flavor/bad/multimarkdown-task-list.md create mode 100644 internal/rules/MDS034-markdown-flavor/bad/myst-abbreviation.md create mode 100644 internal/rules/MDS034-markdown-flavor/bad/pandoc-abbreviation.md create mode 100644 internal/rules/MDS034-markdown-flavor/bad/phpextra-strikethrough.md create mode 100644 internal/rules/MDS034-markdown-flavor/good/any.md create mode 100644 internal/rules/MDS034-markdown-flavor/good/multimarkdown.md create mode 100644 internal/rules/MDS034-markdown-flavor/good/myst.md create mode 100644 internal/rules/MDS034-markdown-flavor/good/pandoc.md create mode 100644 internal/rules/MDS034-markdown-flavor/good/phpextra.md diff --git a/internal/rules/MDS034-markdown-flavor/README.md b/internal/rules/MDS034-markdown-flavor/README.md index 2c7a172e0..ddebb219c 100644 --- a/internal/rules/MDS034-markdown-flavor/README.md +++ b/internal/rules/MDS034-markdown-flavor/README.md @@ -22,14 +22,37 @@ flavor does not render. ## Settings -| Key | Type | Description | -|--------|--------|---------------------------------------------------| -| flavor | string | Target flavor: `commonmark`, `gfm`, or `goldmark` | - -The flavor name is case-sensitive. The `goldmark` -profile is mdsmith-defined. It accepts GFM features -plus heading IDs. It does not accept optional -footnote, definition-list, or math extensions. +| Key | Type | Description | +|--------|--------|---------------------------------| +| flavor | string | Target flavor; see table below. | + +The flavor name is case-sensitive. Supported +values: + +- `commonmark` — strict CommonMark; rejects every + tracked feature. +- `gfm` — GitHub Flavored Markdown; adds tables, + task lists, strikethrough, and bare-URL + autolinks. +- `goldmark` — mdsmith-defined profile; GFM plus + heading IDs. +- `pandoc` — Pandoc's default markdown; GFM plus + footnotes, definition lists, heading IDs, + superscript, subscript, math block, and inline + math. Rejects abbreviations (non-default + extension). +- `phpextra` — PHP Markdown Extra; tables, + footnotes, definition lists, heading IDs, and + abbreviations. Rejects GFM features and math. +- `multimarkdown` — MultiMarkdown; PHP Extra plus + math block and inline math. +- `myst` — MyST (Sphinx documentation flavor); + tables, strikethrough, footnotes, definition + lists, heading IDs, math block, and inline math. +- `any` — accepts every tracked feature. Use when + the target renderer is unknown or permissive and + you want to silence flavor diagnostics without + disabling the rule. ## Config @@ -66,20 +89,23 @@ detector scans text nodes from the main parse for URL-shaped text. It skips links, autolinks, code spans, and code blocks. -| Feature | commonmark | gfm | goldmark | -|--------------------|------------|-----|----------| -| tables | no | yes | yes | -| task lists | no | yes | yes | -| strikethrough | no | yes | yes | -| bare-URL autolinks | no | yes | yes | -| footnotes | no | no | no | -| definition lists | no | no | no | -| heading IDs | no | no | yes | -| superscript | no | no | no | -| subscript | no | no | no | -| math blocks | no | no | no | -| inline math | no | no | no | -| abbreviations | no | no | no | +`flavor: any` accepts every feature and is omitted +from the table below. + +| Feature | commonmark | gfm | goldmark | pandoc | phpextra | multimarkdown | myst | +|--------------------|------------|-----|----------|--------|----------|---------------|------| +| tables | no | yes | yes | yes | yes | yes | yes | +| task lists | no | yes | yes | yes | no | no | no | +| strikethrough | no | yes | yes | yes | no | no | yes | +| bare-URL autolinks | no | yes | yes | yes | no | no | no | +| footnotes | no | no | no | yes | yes | yes | yes | +| definition lists | no | no | no | yes | yes | yes | yes | +| heading IDs | no | no | yes | yes | yes | yes | yes | +| superscript | no | no | no | yes | no | no | no | +| subscript | no | no | no | yes | no | no | no | +| 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 | ## Examples diff --git a/internal/rules/MDS034-markdown-flavor/bad/multimarkdown-task-list.md b/internal/rules/MDS034-markdown-flavor/bad/multimarkdown-task-list.md new file mode 100644 index 000000000..65b8e5e1e --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/multimarkdown-task-list.md @@ -0,0 +1,15 @@ +--- +settings: + flavor: multimarkdown +diagnostics: + - line: 3 + column: 3 + message: "task lists are not supported by multimarkdown" + - line: 4 + column: 3 + message: "task lists are not supported by multimarkdown" +--- +# Heading + +- [x] done +- [ ] todo diff --git a/internal/rules/MDS034-markdown-flavor/bad/myst-abbreviation.md b/internal/rules/MDS034-markdown-flavor/bad/myst-abbreviation.md new file mode 100644 index 000000000..cef9ba0ce --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/myst-abbreviation.md @@ -0,0 +1,16 @@ +--- +settings: + flavor: myst +diagnostics: + - line: 3 + column: 1 + message: "abbreviations are not supported by myst" + - line: 5 + column: 5 + message: "abbreviations are not supported by myst" +--- +# Heading + +*[HTML]: Hyper Text Markup Language + +Use HTML here. diff --git a/internal/rules/MDS034-markdown-flavor/bad/pandoc-abbreviation.md b/internal/rules/MDS034-markdown-flavor/bad/pandoc-abbreviation.md new file mode 100644 index 000000000..395be36cb --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/pandoc-abbreviation.md @@ -0,0 +1,16 @@ +--- +settings: + flavor: pandoc +diagnostics: + - line: 3 + column: 1 + message: "abbreviations are not supported by pandoc" + - line: 5 + column: 5 + message: "abbreviations are not supported by pandoc" +--- +# Heading + +*[HTML]: Hyper Text Markup Language + +Use HTML here. diff --git a/internal/rules/MDS034-markdown-flavor/bad/phpextra-strikethrough.md b/internal/rules/MDS034-markdown-flavor/bad/phpextra-strikethrough.md new file mode 100644 index 000000000..310a55dc6 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/phpextra-strikethrough.md @@ -0,0 +1,11 @@ +--- +settings: + flavor: phpextra +diagnostics: + - line: 3 + column: 6 + message: "strikethrough is not supported by phpextra" +--- +# Heading + +Text ~~crossed out~~ here. diff --git a/internal/rules/MDS034-markdown-flavor/good/any.md b/internal/rules/MDS034-markdown-flavor/good/any.md new file mode 100644 index 000000000..31cf1f91a --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/good/any.md @@ -0,0 +1,33 @@ +--- +settings: + flavor: any +--- +# Heading {#top} + +Text with ~~old~~ markup and a task list: + +- [x] done +- [ ] todo + +| a | b | +|-----|-----| +| 1 | 2 | + +Footnote reference.[^1] + +[^1]: footnote body. + +term +: definition + +E = mc^2^ and H~2~O. + +$x+1$ inline and + +$$ +a^2 + b^2 = c^2 +$$ + +*[API]: Application Programming Interface + +Use API here. diff --git a/internal/rules/MDS034-markdown-flavor/good/multimarkdown.md b/internal/rules/MDS034-markdown-flavor/good/multimarkdown.md new file mode 100644 index 000000000..18868ca77 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/good/multimarkdown.md @@ -0,0 +1,26 @@ +--- +settings: + flavor: multimarkdown +--- +# Heading {#top} + +| a | b | +|-----|-----| +| 1 | 2 | + +Footnote reference.[^1] + +[^1]: footnote body. + +term +: definition + +*[API]: Application Programming Interface + +Use API here. + +See $x+1$ inline and + +$$ +a^2 + b^2 = c^2 +$$ diff --git a/internal/rules/MDS034-markdown-flavor/good/myst.md b/internal/rules/MDS034-markdown-flavor/good/myst.md new file mode 100644 index 000000000..4e4b7e62e --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/good/myst.md @@ -0,0 +1,24 @@ +--- +settings: + flavor: myst +--- +# Heading {#top} + +Text with ~~old~~ markup. + +| a | b | +|-----|-----| +| 1 | 2 | + +Footnote reference.[^1] + +[^1]: footnote body. + +term +: definition + +See $x+1$ inline and + +$$ +a^2 + b^2 = c^2 +$$ diff --git a/internal/rules/MDS034-markdown-flavor/good/pandoc.md b/internal/rules/MDS034-markdown-flavor/good/pandoc.md new file mode 100644 index 000000000..069b8de2d --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/good/pandoc.md @@ -0,0 +1,25 @@ +--- +settings: + flavor: pandoc +--- +# Heading {#top} + +Text with ~~old~~ markup and a task list: + +- [x] done +- [ ] todo + +Footnote reference.[^1] + +[^1]: footnote body. + +term +: definition + +E = mc^2^ and H~2~O. + +See $x+1$ and + +$$ +a^2 + b^2 = c^2 +$$ diff --git a/internal/rules/MDS034-markdown-flavor/good/phpextra.md b/internal/rules/MDS034-markdown-flavor/good/phpextra.md new file mode 100644 index 000000000..c408338d3 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/good/phpextra.md @@ -0,0 +1,20 @@ +--- +settings: + flavor: phpextra +--- +# Heading {#top} + +| a | b | +|-----|-----| +| 1 | 2 | + +Footnote reference.[^1] + +[^1]: footnote body. + +term +: definition + +*[API]: Application Programming Interface + +Use API here. diff --git a/internal/rules/markdownflavor/features.go b/internal/rules/markdownflavor/features.go index d2a05f5e4..c4d704a7d 100644 --- a/internal/rules/markdownflavor/features.go +++ b/internal/rules/markdownflavor/features.go @@ -1,6 +1,7 @@ // Package markdownflavor implements MDS034, which validates Markdown -// against a declared target flavor (commonmark, gfm, goldmark) and -// flags syntax the target renderer will not understand. +// against a declared target flavor (commonmark, gfm, goldmark, +// pandoc, phpextra, multimarkdown, myst, or any) and flags syntax +// the target renderer will not understand. package markdownflavor // Flavor identifies a target Markdown flavor. @@ -13,6 +14,31 @@ const ( FlavorCommonMark FlavorGFM FlavorGoldmark + // FlavorAny accepts every tracked feature. Useful when the + // document is destined for an unknown or permissive renderer and + // the user wants to disable flavor reporting without disabling + // the rule. + FlavorAny + // FlavorPandoc is Pandoc's default markdown dialect. Accepts + // GFM's four features plus footnotes, definition lists, heading + // IDs, superscript, subscript, math block, and inline math; + // rejects abbreviations (a non-default Pandoc extension). + FlavorPandoc + // FlavorPHPExtra is PHP Markdown Extra. Accepts tables, + // footnotes, definition lists, heading IDs, and abbreviations; + // rejects GFM's task lists, strikethrough, bare-URL autolinks, + // and every math / sub/superscript feature. + FlavorPHPExtra + // FlavorMultiMarkdown extends PHP Markdown Extra with math + // block and inline math. Like PHP Extra, rejects GFM task lists, + // strikethrough, bare-URL autolinks, and sub/superscript. + FlavorMultiMarkdown + // FlavorMyST is the MyST flavor used by the Sphinx documentation + // toolchain. Accepts tables, strikethrough, footnotes, + // definition lists, heading IDs, math block, and inline math; + // rejects GFM task lists, bare-URL autolinks, sub/superscript, + // and abbreviations. + FlavorMyST ) // String returns the canonical lowercase name of the flavor. @@ -24,6 +50,16 @@ func (f Flavor) String() string { return "gfm" case FlavorGoldmark: return "goldmark" + case FlavorAny: + return "any" + case FlavorPandoc: + return "pandoc" + case FlavorPHPExtra: + return "phpextra" + case FlavorMultiMarkdown: + return "multimarkdown" + case FlavorMyST: + return "myst" } return "" } @@ -39,6 +75,16 @@ func ParseFlavor(s string) (Flavor, bool) { return FlavorGFM, true case "goldmark": return FlavorGoldmark, true + case "any": + return FlavorAny, true + case "pandoc": + return FlavorPandoc, true + case "phpextra": + return FlavorPHPExtra, true + case "multimarkdown": + return FlavorMultiMarkdown, true + case "myst": + return FlavorMyST, true } return 0, false } @@ -128,8 +174,9 @@ func (f Feature) Name() string { // support maps (flavor, feature) to whether the flavor accepts it. // CommonMark rejects every tracked feature. GFM adds tables, task // lists, strikethrough, and bare-URL autolinks. The goldmark profile -// further adds heading IDs but still rejects the optional extensions -// (footnotes, definition lists, math, sub/sup, abbreviations). +// further adds heading IDs. Pandoc, PHP Markdown Extra, MultiMarkdown, +// and MyST each pick a different combination of the optional +// features; FlavorAny is handled specially in Supports. var support = map[Flavor]map[Feature]bool{ FlavorGFM: { FeatureTables: true, @@ -144,9 +191,52 @@ var support = map[Flavor]map[Feature]bool{ FeatureBareURLAutolinks: true, FeatureHeadingIDs: true, }, + FlavorPandoc: { + FeatureTables: true, + FeatureTaskLists: true, + FeatureStrikethrough: true, + FeatureBareURLAutolinks: true, + FeatureFootnotes: true, + FeatureDefinitionLists: true, + FeatureHeadingIDs: true, + FeatureSuperscript: true, + FeatureSubscript: true, + FeatureMathBlock: true, + FeatureMathInline: true, + }, + FlavorPHPExtra: { + FeatureTables: true, + FeatureFootnotes: true, + FeatureDefinitionLists: true, + FeatureHeadingIDs: true, + FeatureAbbreviations: true, + }, + FlavorMultiMarkdown: { + FeatureTables: true, + FeatureFootnotes: true, + FeatureDefinitionLists: true, + FeatureHeadingIDs: true, + FeatureAbbreviations: true, + FeatureMathBlock: true, + FeatureMathInline: true, + }, + FlavorMyST: { + FeatureTables: true, + FeatureStrikethrough: true, + FeatureFootnotes: true, + FeatureDefinitionLists: true, + FeatureHeadingIDs: true, + FeatureMathBlock: true, + FeatureMathInline: true, + }, } // Supports reports whether the flavor accepts the given feature. +// FlavorAny accepts every feature; other flavors consult the +// support table. func (f Flavor) Supports(feat Feature) bool { + if f == FlavorAny { + return true + } return support[f][feat] } diff --git a/internal/rules/markdownflavor/features_test.go b/internal/rules/markdownflavor/features_test.go index 5ed0ce3f5..0c0e47cf9 100644 --- a/internal/rules/markdownflavor/features_test.go +++ b/internal/rules/markdownflavor/features_test.go @@ -16,6 +16,11 @@ func TestParseFlavor(t *testing.T) { {"commonmark", FlavorCommonMark, true}, {"gfm", FlavorGFM, true}, {"goldmark", FlavorGoldmark, true}, + {"any", FlavorAny, true}, + {"pandoc", FlavorPandoc, true}, + {"phpextra", FlavorPHPExtra, true}, + {"multimarkdown", FlavorMultiMarkdown, true}, + {"myst", FlavorMyST, true}, {"GFM", 0, false}, {"", 0, false}, {"markdown", 0, false}, @@ -35,39 +40,75 @@ func TestFlavorString(t *testing.T) { assert.Equal(t, "commonmark", FlavorCommonMark.String()) assert.Equal(t, "gfm", FlavorGFM.String()) assert.Equal(t, "goldmark", FlavorGoldmark.String()) + assert.Equal(t, "any", FlavorAny.String()) + assert.Equal(t, "pandoc", FlavorPandoc.String()) + assert.Equal(t, "phpextra", FlavorPHPExtra.String()) + assert.Equal(t, "multimarkdown", FlavorMultiMarkdown.String()) + assert.Equal(t, "myst", FlavorMyST.String()) } -func TestFeatureSupport(t *testing.T) { - // CommonMark rejects every feature MDS034 tracks. - for _, f := range AllFeatures() { - assert.False(t, FlavorCommonMark.Supports(f), - "CommonMark must reject %s", f.Name()) +// assertSupports checks every feature in the supported set is +// accepted by flavor and every feature not in that set is rejected. +func assertSupports(t *testing.T, f Flavor, supported ...Feature) { + t.Helper() + want := map[Feature]bool{} + for _, feat := range supported { + want[feat] = true } + for _, feat := range AllFeatures() { + got := f.Supports(feat) + assert.Equal(t, want[feat], got, + "flavor %s feature %s: want=%v got=%v", + f.String(), feat.Name(), want[feat], got) + } +} + +func TestFeatureSupportCommonMark(t *testing.T) { + assertSupports(t, FlavorCommonMark) +} + +func TestFeatureSupportGFM(t *testing.T) { + assertSupports(t, FlavorGFM, + FeatureTables, FeatureTaskLists, FeatureStrikethrough, + FeatureBareURLAutolinks) +} + +func TestFeatureSupportGoldmark(t *testing.T) { + assertSupports(t, FlavorGoldmark, + FeatureTables, FeatureTaskLists, FeatureStrikethrough, + FeatureBareURLAutolinks, FeatureHeadingIDs) +} + +func TestFeatureSupportAny(t *testing.T) { + assertSupports(t, FlavorAny, AllFeatures()...) +} - // GFM supports tables, task lists, strikethrough, bare-URL autolinks. - assert.True(t, FlavorGFM.Supports(FeatureTables)) - assert.True(t, FlavorGFM.Supports(FeatureTaskLists)) - assert.True(t, FlavorGFM.Supports(FeatureStrikethrough)) - assert.True(t, FlavorGFM.Supports(FeatureBareURLAutolinks)) +func TestFeatureSupportPandoc(t *testing.T) { + assertSupports(t, FlavorPandoc, + FeatureTables, FeatureTaskLists, FeatureStrikethrough, + FeatureBareURLAutolinks, FeatureFootnotes, FeatureDefinitionLists, + FeatureHeadingIDs, FeatureSuperscript, FeatureSubscript, + FeatureMathBlock, FeatureMathInline) +} - // GFM rejects footnotes, definition lists, heading IDs, math, sub/sup, abbr. - assert.False(t, FlavorGFM.Supports(FeatureFootnotes)) - assert.False(t, FlavorGFM.Supports(FeatureDefinitionLists)) - assert.False(t, FlavorGFM.Supports(FeatureHeadingIDs)) - assert.False(t, FlavorGFM.Supports(FeatureSuperscript)) - assert.False(t, FlavorGFM.Supports(FeatureSubscript)) - assert.False(t, FlavorGFM.Supports(FeatureMathBlock)) - assert.False(t, FlavorGFM.Supports(FeatureMathInline)) - assert.False(t, FlavorGFM.Supports(FeatureAbbreviations)) +func TestFeatureSupportPHPExtra(t *testing.T) { + assertSupports(t, FlavorPHPExtra, + FeatureTables, FeatureFootnotes, FeatureDefinitionLists, + FeatureHeadingIDs, FeatureAbbreviations) +} + +func TestFeatureSupportMultiMarkdown(t *testing.T) { + assertSupports(t, FlavorMultiMarkdown, + FeatureTables, FeatureFootnotes, FeatureDefinitionLists, + FeatureHeadingIDs, FeatureAbbreviations, + FeatureMathBlock, FeatureMathInline) +} - // goldmark profile: GFM features + heading IDs. - assert.True(t, FlavorGoldmark.Supports(FeatureTables)) - assert.True(t, FlavorGoldmark.Supports(FeatureTaskLists)) - assert.True(t, FlavorGoldmark.Supports(FeatureStrikethrough)) - assert.True(t, FlavorGoldmark.Supports(FeatureBareURLAutolinks)) - assert.True(t, FlavorGoldmark.Supports(FeatureHeadingIDs)) - assert.False(t, FlavorGoldmark.Supports(FeatureFootnotes)) - assert.False(t, FlavorGoldmark.Supports(FeatureDefinitionLists)) +func TestFeatureSupportMyST(t *testing.T) { + assertSupports(t, FlavorMyST, + FeatureTables, FeatureStrikethrough, FeatureFootnotes, + FeatureDefinitionLists, FeatureHeadingIDs, + FeatureMathBlock, FeatureMathInline) } func TestAllFeaturesComplete(t *testing.T) { diff --git a/internal/rules/markdownflavor/rule.go b/internal/rules/markdownflavor/rule.go index 60d43410a..7b0bb343f 100644 --- a/internal/rules/markdownflavor/rule.go +++ b/internal/rules/markdownflavor/rule.go @@ -45,7 +45,8 @@ func (r *Rule) ApplySettings(settings map[string]any) error { fl, ok := ParseFlavor(s) if !ok { return fmt.Errorf( - "markdown-flavor: unknown flavor %q (expected commonmark, gfm, or goldmark)", + "markdown-flavor: unknown flavor %q (expected one of: "+ + "any, commonmark, gfm, goldmark, multimarkdown, myst, pandoc, phpextra)", s, ) } diff --git a/internal/rules/markdownflavor/rule_test.go b/internal/rules/markdownflavor/rule_test.go index 7bffa3e28..f83df18d6 100644 --- a/internal/rules/markdownflavor/rule_test.go +++ b/internal/rules/markdownflavor/rule_test.go @@ -37,7 +37,11 @@ func TestRuleDefaultSettings(t *testing.T) { } func TestRuleApplySettingsValid(t *testing.T) { - for _, name := range []string{"commonmark", "gfm", "goldmark"} { + valid := []string{ + "commonmark", "gfm", "goldmark", + "any", "pandoc", "phpextra", "multimarkdown", "myst", + } + for _, name := range valid { t.Run(name, func(t *testing.T) { r := &Rule{} err := r.ApplySettings(map[string]any{"flavor": name}) @@ -47,6 +51,48 @@ func TestRuleApplySettingsValid(t *testing.T) { } } +// TestRuleFlavorAnySilencesAllDiagnostics verifies that `flavor: any` +// never emits a diagnostic, regardless of what features the document +// uses. That matches the explicit "disable flavor reporting" contract +// promised in the doc. +func TestRuleFlavorAnySilencesAllDiagnostics(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "any"})) + + src := "# Head {#top}\n\n- [ ] task\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n" + + "~~old~~ https://example.com\n\nE = mc^2^ and H~2~O.\n\n" + + "$x+1$ inline and\n\n$$\nx\n$$\n\n" + + "*[API]: Application Programming Interface\n\nUse API here.\n" + diags := r.Check(mkFile(t, src)) + assert.Empty(t, diags, + "flavor: any must not flag any tracked feature") +} + +// TestRuleFlavorPHPExtra exercises the PHP Markdown Extra support +// set: footnotes and abbreviations are accepted, GFM features and +// math are not. +func TestRuleFlavorPHPExtra(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "phpextra"})) + + src := "*[API]: Application Programming Interface\n\n" + + "Use API here.[^1]\n\n[^1]: note\n\n~~strike~~ and $x+1$.\n" + diags := r.Check(mkFile(t, src)) + + byMsg := map[string]bool{} + for _, d := range diags { + byMsg[d.Message] = true + } + assert.False(t, byMsg["footnotes are not supported by phpextra"], + "phpextra accepts footnotes") + assert.False(t, byMsg["abbreviations are not supported by phpextra"], + "phpextra accepts abbreviations") + assert.True(t, byMsg["strikethrough is not supported by phpextra"], + "phpextra rejects strikethrough") + assert.True(t, byMsg["inline math is not supported by phpextra"], + "phpextra rejects inline math") +} + func TestRuleApplySettingsInvalid(t *testing.T) { tests := []struct { name string From 9d8d9c49a82da1dd00b142060269e558ac61693d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 15:44:43 +0000 Subject: [PATCH 25/30] fix: drop racy sync.Once reassignment in MDS033 helper Copilot caught a race condition: the test-only SilenceConfigWarningForTesting helper reassigned the package-level \`configWarned\` sync.Once before consuming it. If Rule.Check ran concurrently on another goroutine (tests run the engine in parallel for different files), the reassignment could race with configWarned.Do inside Check. The original intent was "mark the guard as already- fired so later checks do not emit the warning". A plain configWarned.Do(func(){}) achieves that without touching the Once variable: after the first call, Do is a no-op and never writes. --- internal/rules/directorystructure/rule.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/rules/directorystructure/rule.go b/internal/rules/directorystructure/rule.go index dac0647fb..33bf8d7a5 100644 --- a/internal/rules/directorystructure/rule.go +++ b/internal/rules/directorystructure/rule.go @@ -20,12 +20,14 @@ func init() { // clones the rule per file. var configWarned sync.Once -// SilenceConfigWarningForTesting consumes the package-level once-guard -// without emitting the config warning, so later checks will not fire -// it. Intended for tests that share a process and cannot tolerate a -// misconfigured-state leak from a previous rule's cleanup. +// SilenceConfigWarningForTesting consumes the package-level once- +// guard with a no-op, so later checks will not fire the "no allowed +// patterns" warning. Intended for tests that share a process and +// cannot tolerate a misconfigured-state leak from a previous rule's +// cleanup. Unlike resetting the sync.Once (which would race with a +// concurrent Rule.Check), Do is safe to call at any time: after the +// first call it is a no-op and never writes to the Once. func SilenceConfigWarningForTesting() { - configWarned = sync.Once{} configWarned.Do(func() {}) } From 37520763cee59dfe0d4cd406393a709bb4415613 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 15:54:19 +0000 Subject: [PATCH 26/30] fix: correct extension count in MDS034 Parser() doc Copilot noted the doc said "seven AST-detected core features" but only listed five built-in extensions plus the heading-ID attribute parser (six enablements). Rewrite to match: five built-in extensions plus the heading-ID attribute parser cover six features; the seventh AST-tracked feature, bare-URL autolinks, is detected on the main CommonMark parse by detectBareURLs, not here. --- internal/rules/markdownflavor/parser.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/rules/markdownflavor/parser.go b/internal/rules/markdownflavor/parser.go index 046f80ecb..603ec4394 100644 --- a/internal/rules/markdownflavor/parser.go +++ b/internal/rules/markdownflavor/parser.go @@ -17,11 +17,13 @@ var ( ) // Parser returns the shared goldmark parser used for dual parsing. -// It enables the built-in goldmark extensions for the seven -// AST-detected core features (table, strikethrough, task list, -// footnote, definition list) plus the heading-ID attribute parser, +// It enables five built-in goldmark extensions (table, strikethrough, +// task list, footnote, and definition list) plus the heading-ID +// attribute parser — together covering six AST-detected features — // and the five custom MDS034 extensions that cover superscript, -// subscript, math block, math inline, and abbreviations. +// subscript, math block, inline math, and abbreviations. The seventh +// feature that the rule tracks from the AST, bare-URL autolinks, is +// detected on the main CommonMark parse (see detectBareURLs). // // To keep MDS034 aligned with the rest of mdsmith, the dual parser // also registers lint.PIBlockParserPrioritized so a From 718690e859598198927984e9d97200c29f465761 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 20:24:04 +0000 Subject: [PATCH 27/30] Plan 86: raise MDS034 package coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift uncovered branches across the three packages touched by this PR: - internal/rules/markdownflavor/detect.go: drop the \`want\` parameter from nearestBlockAncestor. Every caller passed \`ast.NodeKind(0)\` so the kind-matching branch was dead; removing it simplifies the call sites and eliminates a 50%- covered helper. - internal/rules/markdownflavor/ext/stubs_test.go: new test that invokes the no-op interface methods (Close / CloseBlock / CanInterruptParagraph / CanAcceptIndentedLine) and Dump on every custom AST node. These methods exist only because goldmark's parser interfaces require them; the test exercises the bodies so coverage does not silently regress when a helper starts doing real work. - internal/rules/directorystructure/rule_test.go: add TestCategory and TestSilenceConfigWarningForTesting for the public methods that had no test yet. Coverage: markdownflavor 93.3% → 94.3%; ext 89.5% → 93.3%; directorystructure 90.2% → 93.4%. --- .../rules/directorystructure/rule_test.go | 23 ++++++++++ internal/rules/markdownflavor/detect.go | 17 +++----- .../rules/markdownflavor/ext/stubs_test.go | 43 +++++++++++++++++++ 3 files changed, 71 insertions(+), 12 deletions(-) create mode 100644 internal/rules/markdownflavor/ext/stubs_test.go diff --git a/internal/rules/directorystructure/rule_test.go b/internal/rules/directorystructure/rule_test.go index d8a6a72f9..3c2386499 100644 --- a/internal/rules/directorystructure/rule_test.go +++ b/internal/rules/directorystructure/rule_test.go @@ -157,6 +157,29 @@ func TestName(t *testing.T) { assert.Equal(t, "directory-structure", r.Name(), "expected directory-structure") } +func TestCategory(t *testing.T) { + r := &Rule{} + assert.Equal(t, "meta", r.Category(), "directory-structure belongs to the meta category") +} + +// TestSilenceConfigWarningForTesting covers the helper used by the +// integration runner to pre-consume the warn-once guard. Calling it +// twice must be safe and the subsequent Check must not emit the +// config warning even when the rule is in a configured-but-empty +// state. +func TestSilenceConfigWarningForTesting(t *testing.T) { + resetConfigWarned() + SilenceConfigWarningForTesting() + // Second call is a no-op. + SilenceConfigWarningForTesting() + + r := newRule(t, []string{}) + f, err := lint.NewFile("docs/a.md", []byte("# x\n")) + require.NoError(t, err) + assert.Empty(t, r.Check(f), + "after silencing the guard, the configured-empty warning must not fire") +} + func TestApplySettings(t *testing.T) { r := &Rule{} err := r.ApplySettings(map[string]any{ diff --git a/internal/rules/markdownflavor/detect.go b/internal/rules/markdownflavor/detect.go index ed77c03ad..23acfcaf8 100644 --- a/internal/rules/markdownflavor/detect.go +++ b/internal/rules/markdownflavor/detect.go @@ -252,7 +252,7 @@ func blockFinding(f *lint.File, n ast.Node, feat Feature) Finding { // inside the containing ListItem). TaskCheckBox has no source segment // of its own. func taskCheckBoxFinding(f *lint.File, n ast.Node) Finding { - if p := nearestBlockAncestor(n, ast.NodeKind(0)); p != nil { + if p := nearestBlockAncestor(n); p != nil { return findingFromBlock(f, p, FeatureTaskLists) } return Finding{Feature: FeatureTaskLists, Line: 1, Column: 1} @@ -263,23 +263,16 @@ func taskCheckBoxFinding(f *lint.File, n ast.Node) Finding { // first-line position instead of firstTextStart, which would return // zero for a childless inline. func inlineExtFinding(f *lint.File, n ast.Node, feat Feature) Finding { - if p := nearestBlockAncestor(n, ast.NodeKind(0)); p != nil { + if p := nearestBlockAncestor(n); p != nil { return findingFromBlock(f, p, feat) } return Finding{Feature: feat, Line: 1, Column: 1} } -// nearestBlockAncestor walks up from n and returns the first ancestor -// whose kind matches want; when want is 0 the first block-typed -// ancestor with Lines() is returned. -func nearestBlockAncestor(n ast.Node, want ast.NodeKind) ast.Node { +// nearestBlockAncestor walks up from n and returns the first block- +// typed ancestor with non-empty Lines(). +func nearestBlockAncestor(n ast.Node) ast.Node { for p := n.Parent(); p != nil; p = p.Parent() { - if want != 0 { - if p.Kind() == want { - return p - } - continue - } if p.Type() != ast.TypeBlock { continue } diff --git a/internal/rules/markdownflavor/ext/stubs_test.go b/internal/rules/markdownflavor/ext/stubs_test.go new file mode 100644 index 000000000..9c83f2661 --- /dev/null +++ b/internal/rules/markdownflavor/ext/stubs_test.go @@ -0,0 +1,43 @@ +package ext + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestExtensionStubsAreSafe exercises the no-op interface methods +// that goldmark requires (Close / CloseBlock / CanInterruptParagraph / +// CanAcceptIndentedLine / Dump). None do meaningful work; the tests +// just ensure they don't panic when invoked. +func TestExtensionStubsAreSafe(t *testing.T) { + assert.NotPanics(t, func() { + (&mathBlockParser{}).Close(nil, nil, nil) + _ = (&mathBlockParser{}).CanInterruptParagraph() + _ = (&mathBlockParser{}).CanAcceptIndentedLine() + }, "mathBlockParser stubs must not panic") + + assert.NotPanics(t, func() { + (&abbreviationBlockParser{}).Close(nil, nil, nil) + _ = (&abbreviationBlockParser{}).CanInterruptParagraph() + _ = (&abbreviationBlockParser{}).CanAcceptIndentedLine() + }, "abbreviationBlockParser stubs must not panic") + + assert.NotPanics(t, func() { + (&superscriptParser{}).CloseBlock(nil, nil) + (&subscriptParser{}).CloseBlock(nil, nil) + (&mathInlineParser{}).CloseBlock(nil, nil) + }, "inline parser CloseBlock stubs must not panic") +} + +// TestDumpDoesNotPanic calls Dump on each custom AST node type with a +// nil source buffer. Dump is a debug helper; we only care that it +// runs without crashing. +func TestDumpDoesNotPanic(t *testing.T) { + assert.NotPanics(t, func() { (&SuperscriptNode{}).Dump(nil, 0) }) + assert.NotPanics(t, func() { (&SubscriptNode{}).Dump(nil, 0) }) + assert.NotPanics(t, func() { (&MathBlockNode{}).Dump(nil, 0) }) + assert.NotPanics(t, func() { (&MathInlineNode{}).Dump(nil, 0) }) + assert.NotPanics(t, func() { (&AbbreviationDefinition{}).Dump(nil, 0) }) + assert.NotPanics(t, func() { (&AbbreviationReference{}).Dump(nil, 0) }) +} From e5af2801d4c27beb9450f1e18765892be9205c89 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 20:31:54 +0000 Subject: [PATCH 28/30] Plan 86: add edge-case tests to close codecov patch gap codecov/project fired with -0.33% on commit 718690e because several rejection / EOF branches inside the new MDS034 block parsers and detect helpers stayed uncovered. Add targeted edge-case tests: - ext/mathblock_edge_test.go covers Open rejections (empty line, four-space indent, non-fence \`$\` line) and Continue's EOF path with an unclosed block. - ext/abbreviation_edge_test.go covers every Open rejection (empty, indent, wrong prefix, missing closing bracket, missing colon, empty term), the \`*[TERM]:\` (empty expansion) success, the transformer's no-definitions and empty-table early-returns, the first-match-at-paragraph-start rewrite branch, and the multi-term gap-text insertion. - detect_edge_test.go covers lineCol / lineStartOf clamp paths, the firstTextStart \`-1\` sentinel, and findHeadingID's no-attribute short-circuit. Local Go coverage now: markdownflavor 94.7% (from 94.3%), ext 94.4% (from 93.3%). --- .../rules/markdownflavor/detect_edge_test.go | 63 +++++++++++ .../ext/abbreviation_edge_test.go | 105 ++++++++++++++++++ .../markdownflavor/ext/mathblock_edge_test.go | 45 ++++++++ 3 files changed, 213 insertions(+) create mode 100644 internal/rules/markdownflavor/detect_edge_test.go create mode 100644 internal/rules/markdownflavor/ext/abbreviation_edge_test.go create mode 100644 internal/rules/markdownflavor/ext/mathblock_edge_test.go diff --git a/internal/rules/markdownflavor/detect_edge_test.go b/internal/rules/markdownflavor/detect_edge_test.go new file mode 100644 index 000000000..be393691c --- /dev/null +++ b/internal/rules/markdownflavor/detect_edge_test.go @@ -0,0 +1,63 @@ +package markdownflavor + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestLineColClampsNegativeOffset exercises the guard that clamps a +// negative offset to 0 so callers that subtract past the start of +// f.Source still get a valid (1, 1) position. +func TestLineColClampsNegativeOffset(t *testing.T) { + line, col := lineCol([]byte("hello\nworld\n"), -5) + assert.Equal(t, 1, line) + assert.Equal(t, 1, col) +} + +// TestLineColClampsOversizedOffset exercises the guard that clamps +// an offset past len(source) back to len(source) so callers that +// look one byte past EOF still get a valid position. +func TestLineColClampsOversizedOffset(t *testing.T) { + src := []byte("hello\nworld\n") + line, col := lineCol(src, len(src)+10) + assert.Equal(t, 3, line) + assert.Equal(t, 1, col) +} + +// TestLineStartOfClampsOversizedOffset mirrors the same clamp for +// lineStartOf. An offset past EOF clamps to len(source); for a file +// ending in a newline that puts us one byte past the last newline, +// which is the start of the (empty) line after the document. +func TestLineStartOfClampsOversizedOffset(t *testing.T) { + src := []byte("hello\nworld\n") + assert.Equal(t, len(src), lineStartOf(src, len(src)+10)) +} + +// TestLineStartOfMidLine returns the first byte of the line +// containing the given offset. +func TestLineStartOfMidLine(t *testing.T) { + src := []byte("hello\nworld\n") + // Offset 8 sits inside "world" — line start is 6. + assert.Equal(t, 6, lineStartOf(src, 8)) +} + +// TestFirstTextStartReturnsNegativeForEmptySubtree covers the +// sentinel return path when no Text node can be found under n. +func TestFirstTextStartReturnsNegativeForEmptySubtree(t *testing.T) { + // An empty file has no children. + f := mkFile(t, "\n") + root := f.AST + // A *real* ast.Document has no Text descendants, so + // firstTextStart returns -1 for it. + assert.Equal(t, -1, firstTextStart(root)) +} + +// TestFindHeadingIDIgnoresHeadingWithoutAttribute confirms that a +// heading parsed without an `id` attribute short-circuits +// findHeadingID and produces no finding. +func TestFindHeadingIDIgnoresHeadingWithoutAttribute(t *testing.T) { + // "# Heading" alone: no attribute block, no finding. + fs := findings(t, "# Heading\n") + assert.False(t, hasFeature(fs, FeatureHeadingIDs)) +} diff --git a/internal/rules/markdownflavor/ext/abbreviation_edge_test.go b/internal/rules/markdownflavor/ext/abbreviation_edge_test.go new file mode 100644 index 000000000..cbea9dca4 --- /dev/null +++ b/internal/rules/markdownflavor/ext/abbreviation_edge_test.go @@ -0,0 +1,105 @@ +package ext + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" +) + +// TestAbbreviationOpenEdgeCases exercises every rejection path in +// the block parser's Open method that the happy-path tests don't +// already hit. +func TestAbbreviationOpenEdgeCases(t *testing.T) { + p := &abbreviationBlockParser{} + + cases := []struct { + name string + src string + }{ + {"empty", ""}, + {"four-space indent", " *[X]: y\n"}, + {"not a definition prefix", "paragraph text\n"}, + {"missing closing bracket", "*[HTML\n"}, + {"missing colon", "*[HTML] hyper\n"}, + {"empty term not accepted", "*[]: something\n"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := text.NewReader([]byte(tc.src)) + pc := parser.NewContext() + node, _ := p.Open(nil, r, pc) + assert.Nil(t, node, + "Open must reject %q (%s)", tc.src, tc.name) + }) + } +} + +// TestAbbreviationDefinitionWithEmptyExpansion verifies the parser +// accepts `*[TERM]:` (no expansion) and records an empty Expansion. +func TestAbbreviationDefinitionWithEmptyExpansion(t *testing.T) { + src := "*[HTML]:\n\nUse HTML here.\n" + doc := parseWith(t, src, Abbreviation) + def := walkFindKind(doc, KindAbbreviationDefinition) + if assert.NotNil(t, def, "accepts `*[TERM]:` with no expansion") { + n := def.(*AbbreviationDefinition) + assert.Equal(t, "HTML", string(n.Term)) + assert.Empty(t, n.Expansion) + } +} + +// TestAbbreviationTransformerNoTable skips the walk entirely when no +// definitions were found. +func TestAbbreviationTransformerNoTable(t *testing.T) { + // A paragraph with no *[term] definition: the transformer's + // `raw == nil` early return fires and nothing gets marked. + src := "Just a paragraph with HTML mentioned.\n" + doc := parseWith(t, src, Abbreviation) + assert.Nil(t, walkFindKind(doc, KindAbbreviationReference)) + assert.Nil(t, walkFindKind(doc, KindAbbreviationDefinition)) +} + +// TestAbbreviationReferenceAtParagraphStart exercises rewriteText's +// `first.start == 0` branch: when a definition match is the first +// byte of a paragraph, the original Text node is replaced with the +// reference rather than being shrunk to a prefix. +func TestAbbreviationReferenceAtParagraphStart(t *testing.T) { + src := "*[HTML]: Hyper Text Markup Language\n\nHTML is great.\n" + doc := parseWith(t, src, Abbreviation) + ref := walkFindKind(doc, KindAbbreviationReference) + if assert.NotNil(t, ref) { + assert.Equal(t, "HTML", string(ref.(*AbbreviationReference).Term)) + } + // The paragraph should also carry the trailing " is great." as + // a sibling text node; ensure the number of refs is 1 (no + // runaway duplication). + assert.Equal(t, 1, countKind(doc, KindAbbreviationReference)) +} + +// TestAbbreviationMultipleTermsInOneParagraph covers the +// appendRestAfter branch that emits a gap text between two +// consecutive matches. +func TestAbbreviationMultipleTermsInOneParagraph(t *testing.T) { + src := "*[HTML]: Hyper Text Markup Language\n*[CSS]: Cascading Style Sheets\n\n" + + "HTML and CSS together.\n" + doc := parseWith(t, src, Abbreviation) + assert.Equal(t, 2, countKind(doc, KindAbbreviationReference)) +} + +// TestAbbreviationTransformerEmptyTable covers the branch where the +// context key is set to a zero-length table (e.g. an upstream +// package interacting with the same key) — transformer must still +// early-return. +func TestAbbreviationTransformerEmptyTable(t *testing.T) { + transformer := &abbreviationTransformer{} + doc := parseWith(t, "# Heading\n\nParagraph.\n", Abbreviation) + pc := parser.NewContext() + pc.Set(abbrTableKey, abbrTable{}) + reader := text.NewReader([]byte("# Heading\n\nParagraph.\n")) + assert.NotPanics(t, func() { + transformer.Transform(doc.(*ast.Document), reader, pc) + }) +} diff --git a/internal/rules/markdownflavor/ext/mathblock_edge_test.go b/internal/rules/markdownflavor/ext/mathblock_edge_test.go new file mode 100644 index 000000000..b3eccc03d --- /dev/null +++ b/internal/rules/markdownflavor/ext/mathblock_edge_test.go @@ -0,0 +1,45 @@ +package ext + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" +) + +// TestMathBlockOpenRejectsShortLine covers the `line == nil` early- +// return path in Open, and the three-space indent limit. +func TestMathBlockOpenEdgeCases(t *testing.T) { + p := &mathBlockParser{} + pc := parser.NewContext() + + // An empty document: PeekLine returns nil → Open rejects. + empty := text.NewReader([]byte("")) + node, _ := p.Open(nil, empty, pc) + assert.Nil(t, node, "Open on empty input must not open a block") + + // Four-space indent: Open rejects even though the line starts + // with `$$` further in. + indented := text.NewReader([]byte(" $$\n x\n $$\n")) + node, _ = p.Open(nil, indented, pc) + assert.Nil(t, node, "Open must not open on four-space-indented line") + + // Line that shares the `$` trigger but isn't a fence. + notFence := text.NewReader([]byte("$price\n")) + node, _ = p.Open(nil, notFence, pc) + assert.Nil(t, node, "Open must reject non-fence `$` lines") +} + +// TestMathBlockContinueEOF covers the `line == nil` path in Continue: +// the reader reaches EOF while the block is still open. +func TestMathBlockContinueEOF(t *testing.T) { + src := "$$\na^2 + b^2\n" + doc := parseWith(t, src, MathBlock) + n := walkFindKind(doc, KindMathBlock) + if assert.NotNil(t, n) { + mb := n.(*MathBlockNode) + assert.False(t, mb.HasClosure(), + "unclosed block must carry closed=false") + } +} From 5619f7d15d9a585290add4e9f41171f60be83a9c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 20:37:14 +0000 Subject: [PATCH 29/30] Plan 86: cover defensive fallbacks in MDS034 detectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last-mile coverage bumps for the branches codecov still flags. Each of these paths is defensive — not reachable from a goldmark-produced AST — so we exercise them with orphan nodes built in the test. - taskCheckBoxFinding / inlineExtFinding return a (1, 1) fallback when the inline node has no block ancestor. - findingFromBlock returns the same fallback when the block has no Lines appended. - nodeByteRange clamps a negative firstTextStart result (FootnoteLink has no children and no segment) to 0. - findHeadingID now has tests for every rejection path: no Attributes(), Attributes() without an "id" key (e.g. \`{.highlight}\`), and the empty- source heading. --- .../rules/markdownflavor/detect_edge_test.go | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/internal/rules/markdownflavor/detect_edge_test.go b/internal/rules/markdownflavor/detect_edge_test.go index be393691c..8d9b83229 100644 --- a/internal/rules/markdownflavor/detect_edge_test.go +++ b/internal/rules/markdownflavor/detect_edge_test.go @@ -4,6 +4,11 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yuin/goldmark/ast" + extast "github.com/yuin/goldmark/extension/ast" + + "github.com/jeduden/mdsmith/internal/lint" ) // TestLineColClampsNegativeOffset exercises the guard that clamps a @@ -61,3 +66,62 @@ func TestFindHeadingIDIgnoresHeadingWithoutAttribute(t *testing.T) { fs := findings(t, "# Heading\n") assert.False(t, hasFeature(fs, FeatureHeadingIDs)) } + +// TestFindHeadingIDIgnoresAttributesWithoutID covers the second +// guard: the heading has an attribute block but no `id` key. +func TestFindHeadingIDIgnoresAttributesWithoutID(t *testing.T) { + // Goldmark's attribute parser accepts class-only attribute + // blocks like `{.highlight}`. Those set Attributes() != nil but + // no "id" key, so findHeadingID should return ok=false. + fs := findings(t, "# Heading {.highlight}\n") + assert.False(t, hasFeature(fs, FeatureHeadingIDs)) +} + +// TestTaskCheckBoxFindingOrphan exercises the defensive fallback in +// taskCheckBoxFinding when the node has no block ancestor — which +// only happens if the AST was hand-constructed rather than produced +// by goldmark. The fallback returns (1, 1). +func TestTaskCheckBoxFindingOrphan(t *testing.T) { + f, err := lint.NewFile("t.md", []byte("body\n")) + require.NoError(t, err) + orphan := extast.NewTaskCheckBox(true) + got := taskCheckBoxFinding(f, orphan) + assert.Equal(t, FeatureTaskLists, got.Feature) + assert.Equal(t, 1, got.Line) + assert.Equal(t, 1, got.Column) +} + +// TestInlineExtFindingOrphan is the same test for inlineExtFinding. +func TestInlineExtFindingOrphan(t *testing.T) { + f, err := lint.NewFile("t.md", []byte("body\n")) + require.NoError(t, err) + orphan := extast.NewFootnoteLink(7) + got := inlineExtFinding(f, orphan, FeatureFootnotes) + assert.Equal(t, FeatureFootnotes, got.Feature) + assert.Equal(t, 1, got.Line) + assert.Equal(t, 1, got.Column) +} + +// TestFindingFromBlockNoLines covers the `lines == nil || .Len()==0` +// short-circuit: a freshly-constructed block with no Lines appended +// falls back to (1, 1). +func TestFindingFromBlockNoLines(t *testing.T) { + f, err := lint.NewFile("t.md", []byte("body\n")) + require.NoError(t, err) + block := ast.NewParagraph() // no Lines appended + got := findingFromBlock(f, block, FeatureTables) + assert.Equal(t, FeatureTables, got.Feature) + assert.Equal(t, 1, got.Line) + assert.Equal(t, 1, got.Column) +} + +// TestNodeByteRangeClampsNegativeStart covers the clamp in +// nodeByteRange that floors a negative firstTextStart result to 0. +// A FootnoteLink has no children and no source segment, so +// firstTextStart returns -1 and nodeByteRange must floor that. +func TestNodeByteRangeClampsNegativeStart(t *testing.T) { + n := extast.NewFootnoteLink(7) + start, end := nodeByteRange(n) + assert.Equal(t, 0, start) + assert.Equal(t, 0, end) +} From 933ba1f9b26b598508b84ffba886619c365aad10 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 20:48:42 +0000 Subject: [PATCH 30/30] Plan 86: cover the last uncovered branches in MDS034 Reach 100% statement coverage in internal/rules/ markdownflavor and internal/rules/markdownflavor/ext. Each branch covered here was a defensive guard, early-exit, or unreachable fallback that natural input never hit. Covered branches: - features.go: String() fallback for an unknown Flavor; Name() fallback for an unknown Feature. - detect.go: - nearestBlockAncestor walks past a non-block inline ancestor (Paragraph > Emphasis > FootnoteLink). - findHeadingID rejects a Heading with an id attribute but no Lines appended. - findHeadingID rejects a Heading whose source line contains no '{'. - ext/abbreviation.go: - rewriteText on an empty-body Text node. - rewriteText on an orphan Text with no parent. - bestMatchAt rejects a term followed by a word byte (suffix match, e.g. API in APIserver). - ext/mathblock.go: - Continue returns parser.Close when the block was already closed in Open (same-line `$$x$$`). - Continue returns parser.Close on EOF. - ext/mathinline.go: - Parse skips a `$` candidate closer that is immediately followed by another `$` (`$$` fence marker), pairing with a later closer. --- .../rules/markdownflavor/detect_edge_test.go | 52 +++++++++++++++++++ .../ext/abbreviation_edge_test.go | 29 +++++++++++ .../markdownflavor/ext/mathblock_edge_test.go | 22 ++++++++ .../markdownflavor/ext/mathinline_test.go | 13 +++++ .../rules/markdownflavor/features_test.go | 10 ++++ 5 files changed, 126 insertions(+) diff --git a/internal/rules/markdownflavor/detect_edge_test.go b/internal/rules/markdownflavor/detect_edge_test.go index 8d9b83229..635130ceb 100644 --- a/internal/rules/markdownflavor/detect_edge_test.go +++ b/internal/rules/markdownflavor/detect_edge_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/yuin/goldmark/ast" extast "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/text" "github.com/jeduden/mdsmith/internal/lint" ) @@ -125,3 +126,54 @@ func TestNodeByteRangeClampsNegativeStart(t *testing.T) { assert.Equal(t, 0, start) assert.Equal(t, 0, end) } + +// TestNearestBlockAncestorSkipsNonBlockAncestors exercises the +// "parent is not a block" branch in nearestBlockAncestor: when we +// walk through an inline ancestor on the way up, the helper skips +// it and keeps climbing. +func TestNearestBlockAncestorSkipsNonBlockAncestors(t *testing.T) { + // Build: Paragraph (block, has Lines) → Emphasis (inline) → + // FootnoteLink (inline). Walking up from the FootnoteLink must + // skip Emphasis and return the Paragraph. + p := ast.NewParagraph() + // Append a line so findingFromBlock can resolve a position + // later (not needed here, but keeps the block well-formed). + p.Lines().Append(text.NewSegment(0, 1)) + em := ast.NewEmphasis(1) + link := extast.NewFootnoteLink(1) + p.AppendChild(p, em) + em.AppendChild(em, link) + + got := nearestBlockAncestor(link) + assert.Same(t, ast.Node(p), got) +} + +// TestFindHeadingIDHandlesMissingLines exercises the +// "lines == nil || lines.Len() == 0" rejection branch in +// findHeadingID. Normal parsing always fills in Lines on a +// Heading, so we synthesise a Heading with the id attribute set +// but no Lines appended. +func TestFindHeadingIDHandlesMissingLines(t *testing.T) { + f, err := lint.NewFile("t.md", []byte("# Heading {#top}\n")) + require.NoError(t, err) + h := ast.NewHeading(1) + h.SetAttributeString("id", []byte("top")) + _, ok := findHeadingID(f, h) + assert.False(t, ok, + "findHeadingID must return ok=false when Lines is empty") +} + +// TestFindHeadingIDHandlesNoOpeningBrace covers the "brace < 0" +// branch: a Heading whose id attribute was somehow set but whose +// source line contains no `{`. The parser ordinarily does not +// produce such a node; we construct one directly. +func TestFindHeadingIDHandlesNoOpeningBrace(t *testing.T) { + f, err := lint.NewFile("t.md", []byte("# plain heading\n")) + require.NoError(t, err) + h := ast.NewHeading(1) + h.SetAttributeString("id", []byte("top")) + h.Lines().Append(text.NewSegment(2, 15)) + _, ok := findHeadingID(f, h) + assert.False(t, ok, + "findHeadingID must return ok=false when source line contains no '{'") +} diff --git a/internal/rules/markdownflavor/ext/abbreviation_edge_test.go b/internal/rules/markdownflavor/ext/abbreviation_edge_test.go index cbea9dca4..bac81b272 100644 --- a/internal/rules/markdownflavor/ext/abbreviation_edge_test.go +++ b/internal/rules/markdownflavor/ext/abbreviation_edge_test.go @@ -9,6 +9,35 @@ import ( "github.com/yuin/goldmark/text" ) +// TestRewriteTextEmptyBody covers the early-return when the Text +// node's segment is empty. +func TestRewriteTextEmptyBody(t *testing.T) { + tbl := abbrTable{"HTML": []byte("Hyper Text Markup Language")} + src := []byte("") + tn := ast.NewTextSegment(text.NewSegment(0, 0)) + assert.NotPanics(t, func() { rewriteText(tn, tbl, src) }) +} + +// TestRewriteTextOrphanParent covers the early-return when the Text +// has no parent (the rewrite pass has nowhere to insert siblings). +func TestRewriteTextOrphanParent(t *testing.T) { + src := []byte("HTML") + tbl := abbrTable{"HTML": []byte("Hyper Text Markup Language")} + tn := ast.NewTextSegment(text.NewSegment(0, len(src))) + assert.NotPanics(t, func() { rewriteText(tn, tbl, src) }) +} + +// TestBestMatchAtWordBoundaryRejectsSuffix exercises the +// "endIdx < len(body) && isWordByte(...)" rejection in bestMatchAt: +// a defined term that is a prefix of a longer word must not match. +func TestBestMatchAtWordBoundaryRejectsSuffix(t *testing.T) { + tbl := abbrTable{"API": []byte("Application Programming Interface")} + body := []byte("APIserver does things") + _, ok := bestMatchAt(body, 0, tbl) + assert.False(t, ok, + "API followed by word byte 's' must not match") +} + // TestAbbreviationOpenEdgeCases exercises every rejection path in // the block parser's Open method that the happy-path tests don't // already hit. diff --git a/internal/rules/markdownflavor/ext/mathblock_edge_test.go b/internal/rules/markdownflavor/ext/mathblock_edge_test.go index b3eccc03d..302f566dd 100644 --- a/internal/rules/markdownflavor/ext/mathblock_edge_test.go +++ b/internal/rules/markdownflavor/ext/mathblock_edge_test.go @@ -43,3 +43,25 @@ func TestMathBlockContinueEOF(t *testing.T) { "unclosed block must carry closed=false") } } + +// TestMathBlockContinueAlreadyClosed exercises the `mb.closed` early- +// return in Continue. A same-line `$$…$$` block is closed during +// Open; goldmark still calls Continue once, and it must return +// parser.Close without consuming another line. +func TestMathBlockContinueAlreadyClosed(t *testing.T) { + mb := &MathBlockNode{closed: true} + r := text.NewReader([]byte("some other line\n")) + got := (&mathBlockParser{}).Continue(mb, r, parser.NewContext()) + assert.Equal(t, parser.Close, got, + "Continue on an already-closed block must return parser.Close") +} + +// TestMathBlockContinueEOFDirect covers the `line == nil` branch in +// Continue by feeding the parser an empty reader. +func TestMathBlockContinueEOFDirect(t *testing.T) { + mb := &MathBlockNode{} + r := text.NewReader([]byte("")) + got := (&mathBlockParser{}).Continue(mb, r, parser.NewContext()) + assert.Equal(t, parser.Close, got, + "Continue on EOF must return parser.Close") +} diff --git a/internal/rules/markdownflavor/ext/mathinline_test.go b/internal/rules/markdownflavor/ext/mathinline_test.go index 0842037ac..909ba2106 100644 --- a/internal/rules/markdownflavor/ext/mathinline_test.go +++ b/internal/rules/markdownflavor/ext/mathinline_test.go @@ -54,3 +54,16 @@ func TestMathInlineDoesNotMatchDoubleDollar(t *testing.T) { doc := parseWith(t, "before $$ not inline $$ after\n", MathInline) assert.Nil(t, walkFindKind(doc, KindMathInline)) } + +// TestMathInlineSkipsDoubleDollarAsCloser exercises the Parse branch +// that rejects a candidate closing `$` when it is immediately +// followed by another `$` (i.e. a `$$` fence). The parser must look +// past the first `$$` and pair the opening `$` with a later, valid +// closing `$`. +func TestMathInlineSkipsDoubleDollarAsCloser(t *testing.T) { + // The first candidate closer `$` at index after `x` is followed + // by another `$`, so it is skipped; the next `$` after `y` + // closes the span. + doc := parseWith(t, "see $x$$y$ here\n", MathInline) + assert.NotNil(t, walkFindKind(doc, KindMathInline)) +} diff --git a/internal/rules/markdownflavor/features_test.go b/internal/rules/markdownflavor/features_test.go index 0c0e47cf9..daad7abf3 100644 --- a/internal/rules/markdownflavor/features_test.go +++ b/internal/rules/markdownflavor/features_test.go @@ -63,6 +63,16 @@ func assertSupports(t *testing.T, f Flavor, supported ...Feature) { } } +func TestFlavorStringUnknownIsEmpty(t *testing.T) { + var zero Flavor + assert.Equal(t, "", zero.String()) + assert.Equal(t, "", Flavor(999).String()) +} + +func TestFeatureNameUnknownIsEmpty(t *testing.T) { + assert.Equal(t, "", Feature(999).Name()) +} + func TestFeatureSupportCommonMark(t *testing.T) { assertSupports(t, FlavorCommonMark) }