Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ footer: |
| 84 | 🔲 | [Symlink default-deny for file discovery](plan/84_symlink-default-deny.md) |
| 85 | 🔳 | [Increase test coverage to 95% by extracting shared rule helpers](plan/85_coverage-to-95-percent.md) |
| 86 | 🔳 | [Markdown flavor validation](plan/86_markdown-flavor-validation.md) |
| 89 | 🔲 | [TOC generator directive and MDS035 auto-fix](plan/89_toc-generator-directive.md) |
| 89 | | [TOC generator directive and MDS035 auto-fix](plan/89_toc-generator-directive.md) |
| 90 | ✅ | [Isolate corpus test git config from host signing](plan/90_corpus-test-git-config-isolation.md) |
| 91 | 🔲 | [MDS037 skips paragraphs inside generated sections](plan/91_mds037-skip-generated-sections.md) |
<?/catalog?>
1 change: 1 addition & 0 deletions cmd/mdsmith/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import (
_ "github.com/jeduden/mdsmith/internal/rules/singletrailingnewline"
_ "github.com/jeduden/mdsmith/internal/rules/tableformat"
_ "github.com/jeduden/mdsmith/internal/rules/tablereadability"
_ "github.com/jeduden/mdsmith/internal/rules/toc"
_ "github.com/jeduden/mdsmith/internal/rules/tocdirective"
_ "github.com/jeduden/mdsmith/internal/rules/tokenbudget"
_ "github.com/jeduden/mdsmith/internal/rules/unclosedcodeblock"
Expand Down
6 changes: 4 additions & 2 deletions docs/background/archetypes/generated-section/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ HTML processing instruction markers. A linting rule checks that
the content between the markers matches what the directive would
produce, and a fix command regenerates it in place.

This archetype documents the shared mechanics. Individual rules
(e.g., [MDS019 catalog](../../../../internal/rules/MDS019-catalog/)) define
This archetype documents the shared mechanics. Individual rules —
[MDS019 catalog](../../../../internal/rules/MDS019-catalog/),
[MDS021 include](../../../../internal/rules/MDS021-include/),
and [MDS038 toc](../../../../internal/rules/MDS038-toc/) — define
their own parameters, template fields, and behaviors.

## Marker Syntax
Expand Down
26 changes: 16 additions & 10 deletions docs/background/markdown-linters.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Key differentiators:
- Token-budget rule ([MDS028][mds028]) for LLM context
windows
- Paragraph readability (ARI grade) and structure limits
- Regenerable sections: catalog, include,
- Regenerable sections: catalog, include, toc,
required-structure
- Git merge driver for auto-resolving generated sections
- Metrics subsystem (bytes, lines, words, headings,
Expand Down Expand Up @@ -226,14 +226,14 @@ lack determinism.

### Formatting and Fixing

| Capability | mdsmith | Prettier | markdownlint |
|--------------------|------------------|--------------------------|--------------|
| Autofix CLI | `fix` | `--write` | `--fix` |
| Table alignment | [MDS025][mds025] | yes | no |
| Prose wrapping | no | [`proseWrap`][prosewrap] | no |
| Embedded code fmt | no | JS/TS/CSS/JSON | no |
| Multi-pass fix | yes | single pass | single pass |
| Generated sections | catalog, include | no | no |
| Capability | mdsmith | Prettier | markdownlint |
|--------------------|-----------------------|--------------------------|--------------|
| Autofix CLI | `fix` | `--write` | `--fix` |
| Table alignment | [MDS025][mds025] | yes | no |
| Prose wrapping | no | [`proseWrap`][prosewrap] | no |
| Embedded code fmt | no | JS/TS/CSS/JSON | no |
| Multi-pass fix | yes | single pass | single pass |
| Generated sections | catalog, include, toc | no | no |

Prose wrapping controls whether a tool reflows paragraph
line breaks. Prettier's [`proseWrap`][prosewrap] option
Expand All @@ -243,7 +243,7 @@ as-is, the default). Neither mdsmith nor markdownlint
reflow prose; they only diagnose long lines.

Prettier is the strongest pure formatter. mdsmith has
unique autofix for generated content (catalog, include).
unique autofix for generated content (catalog, include, toc).
markdownlint fixes structural violations.

### Cross-File and Project Features
Expand Down Expand Up @@ -281,6 +281,11 @@ when a matching link reference definition
makes it a legitimate link. No other linter
in this comparison detects these tokens.

`mdsmith fix` replaces each token with a
`<?toc?>...<?/toc?>` block ([MDS038][mds038]).
A second fix pass populates the block with a
nested heading list.

### Runtime and Integration

| Property | mdsmith | markdownlint | remark-lint | Prettier | Vale | textlint | LLM |
Expand Down Expand Up @@ -475,6 +480,7 @@ relaxed rules) for presentation files.
[mds029]: ../../internal/rules/MDS029-conciseness-scoring/README.md
[mds030]: ../../internal/rules/MDS030-empty-section-body/README.md
[mds035]: ../../internal/rules/MDS035-toc-directive/README.md
[mds038]: ../../internal/rules/MDS038-toc/README.md
<!-- markdownlint links -->
[markdownlint]: https://github.com/DavidAnson/markdownlint
[markdownlint-cli2]: https://github.com/DavidAnson/markdownlint-cli2
Expand Down
48 changes: 47 additions & 1 deletion internal/archetype/gensection/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,9 @@ func TestEngine_Check_InvalidYAML(t *testing.T) {
}

func TestEngine_Check_NonStringValues(t *testing.T) {
src := "<?mock\nkey: 42\n?>\n<?/mock?>\n"
// Integers and floats are now silently coerced to strings; booleans
// and maps are still rejected as non-string values.
src := "<?mock\nkey: true\n?>\n<?/mock?>\n"
f := newTestFile(t, "test.md", src)
d := &mockDirective{}
e := NewEngine(d)
Expand All @@ -248,6 +250,50 @@ func TestEngine_Check_NonStringValues(t *testing.T) {
"expected 'non-string value' message, got %q", diags[0].Message)
}

func TestEngine_Check_IntegerValueCoerced(t *testing.T) {
// Integer YAML params (e.g. min-level: 2) are coerced to string "2".
src := "<?mock\nkey: 42\n?>\n<?/mock?>\n"
f := newTestFile(t, "test.md", src)
d := &mockDirective{}
e := NewEngine(d)
diags := e.Check(f)
assert.Empty(t, diags, "integer param should be coerced, not rejected")
}

func TestEngine_Check_FloatIntValueCoerced(t *testing.T) {
// Float64 YAML params that represent integers (e.g. 2.0) are coerced to "2".
src := "<?mock\nkey: 2.0\n?>\n<?/mock?>\n"
f := newTestFile(t, "test.md", src)
d := &mockDirective{validateFn: func(
_ string, _ int, params map[string]string, _ map[string]ColumnConfig,
) []lint.Diagnostic {
if params["key"] != "2" {
return []lint.Diagnostic{{Message: "expected \"2\", got " + params["key"]}}
}
return nil
}}
e := NewEngine(d)
diags := e.Check(f)
assert.Empty(t, diags, "float-int param should be coerced to \"2\", not rejected")
}

func TestEngine_Check_FloatFractionalValueCoerced(t *testing.T) {
// Float64 YAML params with fractional parts (e.g. 2.5) are coerced to "2.5".
src := "<?mock\nkey: 2.5\n?>\n<?/mock?>\n"
f := newTestFile(t, "test.md", src)
d := &mockDirective{validateFn: func(
_ string, _ int, params map[string]string, _ map[string]ColumnConfig,
) []lint.Diagnostic {
if params["key"] != "2.5" {
return []lint.Diagnostic{{Message: "expected \"2.5\", got " + params["key"]}}
}
return nil
}}
e := NewEngine(d)
diags := e.Check(f)
assert.Empty(t, diags, "fractional float param should be coerced to \"2.5\"")
}

func TestEngine_Check_ValidationDiags(t *testing.T) {
src := "<?mock\nkey: value\n?>\n<?/mock?>\n"
f := newTestFile(t, "test.md", src)
Expand Down
13 changes: 13 additions & 0 deletions internal/archetype/gensection/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package gensection

import (
"fmt"
"strconv"
"strings"

"github.com/jeduden/mdsmith/internal/fieldinterp"
Expand Down Expand Up @@ -253,6 +254,9 @@ func ExtractColumnsRaw(rawMap map[string]any) map[string]any {
// ValidateStringParams checks that all values in rawMap are strings.
// YAML sequences of strings are joined with "\n" into a single string,
// allowing rules to accept list-valued parameters (e.g., multi-glob).
// YAML integer and float scalars are converted to their decimal string
// representation so rules with numeric parameters (e.g. min-level: 2)
// do not require quoting in the directive body.
func ValidateStringParams(
filePath string, line int, rawMap map[string]any, ruleID, ruleName string,
) (map[string]string, []lint.Diagnostic) {
Expand All @@ -262,6 +266,15 @@ func ValidateStringParams(
switch val := v.(type) {
case string:
params[k] = val
case int:
params[k] = strconv.Itoa(val)
case float64:
// Preserve integer representation when the float has no fractional part.
if val == float64(int64(val)) {
params[k] = strconv.FormatInt(int64(val), 10)
} else {
params[k] = strconv.FormatFloat(val, 'f', -1, 64)
}
case []any:
strs, err := toStringSlice(val)
if err != nil {
Expand Down
49 changes: 49 additions & 0 deletions internal/integration/multipass_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package integration

import (
"os"
"path/filepath"
"testing"

"github.com/jeduden/mdsmith/internal/config"
"github.com/jeduden/mdsmith/internal/fix"
"github.com/jeduden/mdsmith/internal/rule"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestMultiPassFix_TOCDirective verifies that a single fix run converts
// [TOC] into a populated <?toc?>...<?/toc?> block. MDS035 replaces [TOC]
// with the empty directive in pass 1; MDS038 fills the heading list in pass 2.
func TestMultiPassFix_TOCDirective(t *testing.T) {
src := "# Document\n\n[TOC]\n\n## Section One\n\n## Section Two\n"

dir := t.TempDir()
mdFile := filepath.Join(dir, "doc.md")
require.NoError(t, os.WriteFile(mdFile, []byte(src), 0o644))

cfg := &config.Config{
Rules: map[string]config.RuleCfg{
"toc-directive": {Enabled: true},
"toc": {Enabled: true},
},
}

fixer := &fix.Fixer{
Config: cfg,
Rules: rule.All(),
}

result := fixer.Fix([]string{mdFile})
require.Empty(t, result.Errors, "unexpected errors: %v", result.Errors)

got, err := os.ReadFile(mdFile)
require.NoError(t, err)

content := string(got)
assert.Contains(t, content, "<?toc?>", "toc directive start marker present")
assert.Contains(t, content, "<?/toc?>", "toc directive end marker present")
assert.Contains(t, content, "- [Section One](#section-one)", "heading link present")
assert.Contains(t, content, "- [Section Two](#section-two)", "heading link present")
assert.NotContains(t, content, "\n[TOC]\n", "original TOC token replaced")
}
1 change: 1 addition & 0 deletions internal/integration/rules_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import (
_ "github.com/jeduden/mdsmith/internal/rules/singletrailingnewline"
_ "github.com/jeduden/mdsmith/internal/rules/tableformat"
_ "github.com/jeduden/mdsmith/internal/rules/tablereadability"
_ "github.com/jeduden/mdsmith/internal/rules/toc"
_ "github.com/jeduden/mdsmith/internal/rules/tocdirective"
_ "github.com/jeduden/mdsmith/internal/rules/tokenbudget"
_ "github.com/jeduden/mdsmith/internal/rules/unclosedcodeblock"
Expand Down
77 changes: 77 additions & 0 deletions internal/mdtext/mdtext.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package mdtext

import (
"fmt"
"strings"
"sync"
"unicode"
Expand All @@ -11,6 +12,82 @@ import (
sentlib "github.com/neurosnap/sentences"
)

// Slugify converts heading text to a GitHub-compatible URL anchor slug.
// Lowercase, letters/digits preserved, spaces and hyphens become a single dash.
func Slugify(s string) string {
s = strings.TrimSpace(strings.ToLower(s))
if s == "" {
return ""
}
var b strings.Builder
prevDash := false
for _, r := range s {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r):
b.WriteRune(r)
prevDash = false
case unicode.IsSpace(r) || r == '-' || r == '_':
if b.Len() > 0 && !prevDash {
b.WriteByte('-')
prevDash = true
}
}
}
return strings.Trim(b.String(), "-")
}

// TOCItem represents a single heading entry for table-of-contents generation.
type TOCItem struct {
Level int
Text string
Anchor string
}

// CollectTOCItems returns all headings from the AST as TOC items, in document
// order. Anchors are disambiguated by insertion order: first occurrence keeps
// the plain slug, subsequent duplicates get -1, -2, … suffixes — matching the
// anchor computation in crossfilereferenceintegrity. Tracks used anchors (not
// just base slugs) to guarantee unique anchors even when a later heading's
// base slug matches an earlier heading's disambiguated anchor.
func CollectTOCItems(root ast.Node, source []byte) []TOCItem {
var items []TOCItem
usedAnchors := make(map[string]bool)
slugCounts := make(map[string]int)
_ = ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
h, ok := n.(*ast.Heading)
if !ok {
return ast.WalkContinue, nil
}
text := ExtractPlainText(h, source)
slug := Slugify(text)
if slug == "" {
return ast.WalkContinue, nil
}

// Find a unique anchor by incrementing suffix until unused.
anchor := slug
if usedAnchors[anchor] {
count := slugCounts[slug]
for {
count++
anchor = fmt.Sprintf("%s-%d", slug, count)
if !usedAnchors[anchor] {
break
}
}
slugCounts[slug] = count
}

usedAnchors[anchor] = true
items = append(items, TOCItem{Level: h.Level, Text: text, Anchor: anchor})
return ast.WalkContinue, nil
})
return items
}

// ExtractPlainText extracts readable text from a goldmark AST node,
// stripping markdown syntax. Keeps: text content, link display text,
// emphasis inner text, image alt text, code span text.
Expand Down
Loading
Loading