Skip to content

Commit 6138b7b

Browse files
author
merge-queue-bot
committed
Merge PR #758: feat(rules): add MDS060 occurrence rule (plan 2607022118)
2 parents 777a68f + 8f5e5c4 commit 6138b7b

14 files changed

Lines changed: 1124 additions & 0 deletions

File tree

docs/research/markdownlint-coverage/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,7 @@ row-expr: |
723723
| [MDS056](../../../internal/rules/MDS056-forbidden-text/README.md) forbidden-text |||||||
724724
| [MDS057](../../../internal/rules/MDS057-required-text-patterns/README.md) required-text-patterns |||||||
725725
| [MDS058](../../../internal/rules/MDS058-required-mentions/README.md) required-mentions |||||||
726+
| [MDS060](../../../internal/rules/MDS060-occurrence/README.md) occurrence |||||||
726727
| [MDS063](../../../internal/rules/MDS063-descriptive-link-text/README.md) descriptive-link-text | MD059 ✅ descriptive-link-text | MD059 ✅ link-text |||||
727728
<?/catalog?>
728729

internal/integration/perrule_bench_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,7 @@ var perRuleAllocCeiling = map[string]float64{
309309
"MDS056": 4, // forbidden-text: 0 allocs
310310
"MDS057": 4, // required-text-patterns: 0 allocs
311311
"MDS058": 4, // required-mentions: 0 allocs
312+
"MDS060": 4, // occurrence: 0 allocs (inert without tokens or pattern)
312313
"MDS063": 44, // descriptive-link-text: ~36 allocs
313314
"MDS067": 12, // callout-type: ~8 allocs
314315
"MDS068": 4, // link-style: 0 allocs

internal/integration/testdata/rule_walk_audit.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,17 @@
648648
"uses_ast_walk": true,
649649
"reads_file_ast": true
650650
},
651+
{
652+
"id": "MDS060",
653+
"name": "occurrence",
654+
"category": "hybrid",
655+
"nil_ast_safe": false,
656+
"code_block_sensitive": false,
657+
"fired": true,
658+
"is_node_checker": false,
659+
"uses_ast_walk": false,
660+
"reads_file_ast": true
661+
},
651662
{
652663
"id": "MDS061",
653664
"name": "list-marker-space",

internal/rulelayer/rule_walk_audit.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,17 @@
648648
"uses_ast_walk": true,
649649
"reads_file_ast": true
650650
},
651+
{
652+
"id": "MDS060",
653+
"name": "occurrence",
654+
"category": "hybrid",
655+
"nil_ast_safe": false,
656+
"code_block_sensitive": false,
657+
"fired": true,
658+
"is_node_checker": false,
659+
"uses_ast_walk": false,
660+
"reads_file_ast": true
661+
},
651662
{
652663
"id": "MDS061",
653664
"name": "list-marker-space",
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
---
2+
id: MDS060
3+
name: occurrence
4+
status: ready
5+
description: >-
6+
A scope must contain each configured token or pattern between `min` and
7+
`max` times (inclusive). Counts prose only; fenced and indented code
8+
blocks are excluded.
9+
category: prose
10+
nature: content
11+
maintainability: null
12+
markdownlint: []
13+
rumdl: []
14+
mado: []
15+
panache: []
16+
obsidian-linter: []
17+
gomarklint: []
18+
---
19+
# MDS060: occurrence
20+
21+
A scope must contain each configured token or pattern between `min` and
22+
`max` times (inclusive). Counts prose only; fenced and indented code
23+
blocks are excluded.
24+
25+
The rule walks every scope unit (file, heading-bounded section, or
26+
paragraph) and counts non-overlapping occurrences of each token or regex
27+
pattern match. Fenced and indented code blocks are never counted.
28+
A diagnostic is emitted at the scope unit's first line when the count
29+
falls below `min` or exceeds `max`.
30+
31+
`tokens` and `pattern` are mutually exclusive. Use `lists:` to pull
32+
token sets from wordlist files without repeating them inline.
33+
34+
## Settings
35+
36+
| Setting | Type | Default | Description |
37+
| ---------------- | --------------- | ----------- | ------------------------------------------------------------------- |
38+
| `scope` | string | `paragraph` | `file`, `section`, or `paragraph` |
39+
| `tokens` | list of strings | `[]` | Literal substrings to count. Populated by `lists:`. Mutually |
40+
| | | | exclusive with `pattern`. |
41+
| `pattern` | string | `""` | Go RE2 regex to match. Mutually exclusive with `tokens`. |
42+
| `min` | integer | `0` | Minimum occurrences per scope unit (must be >= 0) |
43+
| `max` | integer | `-1` | Maximum occurrences per scope unit; `-1` is unbounded |
44+
| `count` | string | `each` | `each` checks each token independently; `combined` sums all matches |
45+
| `case-sensitive` | bool | `false` | When false, token matching ignores case |
46+
47+
## Config
48+
49+
Enable with em-dash density preset (at most two per paragraph):
50+
51+
```yaml
52+
rules:
53+
occurrence:
54+
enabled: true
55+
pattern: ""
56+
scope: paragraph
57+
count: combined
58+
max: 2
59+
```
60+
61+
Enable with term-density preset (buzzword list, at most twice per section):
62+
63+
```yaml
64+
rules:
65+
occurrence:
66+
enabled: true
67+
scope: section
68+
count: each
69+
max: 2
70+
lists:
71+
- buzzwords
72+
```
73+
74+
Disable:
75+
76+
```yaml
77+
rules:
78+
occurrence: false
79+
```
80+
81+
## Examples
82+
83+
### Token exceeds max
84+
85+
<?include
86+
file: bad/section-scope.md
87+
wrap: markdown
88+
?>
89+
90+
```markdown
91+
# Title
92+
93+
jargon jargon jargon here.
94+
```
95+
96+
<?/include?>
97+
98+
### Pattern under max
99+
100+
<?include
101+
file: good/default.md
102+
wrap: markdown
103+
?>
104+
105+
```markdown
106+
# Title
107+
108+
First — second — end.
109+
```
110+
111+
<?/include?>
112+
113+
### Fenced code blocks excluded
114+
115+
<?include
116+
file: good/fenced-code-excluded.md
117+
wrap: markdown
118+
?>
119+
120+
````markdown
121+
# Title
122+
123+
keyword keyword.
124+
125+
```text
126+
keyword keyword keyword
127+
```
128+
````
129+
130+
<?/include?>
131+
132+
## Meta-Information
133+
134+
- **ID**: MDS060
135+
- **Name**: `occurrence`
136+
- **Status**: ready
137+
- **Default**: disabled
138+
- **Fixable**: no
139+
- **Implementation**: [source](./)
140+
- **Category**: prose
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
settings:
3+
pattern: ""
4+
scope: paragraph
5+
count: combined
6+
max: 2
7+
diagnostics:
8+
- line: 3
9+
column: 1
10+
message: '"—" appears 3 time(s) in paragraph (max 2)'
11+
---
12+
# Title
13+
14+
First — second — third — end.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
settings:
3+
tokens:
4+
- keyword
5+
scope: paragraph
6+
count: each
7+
min: 2
8+
max: -1
9+
diagnostics:
10+
- line: 3
11+
column: 1
12+
message: '"keyword" appears 1 time(s) in paragraph (min 2)'
13+
---
14+
# Title
15+
16+
Only keyword once.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
settings:
3+
tokens:
4+
- jargon
5+
scope: section
6+
count: each
7+
max: 2
8+
diagnostics:
9+
- line: 1
10+
column: 1
11+
message: '"jargon" appears 3 time(s) in section (max 2)'
12+
---
13+
# Title
14+
15+
jargon jargon jargon here.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
settings:
3+
pattern: ""
4+
scope: paragraph
5+
count: combined
6+
max: 2
7+
---
8+
# Title
9+
10+
First — second — end.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
settings:
3+
tokens:
4+
- keyword
5+
scope: section
6+
count: each
7+
max: 2
8+
---
9+
# Title
10+
11+
keyword keyword.
12+
13+
```text
14+
keyword keyword keyword
15+
```

0 commit comments

Comments
 (0)