Skip to content

Commit e5703b0

Browse files
committed
Add document-length custom markdownlint rule (max 1500 lines)
- Rule reports a single error on line 1 when the file exceeds a configurable maximum (default 1500). Config: document-length.maximum in .markdownlint.yml. - Register in .markdownlint-cli2.jsonc and .vscode/settings.json; document in markdownlint-rules README and root README. - Generate negative_document_length.md in verify_markdownlint_fixtures when missing (ensure_long_document_fixture); add Python test and expected_errors entry with comment on generation.
1 parent 805845d commit e5703b0

8 files changed

Lines changed: 176 additions & 0 deletions

File tree

‎.markdownlint.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ ascii-only:
4343
- "📊"
4444
- "⚠️"
4545

46+
# document-length: disallow documents longer than maximum lines (default 1500)
47+
# - maximum: positive integer; default 1500
48+
document-length:
49+
maximum: 1500
50+
4651
# heading-title-case: AP-style headline capitalization; words in backticks ignored
4752
# heading-title-case:
4853
# lowercaseWords: ["through", ...] # optional; extends default list (add words)

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ Lint and docs-as-code tooling: custom [markdownlint](https://github.com/DavidAns
3131
- [no-heading-like-lines.js](markdownlint-rules/no-heading-like-lines.js) - no heading-like lines.
3232
- Report lines that look like headings but are not (e.g. `**Text:**`, `1. **Text**`); prompt use of real `#` headings.
3333
- Use when: ensuring real Markdown headings instead of bold/italic that look like headings.
34+
- [document-length.js](markdownlint-rules/document-length.js) - maximum document length.
35+
- Disallow documents longer than a configured number of lines (default 1500); reports on line 1 when over the limit.
36+
- Use when: keeping individual docs under a line cap to encourage splitting.
3437
- [utils.js](markdownlint-rules/utils.js) - shared utilities
3538
- Heading/content helpers and path/glob matching; used by other rules.
3639
Do not register as a rule in markdownlint.

‎markdownlint-rules/README.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,23 @@ Order of entries matters: the first pattern that matches the anchor id is used.
131131

132132
**Behavior:** Reports lines that look like headings but are not (e.g. `**Text:**`, `**Text**:`, `1. **Text**`, and italic variants). Prompts use of real `#` headings instead.
133133

134+
### `document-length`
135+
136+
**File:** `document-length.js`
137+
138+
**Description:** Disallow documents longer than a configured number of lines.
139+
140+
**Configuration:** In `.markdownlint.yml` (or `.markdownlint.json`) under `document-length`:
141+
142+
```yaml
143+
document-length:
144+
maximum: 1500 # optional; default 1500
145+
```
146+
147+
- **`maximum`** (number, default `1500`): Maximum allowed line count. Must be a positive integer.
148+
149+
**Behavior:** When the file has more than `maximum` lines, the rule reports a single error on line 1. The message includes the actual line count and the maximum and suggests splitting into smaller files.
150+
134151
### `ascii-only`
135152

136153
**File:** `ascii-only.js`
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"use strict";
2+
3+
/**
4+
* markdownlint rule: disallow documents longer than a configured number of lines.
5+
* Reports a single error on line 1 when the file exceeds the maximum.
6+
*
7+
* @param {object} params - markdownlint params (lines, name, config)
8+
* @param {function(object): void} onError - Callback to report an error
9+
*/
10+
function ruleFunction(params, onError) {
11+
const lines = params.lines;
12+
const raw = params.config?.maximum;
13+
const maximum =
14+
typeof raw === "number" && Number.isInteger(raw) && raw >= 1 ? raw : 1500;
15+
16+
if (lines.length <= maximum) {
17+
return;
18+
}
19+
20+
onError({
21+
lineNumber: 1,
22+
detail: `Document has ${lines.length} lines (maximum ${maximum}). Consider splitting into smaller files.`,
23+
context: lines[0] ?? "",
24+
});
25+
}
26+
27+
module.exports = {
28+
names: ["document-length"],
29+
description: "Disallow documents longer than a configured number of lines",
30+
tags: ["length"],
31+
function: ruleFunction,
32+
};

‎md_test_files/expected_errors.yml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,16 @@ negative_duplicate_headings_normalized.md:
9292
rule: no-duplicate-headings-normalized
9393
message_contains: Duplicate heading title "overview"
9494

95+
# negative_document_length.md is generated automatically (not committed).
96+
# See test-scripts/verify_markdownlint_fixtures.py: ensure_long_document_fixture() and
97+
# main() which creates it when missing; test_verify_markdownlint_fixtures.py generates
98+
# it in test_verify_document_length_generated_fixture then removes it.
99+
negative_document_length.md:
100+
errors:
101+
- line: 1
102+
rule: document-length
103+
message_contains: "maximum"
104+
95105
negative_heading_like.md:
96106
errors:
97107
- line: 9

‎test-scripts/test_verify_markdownlint_fixtures.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,24 @@ def test_verify_file_positive_integration(self):
294294
except (OSError, FileNotFoundError) as e:
295295
self.skipTest(f"markdownlint not runnable: {e}")
296296

297+
def test_verify_document_length_generated_fixture(self):
298+
"""Generate a 1501-line fixture, verify document-length error, then remove file."""
299+
path = _REPO_ROOT / "md_test_files" / "negative_document_length.md"
300+
expect_path = _REPO_ROOT / "md_test_files" / "expected_errors.yml"
301+
if not expect_path.exists():
302+
self.skipTest("expected_errors.yml not found")
303+
v.ensure_long_document_fixture(path)
304+
try:
305+
self.assertIn("negative_document_length.md", v.load_expected_errors(expect_path))
306+
cmd = v.find_markdownlint_cmd()
307+
expectations = v.load_expected_errors(expect_path)
308+
try:
309+
v.verify_file(cmd, path, expectations)
310+
except (OSError, FileNotFoundError) as e:
311+
self.skipTest(f"markdownlint not runnable: {e}")
312+
finally:
313+
path.unlink(missing_ok=True)
314+
297315
def test_verify_file_raises_when_exit_code_mismatch(self):
298316
"""Exit code mismatch (expect 0 errors, got non-zero) raises AssertionError."""
299317
path = _REPO_ROOT / "md_test_files" / "positive.md"

‎test-scripts/verify_markdownlint_fixtures.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727

2828
import yaml
2929

30+
LONG_FIXTURE_LINES = 1501
31+
3032

3133
@dataclass(frozen=True)
3234
class ExpectedError:
@@ -60,11 +62,21 @@ def find_markdownlint_cmd() -> List[str]:
6062
return ["npx", "markdownlint-cli2"]
6163

6264

65+
def ensure_long_document_fixture(path: Path) -> None:
66+
"""Write a markdown file with LONG_FIXTURE_LINES lines so document-length rule fails."""
67+
# Blank after heading to satisfy MD022; trailing newline to satisfy MD047
68+
lines = ["# Title", ""] + [f"line {i}" for i in range(3, LONG_FIXTURE_LINES + 1)]
69+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
70+
71+
6372
def list_fixture_files() -> List[Path]:
6473
"""Return fixture paths: positive.md plus sorted negative_*.md in md_test_files."""
6574
md_dir = repo_root() / "md_test_files"
6675
positive = md_dir / "positive.md"
6776
negatives = sorted(md_dir.glob("negative_*.md"))
77+
long_fixture = md_dir / "negative_document_length.md"
78+
if long_fixture not in negatives:
79+
negatives = sorted(negatives + [long_fixture])
6880
return [positive, *negatives]
6981

7082

@@ -311,6 +323,8 @@ def main() -> int:
311323
failures: List[str] = []
312324

313325
for f in files:
326+
if f.name == "negative_document_length.md" and not f.exists():
327+
ensure_long_document_fixture(f)
314328
if verbose:
315329
exp_total = 0
316330
if f.name in expectations_by_file:
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"use strict";
2+
3+
/**
4+
* Unit tests for document-length: disallow documents longer than a configured
5+
* maximum number of lines; reports one error on line 1 when over the limit.
6+
*/
7+
8+
const { describe, it } = require("node:test");
9+
const assert = require("node:assert");
10+
const rule = require("../../markdownlint-rules/document-length.js");
11+
const { runRule } = require("./run-rule.js");
12+
13+
function makeLines(n) {
14+
return Array.from({ length: n }, (_, i) => (i === 0 ? "# Title" : `line ${i + 1}`));
15+
}
16+
17+
describe("document-length", () => {
18+
it("reports no errors when lines.length <= maximum (default 1500)", () => {
19+
const lines = makeLines(1500);
20+
const errors = runRule(rule, lines);
21+
assert.strictEqual(errors.length, 0);
22+
});
23+
24+
it("reports one error when lines.length > maximum (1501 lines)", () => {
25+
const lines = makeLines(1501);
26+
const errors = runRule(rule, lines);
27+
assert.strictEqual(errors.length, 1);
28+
assert.strictEqual(errors[0].lineNumber, 1);
29+
assert.ok(errors[0].detail.includes("1501") && errors[0].detail.includes("1500"));
30+
assert.ok(errors[0].detail.includes("maximum") || errors[0].detail.includes("Consider splitting"));
31+
});
32+
33+
it("respects custom maximum: 11 lines with max 10 reports one error", () => {
34+
const lines = makeLines(11);
35+
const errors = runRule(rule, lines, { maximum: 10 });
36+
assert.strictEqual(errors.length, 1);
37+
assert.strictEqual(errors[0].lineNumber, 1);
38+
assert.ok(errors[0].detail.includes("11") && errors[0].detail.includes("10"));
39+
});
40+
41+
it("respects custom maximum: 10 lines with max 10 reports no error", () => {
42+
const lines = makeLines(10);
43+
const errors = runRule(rule, lines, { maximum: 10 });
44+
assert.strictEqual(errors.length, 0);
45+
});
46+
47+
it("reports no error for 0 lines", () => {
48+
const errors = runRule(rule, []);
49+
assert.strictEqual(errors.length, 0);
50+
});
51+
52+
it("reports no error for 1 line when max is 1", () => {
53+
const errors = runRule(rule, ["only line"], { maximum: 1 });
54+
assert.strictEqual(errors.length, 0);
55+
});
56+
57+
it("reports one error for 2 lines when max is 1", () => {
58+
const errors = runRule(rule, ["first", "second"], { maximum: 1 });
59+
assert.strictEqual(errors.length, 1);
60+
assert.strictEqual(errors[0].lineNumber, 1);
61+
});
62+
63+
it("uses default maximum when config.maximum is not a positive integer", () => {
64+
const lines = makeLines(1501);
65+
const errors = runRule(rule, lines, { maximum: "1500" });
66+
assert.strictEqual(errors.length, 1);
67+
assert.ok(errors[0].detail.includes("1500"));
68+
});
69+
70+
it("reports error with empty context when first line is undefined (over limit)", () => {
71+
const lines = Array(2);
72+
lines[1] = "second";
73+
const errors = runRule(rule, lines, { maximum: 1 });
74+
assert.strictEqual(errors.length, 1);
75+
assert.strictEqual(errors[0].context, "");
76+
});
77+
});

0 commit comments

Comments
 (0)