Skip to content

Commit 70b76ad

Browse files
committed
Implement plan 88: MDS035 toc-directive rule
Add a new opt-in rule that flags renderer-specific table-of-contents directives which render as literal text on CommonMark and goldmark: - `[TOC]` (Python-Markdown / MultiMarkdown / Pandoc) - `[[_TOC_]]` (GitLab, Azure DevOps) - `[[toc]]` (markdown-it, VitePress) - `${toc}` (VitePress configs) Detection is paragraph-scoped line matching, so fenced/indented code blocks and inline code spans are naturally excluded. For the `[TOC]` variant only, a matching link reference definition suppresses the diagnostic, because the token then resolves to a legitimate link. Side fix: integration test cleanup now snapshots the rule's value via reflect and restores it on cleanup, rather than calling `ApplySettings(DefaultSettings())`. The previous approach left directory-structure's `configured` flag set across tests, which only surfaced as a warning now that MDS035 is the first fixture to run after MDS033 and triggered the `sync.Once` warning. https://claude.ai/code/session_$(uuidgen 2>/dev/null | tr -d - | head -c 22 || echo 01T569bC9ogHHf5A6Cgxn9NW)
1 parent f48ec68 commit 70b76ad

17 files changed

Lines changed: 641 additions & 69 deletions

File tree

PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,5 @@ footer: |
4545
| 85 | 🔲 | [Increase test coverage to 95% by extracting shared rule helpers](plan/85_coverage-to-95-percent.md) |
4646
| 86 | 🔲 | [Markdown flavor validation](plan/86_markdown-flavor-validation.md) |
4747
| 87 | 🔲 | [Flavor validation for GitHub Alerts](plan/87_markdown-flavor-github-alerts.md) |
48-
| 88 | 🔲 | [TOC directive migration aid](plan/88_toc-directive-migration.md) |
48+
| 88 | | [TOC directive migration aid](plan/88_toc-directive-migration.md) |
4949
<?/catalog?>

cmd/mdsmith/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import (
5454
_ "github.com/jeduden/mdsmith/internal/rules/singletrailingnewline"
5555
_ "github.com/jeduden/mdsmith/internal/rules/tableformat"
5656
_ "github.com/jeduden/mdsmith/internal/rules/tablereadability"
57+
_ "github.com/jeduden/mdsmith/internal/rules/tocdirective"
5758
_ "github.com/jeduden/mdsmith/internal/rules/tokenbudget"
5859
_ "github.com/jeduden/mdsmith/internal/rules/unclosedcodeblock"
5960
)

docs/background/markdown-linters.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,24 @@ mdsmith has the strongest cross-file and project-level
263263
features. The merge driver and regenerable sections are
264264
unique to mdsmith.
265265

266+
### Renderer Portability
267+
268+
Several Markdown renderers expand non-standard
269+
tokens into tables of contents. Common
270+
variants are `[TOC]` (Python-Markdown),
271+
`[[_TOC_]]` (GitLab, Azure DevOps), `[[toc]]`
272+
(markdown-it, VitePress), and `${toc}` (some
273+
VitePress configs). CommonMark and goldmark —
274+
the engine mdsmith uses — expand none of
275+
them. They render as literal text.
276+
277+
[MDS035][mds035] (toc-directive, opt-in) flags
278+
each of the four tokens on its own line. For
279+
`[TOC]`, the rule suppresses the diagnostic
280+
when a matching link reference definition
281+
makes it a legitimate link. No other linter
282+
in this comparison detects these tokens.
283+
266284
### Runtime and Integration
267285

268286
| Property | mdsmith | markdownlint | remark-lint | Prettier | Vale | textlint | LLM |
@@ -456,6 +474,7 @@ relaxed rules) for presentation files.
456474
[mds028]: ../../internal/rules/MDS028-token-budget/README.md
457475
[mds029]: ../../internal/rules/MDS029-conciseness-scoring/README.md
458476
[mds030]: ../../internal/rules/MDS030-empty-section-body/README.md
477+
[mds035]: ../../internal/rules/MDS035-toc-directive/README.md
459478
<!-- markdownlint links -->
460479
[markdownlint]: https://github.com/DavidAnson/markdownlint
461480
[markdownlint-cli2]: https://github.com/DavidAnson/markdownlint-cli2

internal/config/config_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import (
4141
_ "github.com/jeduden/mdsmith/internal/rules/singletrailingnewline"
4242
_ "github.com/jeduden/mdsmith/internal/rules/tableformat"
4343
_ "github.com/jeduden/mdsmith/internal/rules/tablereadability"
44+
_ "github.com/jeduden/mdsmith/internal/rules/tocdirective"
4445
_ "github.com/jeduden/mdsmith/internal/rules/tokenbudget"
4546
)
4647

internal/engine/categories_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import (
3434
_ "github.com/jeduden/mdsmith/internal/rules/notrailingpunctuation"
3535
_ "github.com/jeduden/mdsmith/internal/rules/notrailingspaces"
3636
_ "github.com/jeduden/mdsmith/internal/rules/singletrailingnewline"
37+
_ "github.com/jeduden/mdsmith/internal/rules/tocdirective"
3738
_ "github.com/jeduden/mdsmith/internal/rules/tokenbudget"
3839
)
3940

internal/integration/rules_test.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"os"
66
"path/filepath"
7+
"reflect"
78
"regexp"
89
"strings"
910
"testing"
@@ -46,6 +47,7 @@ import (
4647
_ "github.com/jeduden/mdsmith/internal/rules/singletrailingnewline"
4748
_ "github.com/jeduden/mdsmith/internal/rules/tableformat"
4849
_ "github.com/jeduden/mdsmith/internal/rules/tablereadability"
50+
_ "github.com/jeduden/mdsmith/internal/rules/tocdirective"
4951
_ "github.com/jeduden/mdsmith/internal/rules/tokenbudget"
5052
_ "github.com/jeduden/mdsmith/internal/rules/unclosedcodeblock"
5153

@@ -100,8 +102,11 @@ func parseFixtureFrontMatter(
100102
return fm.Settings, fm.Diagnostics, content
101103
}
102104

103-
// applySettingsToRule applies fixture settings to a rule. It saves and restores
104-
// the default settings after the test to avoid polluting the global singleton.
105+
// applySettingsToRule applies fixture settings to a rule. It snapshots the
106+
// rule's value before the change and restores it on test cleanup, so that
107+
// rules whose internal state cannot be recreated from DefaultSettings
108+
// alone (e.g. directory-structure's `configured` flag) do not leak state
109+
// into later tests.
105110
func applySettingsToRule(
106111
t *testing.T, r rule.Rule, settings map[string]any,
107112
) {
@@ -118,10 +123,15 @@ func applySettingsToRule(
118123
)
119124
}
120125

121-
defaults := cr.DefaultSettings()
122-
t.Cleanup(func() {
123-
_ = cr.ApplySettings(defaults)
124-
})
126+
// Snapshot via reflect so cleanup fully restores the pre-test state.
127+
rv := reflect.ValueOf(r)
128+
if rv.Kind() == reflect.Ptr && !rv.IsNil() {
129+
snapshot := reflect.New(rv.Elem().Type()).Elem()
130+
snapshot.Set(rv.Elem())
131+
t.Cleanup(func() {
132+
rv.Elem().Set(snapshot)
133+
})
134+
}
125135

126136
if err := cr.ApplySettings(settings); err != nil {
127137
t.Fatalf("applying settings: %v", err)
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
---
2+
id: MDS035
3+
name: toc-directive
4+
status: ready
5+
description: Flag renderer-specific TOC directives that render as literal text on CommonMark and goldmark.
6+
---
7+
# MDS035: toc-directive
8+
9+
Flag renderer-specific TOC directives that
10+
render as literal text on CommonMark and
11+
goldmark.
12+
13+
- **ID**: MDS035
14+
- **Name**: `toc-directive`
15+
- **Status**: ready
16+
- **Default**: disabled (opt-in)
17+
- **Fixable**: no
18+
- **Implementation**:
19+
[source](./)
20+
- **Category**: meta
21+
22+
## What it detects
23+
24+
Four directive variants appear in the wild,
25+
each expanded by a different renderer:
26+
27+
| Token | Expanded by |
28+
|-------------|----------------------------------------|
29+
| `[TOC]` | Python-Markdown, MultiMarkdown, Pandoc |
30+
| `[[_TOC_]]` | GitLab Flavored Markdown, Azure DevOps |
31+
| `[[toc]]` | markdown-it-toc-done-right, VitePress |
32+
| `${toc}` | some VitePress configurations |
33+
34+
CommonMark and goldmark do not expand any of
35+
them; authors see the directive token in the
36+
rendered output instead of a table of
37+
contents. The rule flags each token when it
38+
appears on its own line inside a paragraph so
39+
authors can replace it, delete it, or maintain
40+
the list manually.
41+
42+
`[TOC]` alone is also valid CommonMark
43+
shortcut reference link syntax. When the
44+
document contains a matching
45+
`[TOC]: <url>` definition, the rule
46+
suppresses the diagnostic because the token
47+
resolves to a real link rather than rendering
48+
as literal text.
49+
50+
## Why not auto-fix
51+
52+
The right replacement depends on intent. For
53+
file-index usage — an index page listing
54+
sibling documents — mdsmith's
55+
[`<?catalog?>`](../MDS019-catalog/README.md)
56+
directive is the replacement. For in-document
57+
heading TOCs, mdsmith has no equivalent; the
58+
author must drop the directive or maintain a
59+
manual list. The rule is detection-only and
60+
names both cases in its message.
61+
62+
## Config
63+
64+
Enable:
65+
66+
```yaml
67+
rules:
68+
toc-directive: true
69+
```
70+
71+
Disable (default):
72+
73+
```yaml
74+
rules:
75+
toc-directive: false
76+
```
77+
78+
## Examples
79+
80+
### Good
81+
82+
<?include
83+
file: good/default.md
84+
wrap: markdown
85+
?>
86+
87+
````markdown
88+
# Document with no TOC directives
89+
90+
This document has no renderer-specific TOC
91+
markers, so MDS035 stays silent.
92+
93+
Normal prose is unaffected, and inline code
94+
like `[TOC]` or `${toc}` is not flagged because
95+
the tokens are inside code spans.
96+
97+
```text
98+
[TOC]
99+
[[_TOC_]]
100+
[[toc]]
101+
${toc}
102+
```
103+
104+
Even the fenced block above is a code block,
105+
not a paragraph, so nothing is reported.
106+
````
107+
108+
<?/include?>
109+
110+
### Bad
111+
112+
<?include
113+
file: bad/bracketed.md
114+
wrap: markdown
115+
?>
116+
117+
```markdown
118+
# Python-Markdown TOC directive
119+
120+
[TOC]
121+
122+
Everything below the directive renders fine,
123+
but the directive itself appears as literal
124+
text on CommonMark and goldmark renderers.
125+
```
126+
127+
<?/include?>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
diagnostics:
3+
- line: 3
4+
column: 1
5+
message: "unsupported TOC directive `[TOC]`; mdsmith has no heading TOC equivalent; use `<?catalog?>` for file indexes (MDS019)"
6+
---
7+
# Python-Markdown TOC directive
8+
9+
[TOC]
10+
11+
Everything below the directive renders fine,
12+
but the directive itself appears as literal
13+
text on CommonMark and goldmark renderers.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
diagnostics:
3+
- line: 3
4+
column: 1
5+
message: "unsupported TOC directive `[[_TOC_]]`; mdsmith has no heading TOC equivalent; use `<?catalog?>` for file indexes (MDS019)"
6+
---
7+
# GitLab-flavored TOC directive
8+
9+
[[_TOC_]]
10+
11+
GitLab Flavored Markdown and Azure DevOps
12+
expand this into a TOC; CommonMark and
13+
goldmark render it as plain text.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
diagnostics:
3+
- line: 3
4+
column: 1
5+
message: "unsupported TOC directive `[[toc]]`; mdsmith has no heading TOC equivalent; use `<?catalog?>` for file indexes (MDS019)"
6+
---
7+
# markdown-it / VitePress TOC directive
8+
9+
[[toc]]
10+
11+
markdown-it-toc-done-right and VitePress
12+
replace this with a generated heading TOC;
13+
CommonMark leaves it as literal text.

0 commit comments

Comments
 (0)