Skip to content

Commit 17b5e55

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 fef3ac3 commit 17b5e55

17 files changed

Lines changed: 642 additions & 70 deletions

File tree

PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,6 @@ 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
| 89 | 🔲 | [TOC generator directive and MDS035 auto-fix](plan/89_toc-generator-directive.md) |
5050
<?/catalog?>

cmd/mdsmith/main.go

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

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
@@ -42,6 +42,7 @@ import (
4242
_ "github.com/jeduden/mdsmith/internal/rules/singletrailingnewline"
4343
_ "github.com/jeduden/mdsmith/internal/rules/tableformat"
4444
_ "github.com/jeduden/mdsmith/internal/rules/tablereadability"
45+
_ "github.com/jeduden/mdsmith/internal/rules/tocdirective"
4546
_ "github.com/jeduden/mdsmith/internal/rules/tokenbudget"
4647
)
4748

internal/engine/categories_test.go

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

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"
@@ -47,6 +48,7 @@ import (
4748
_ "github.com/jeduden/mdsmith/internal/rules/singletrailingnewline"
4849
_ "github.com/jeduden/mdsmith/internal/rules/tableformat"
4950
_ "github.com/jeduden/mdsmith/internal/rules/tablereadability"
51+
_ "github.com/jeduden/mdsmith/internal/rules/tocdirective"
5052
_ "github.com/jeduden/mdsmith/internal/rules/tokenbudget"
5153
_ "github.com/jeduden/mdsmith/internal/rules/unclosedcodeblock"
5254

@@ -101,8 +103,11 @@ func parseFixtureFrontMatter(
101103
return fm.Settings, fm.Diagnostics, content
102104
}
103105

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

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

127137
if err := cr.ApplySettings(settings); err != nil {
128138
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)