Skip to content

Commit 3ae89a4

Browse files
committed
qa: expand rule tests, fix rules and docs
- Add unit tests for fixInfo and edge cases across custom rules. - fenced-code-under-heading: require H2–H6 in range above blocks; no-heading-like-lines: avoid matching **:**; one-sentence-per-line fixes. - README and markdownlint-rules README: fixture wording, Node.js/utils.js formatting; expected_errors and test-scripts updates.
1 parent 88b1223 commit 3ae89a4

20 files changed

Lines changed: 528 additions & 95 deletions

README.md

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,7 @@ Lint and docs-as-code tooling: custom [markdownlint](https://github.com/DavidAns
7373
- Use when: copying the rule files; required by several of the rules above.
7474

7575
- **JS linting** for the rule code: ESLint (recommended + complexity/max-lines + eslint-plugin-security), aligned with the GitHub Actions workflow.
76-
- **Markdownlint fixture tests**: [md_test_files/](md_test_files/README.md) includes positive_*.
77-
md and negative_*.
78-
md fixtures with explicit expected errors, verified by `test-scripts/verify_markdownlint_fixtures.py`.
76+
- **Markdownlint fixture tests**: [md_test_files/](md_test_files/README.md) includes `positive_*.md` and `negative_*.md` fixtures with explicit expected errors, verified by `test-scripts/verify_markdownlint_fixtures.py`.
7977
- **Rule unit tests**: Node `node:test` unit tests for each custom rule in `test/markdownlint-rules/` (including security tests for defensive regex handling and ReDoS awareness); run with `make test-rules` or `npm run test:rules`.
8078
CI runs `make test-rules-coverage` (fails if any rule file is below 90% line/statement coverage).
8179
- **Python unit tests**: `unittest` tests for [test-scripts/](test-scripts/README.md) in `test-scripts/test_*.py`; run with `make test-python`.
@@ -86,8 +84,7 @@ See **[markdownlint-rules/README.md](markdownlint-rules/README.md)** for rule do
8684

8785
## Requirements
8886

89-
- Node.
90-
js and npm (for JS linting)
87+
- `Node.js` and npm (for JS linting)
9188
- Python 3 (for repo test scripts and `make lint-python`)
9289
- [markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2) and config (`.markdownlint-cli2.jsonc`, `.markdownlint.yml`) when using the custom rules in another repo
9390

markdownlint-rules/README.md

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,8 @@ In this repo they are registered in [.markdownlint-cli2.jsonc](../.markdownlint-
2525
You can reuse any of them in your own project; see [Reusing These Rules](#reusing-these-rules) below.
2626
Some rules are **fixable** (heading-title-case, ascii-only, heading-numbering, no-heading-like-lines, one-sentence-per-line): they report `fixInfo` so `markdownlint-cli2 --fix` and the editor "Fix all" can apply corrections automatically.
2727

28-
- **Requirements:** Node.
29-
js and [markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2) (or the [markdownlint](https://github.com/DavidAnson/markdownlint) core with custom rule support).
30-
When reusing rules, copy any helper files they depend on; see [Shared Helper](#shared-helper) for which rules require **utils.
31-
js**.
28+
- **Requirements:** `Node.js` and [markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2) (or the [markdownlint](https://github.com/DavidAnson/markdownlint) core with custom rule support).
29+
When reusing rules, copy any helper files they depend on; see [Shared Helper](#shared-helper) for which rules require `utils.js`.
3230
- **Rule modules**: Each `*.js` file here (except `utils.js`) is a custom rule.
3331
- **Config**: Rule-specific options are set under the rule name in a markdownlint config file.
3432
You can use `.markdownlint.yml` or `.markdownlint.json` (markdownlint accepts either).
@@ -42,8 +40,7 @@ To use one or more of these rules in another repo:
4240
1. Create a `.markdownlint-rules` directory in that repo (if it does not exist).
4341
2. Copy the rule file(s) you want (e.g. `ascii-only.js`, `no-heading-like-lines.js`) into `.markdownlint-rules`.
4442
3. If a rule depends on helpers, copy those too.
45-
Most rules require **utils.
46-
js** (see [Shared Helper](#shared-helper) for the full list).
43+
Most rules require `utils.js` (see [Shared Helper](#shared-helper) for the full list).
4744
Copy `utils.js` into `.markdownlint-rules` and do **not** list it in `customRules` (see below).
4845
**no-heading-like-lines** optionally uses `heading-title-case.js` and `heading-numbering.js` when `convertToHeading: true` (for AP title case and number prefixes); copy those into the same directory only if you want that behavior.
4946
4. In the repo root, in `.markdownlint-cli2.jsonc` (or your config file), add the rule name(s) to the `customRules` array and set `customRulePaths` so it points at your `.markdownlint-rules` folder (see [markdownlint-cli2 custom rules](https://github.com/DavidAnson/markdownlint-cli2#custom-rules)).
@@ -298,7 +295,8 @@ document-length:
298295
Must be a positive integer.
299296
- **`excludePathPatterns`** (list of strings, default none): Glob patterns for file paths where this rule is skipped.
300297

301-
**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.
298+
**Behavior:** When the file has more than `maximum` lines, the rule reports a single error on line 1.
299+
The message includes the actual line count and the maximum and suggests splitting into smaller files.
302300

303301
### `ascii-only`
304302

@@ -634,18 +632,15 @@ one-sentence-per-line:
634632

635633
## Shared Helper
636634

637-
**utils.
638-
js** is not a rule; it provides utilities used by all custom rules in this repo.
635+
`utils.js` is not a rule; it provides utilities used by all custom rules in this repo.
639636
Do not list it in `customRules` in `.markdownlint-cli2.jsonc`.
640637
When reusing any rule, copy `utils.js` into your `.markdownlint-rules` (see [Reusing These Rules](#reusing-these-rules)).
641638

642-
**All custom rules in this repo depend on utils.
643-
js** (for `pathMatchesAny` and/or other helpers): allow-custom-anchors, ascii-only, document-length, fenced-code-under-heading, heading-min-words, heading-numbering, heading-title-case, no-duplicate-headings-normalized, no-empty-heading, no-heading-like-lines, no-h1-content, one-sentence-per-line.
639+
**All custom rules in this repo depend on `utils.js`** (for `pathMatchesAny` and/or other helpers): allow-custom-anchors, ascii-only, document-length, fenced-code-under-heading, heading-min-words, heading-numbering, heading-title-case, no-duplicate-headings-normalized, no-empty-heading, no-heading-like-lines, no-h1-content, one-sentence-per-line.
644640

645641
**All custom rules accept `excludePathPatterns`** (optional list of glob patterns).
646642
When the file path matches any pattern, the rule is skipped for that file.
647-
This uses `pathMatchesAny` from utils.
648-
js.
643+
This uses `pathMatchesAny` from `utils.js`.
649644

650645
- **Heading and content:** `extractHeadings`, `iterateNonFencedLines`, `iterateProseLines`, `stripInlineCode`, `parseHeadingNumberPrefix`, `normalizeHeadingTitleForDup`, `normalizedTitleForDuplicate`, `RE_ATX_HEADING`, `RE_NUMBERING_PREFIX`.
651646
- **Path/glob matching:** `globToRegExp`, `matchGlob`, `pathMatchesAny` - used for `excludePathPatterns` and other path options (e.g. ascii-only `allowedPathPatternsUnicode`).

markdownlint-rules/fenced-code-under-heading.js

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,15 @@ function processAtxLine(trimmed, lineNumber, opts, headings) {
7272
}
7373

7474
/**
75-
* Single pass: find all H2–H6 in range and all opening fence lines for configured languages.
75+
* Single pass: find all headings in range, all ATX headings (any level), and opening fence lines.
7676
*
7777
* @param {string[]} lines
7878
* @param {{ minHeadingLevel: number, maxHeadingLevel: number, languages: string[] }} opts
79-
* @returns {{ headings: { lineNumber: number, level: number }[], blocks: { lineNumber: number, language: string }[] }}
79+
* @returns {{ headings: { lineNumber: number, level: number }[], allHeadings: { lineNumber: number, level: number }[], blocks: { lineNumber: number, language: string }[] }}
8080
*/
8181
function findHeadingsAndBlocks(lines, opts) {
8282
const headings = [];
83+
const allHeadings = [];
8384
const blocks = [];
8485
const state = { inFence: false, fenceMarker: null, fenceLen: 0 };
8586

@@ -92,10 +93,12 @@ function findHeadingsAndBlocks(lines, opts) {
9293
}
9394
if (!state.inFence) {
9495
processAtxLine(trimmed, lineNumber, opts, headings);
96+
const m = trimmed.match(RE_ATX);
97+
if (m) allHeadings.push({ lineNumber, level: m[1].length });
9598
}
9699
}
97100

98-
return { headings, blocks };
101+
return { headings, allHeadings, blocks };
99102
}
100103

101104
/**
@@ -136,7 +139,7 @@ function findAllBlocks(lines) {
136139
}
137140

138141
/**
139-
* For each block, get the last heading (H2–H6 in range) that appears before the block.
142+
* For each block, get the last heading (in range) that appears before the block.
140143
*
141144
* @param {{ lineNumber: number, level: number }[]} headings - Sorted by lineNumber
142145
* @param {number} blockLine
@@ -151,6 +154,22 @@ function precedingHeading(headings, blockLine) {
151154
return last;
152155
}
153156

157+
/**
158+
* Get the immediately preceding heading (any level) before blockLine.
159+
*
160+
* @param {{ lineNumber: number, level: number }[]} allHeadings - Sorted by lineNumber
161+
* @param {number} blockLine
162+
* @returns {{ lineNumber: number, level: number }|null}
163+
*/
164+
function precedingHeadingAnyLevel(allHeadings, blockLine) {
165+
let last = null;
166+
for (const h of allHeadings) {
167+
if (h.lineNumber >= blockLine) break;
168+
last = h;
169+
}
170+
return last;
171+
}
172+
154173
/* c8 ignore start -- path filter branches covered by tests; per-file threshold met by other code */
155174
function shouldSkipFile(filePath, opts) {
156175
const includePaths = Array.isArray(opts.includePaths) ? opts.includePaths : [];
@@ -162,10 +181,12 @@ function shouldSkipFile(filePath, opts) {
162181
/* c8 ignore stop */
163182

164183
function reportBlocksWithoutHeading(ctx) {
165-
const { blocks, headings, opts, lines, onError } = ctx;
184+
const { blocks, allHeadings, opts, lines, onError } = ctx;
166185
for (const block of blocks) {
167-
const headingLine = precedingHeading(headings, block.lineNumber);
168-
if (headingLine != null || !opts.requireHeading) continue;
186+
const immediate = precedingHeadingAnyLevel(allHeadings, block.lineNumber);
187+
if (!opts.requireHeading) continue;
188+
const validLevel = immediate != null && immediate.level >= opts.minHeadingLevel && immediate.level <= opts.maxHeadingLevel;
189+
if (validLevel) continue;
169190
onError({
170191
lineNumber: block.lineNumber,
171192
detail: `Fenced code block (${block.language}) must have an H${opts.minHeadingLevel}-H${opts.maxHeadingLevel} heading above it.`,
@@ -242,8 +263,8 @@ function ruleFunction(params, onError) {
242263
if (opts.languages.length === 0 || shouldSkipFile(filePath, opts)) return;
243264
/* c8 ignore stop */
244265

245-
const { headings, blocks } = findHeadingsAndBlocks(lines, opts);
246-
reportBlocksWithoutHeading({ blocks, headings, opts, lines, onError });
266+
const { headings, allHeadings, blocks } = findHeadingsAndBlocks(lines, opts);
267+
reportBlocksWithoutHeading({ blocks, allHeadings, opts, lines, onError });
247268
if (opts.exclusive) {
248269
const allBlocks = findAllBlocks(lines);
249270
reportExclusiveViolations({ allBlocks, headings, opts, lines, onError });

markdownlint-rules/no-heading-like-lines.js

Lines changed: 80 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@ try {
2929
}
3030
/* c8 ignore stop */
3131

32-
/** Patterns: [regex, description]. Order matches EXTRACTORS. Includes MD036-style whole-line emphasis. */
32+
/** Patterns: [regex, description]. Order matches EXTRACTORS. Includes MD036-style whole-line emphasis. Require content (.+ not .*) so **:** does not match. */
3333
const PATTERNS = [
34-
[/^\s*\*\*.*:\*\*\s*$/, "bold with colon inside (**Text:**)"],
35-
[/^\s*\*\*.*\*\*:\s*$/, "bold with colon outside (**Text**:)"],
34+
[/^\s*\*\*.+:\*\*\s*$/, "bold with colon inside (**Text:**)"],
35+
[/^\s*\*\*.+\*\*:\s*$/, "bold with colon outside (**Text**:)"],
3636
[/^\s*[0-9]+\.\s+\*\*.*\*\*\s*$/, "numbered list with bold (1. **Text**)"],
3737
[/^\s*\*.*:\*\s*$/, "italic with colon inside (*Text:*)"],
3838
[/^\s*\*.*\*:\s*$/, "italic with colon outside (*Text*:)"],
@@ -123,66 +123,97 @@ function buildConvertToHeadingInsertText(opts) {
123123
return headingLine;
124124
}
125125

126+
/** True when pattern p matched but content is only colon (e.g. **:**); skip reporting. */
127+
function skipBoldColonOnly(trimmedLine, p, pattern) {
128+
if (![0, 1, 6].includes(p)) return false;
129+
const reWithGroup = p <= 1 ? EXTRACTORS[p][0] : pattern;
130+
const m = trimmedLine.match(reWithGroup);
131+
const content = m?.[1];
132+
return content != null && (content.trim() === "" || content.trim() === ":");
133+
}
134+
126135
/**
127-
* markdownlint rule: flag lines that look like headings but use bold/italic
128-
* (e.g. **Section:** or 1. **Item**, or MD036-style **Introduction** / *Note*)
129-
* so they can be converted to proper ATX headings. Supports fixInfo for --fix.
130-
*
131-
* @param {object} params - markdownlint params (lines, name, config)
132-
* @param {function(object): void} onError - Callback to report an error
136+
* Find first pattern index that matches and passes skip checks; returns { p, description, extractedTitle } or null.
133137
*/
134-
function ruleFunction(params, onError) {
138+
function findHeadingLikeMatch(trimmedLine, punctuationMarks) {
139+
for (let p = 0; p < PATTERNS.length; p++) {
140+
const [pattern, description] = PATTERNS[p];
141+
if (!pattern.test(trimmedLine) || skipBoldColonOnly(trimmedLine, p, pattern)) continue;
142+
const extractedTitle = extractTitleFromHeadingLike(trimmedLine, p);
143+
if (MD036_STYLE_PATTERN_INDICES.has(p) && extractedTitle.length > 0) {
144+
const lastChar = extractedTitle.slice(-1);
145+
if (punctuationMarks.includes(lastChar)) continue;
146+
}
147+
return { p, description, extractedTitle };
148+
}
149+
return null;
150+
}
151+
152+
/** Build and report one heading-like error. */
153+
function reportHeadingLikeError(line, index, match, ctx) {
154+
const { lines, headings, config, ruleConfig, convertToHeading, onError } = ctx;
155+
const { description, extractedTitle } = match;
156+
const lineNumber = index + 1;
157+
const insertText = !convertToHeading
158+
? extractedTitle
159+
: buildConvertToHeadingInsertText({
160+
lines, index, lineNumber, headings, config, ruleConfig, extractedTitle,
161+
});
162+
onError({
163+
lineNumber,
164+
detail: `Line looks like ${description}; use an ATX heading (# Title) instead of heading-like formatting.`,
165+
context: line,
166+
fixInfo: { editColumn: 1, deleteCount: line.length, insertText },
167+
});
168+
}
169+
170+
/** Normalize rule config and build context for processing. */
171+
function getNoHeadingLikeContext(params, onError) {
135172
const lines = params.lines;
136-
const filePath = params.name || "";
137173
const ruleConfig = params.config?.["no-heading-like-lines"] ?? params.config ?? {};
138-
const excludePathPatterns = ruleConfig.excludePathPatterns;
139-
if (Array.isArray(excludePathPatterns) && excludePathPatterns.length > 0 && pathMatchesAny(filePath, excludePathPatterns)) {
140-
return;
141-
}
142174
const convertToHeading = ruleConfig.convertToHeading === true;
143175
const defaultHeadingLevel = ruleConfig.defaultHeadingLevel;
144176
const fixedHeadingLevel = ruleConfig.fixedHeadingLevel;
145177
const punctuationMarks = typeof ruleConfig.punctuationMarks === "string"
146178
? ruleConfig.punctuationMarks
147179
: ".,;!?";
148180
const config = { defaultHeadingLevel, fixedHeadingLevel };
149-
150181
const headings = convertToHeading ? extractHeadings(lines) : [];
182+
return {
183+
lines,
184+
ruleConfig,
185+
excludePathPatterns: ruleConfig.excludePathPatterns,
186+
punctuationMarks,
187+
config,
188+
headings,
189+
convertToHeading,
190+
onError,
191+
ctx: { lines, headings, config, ruleConfig, convertToHeading, onError },
192+
};
193+
}
151194

152-
lines.forEach((line, index) => {
153-
const lineNumber = index + 1;
195+
/**
196+
* markdownlint rule: flag lines that look like headings but use bold/italic
197+
* (e.g. **Section:** or 1. **Item**, or MD036-style **Introduction** / *Note*)
198+
* so they can be converted to proper ATX headings. Supports fixInfo for --fix.
199+
*
200+
* @param {object} params - markdownlint params (lines, name, config)
201+
* @param {function(object): void} onError - Callback to report an error
202+
*/
203+
function ruleFunction(params, onError) {
204+
const { lines, excludePathPatterns, punctuationMarks, ctx } = getNoHeadingLikeContext(params, onError);
205+
const filePath = params.name || "";
206+
if (Array.isArray(excludePathPatterns) && excludePathPatterns.length > 0 && pathMatchesAny(filePath, excludePathPatterns)) {
207+
return;
208+
}
209+
for (let index = 0; index < lines.length; index++) {
210+
const line = lines[index];
154211
const trimmedLine = line.trim();
155-
156-
if (!trimmedLine) return;
157-
158-
for (let p = 0; p < PATTERNS.length; p++) {
159-
const [pattern, description] = PATTERNS[p];
160-
if (!pattern.test(trimmedLine)) continue;
161-
162-
const extractedTitle = extractTitleFromHeadingLike(trimmedLine, p);
163-
if (MD036_STYLE_PATTERN_INDICES.has(p) && extractedTitle.length > 0) {
164-
const lastChar = extractedTitle.slice(-1);
165-
if (punctuationMarks.includes(lastChar)) continue;
166-
}
167-
let insertText;
168-
169-
if (!convertToHeading) {
170-
insertText = extractedTitle;
171-
} else {
172-
insertText = buildConvertToHeadingInsertText({
173-
lines, index, lineNumber, headings, config, ruleConfig, extractedTitle,
174-
});
175-
}
176-
177-
onError({
178-
lineNumber,
179-
detail: `Line looks like ${description}; use an ATX heading (# Title) instead of heading-like formatting.`,
180-
context: line,
181-
fixInfo: { editColumn: 1, deleteCount: line.length, insertText },
182-
});
183-
return;
184-
}
185-
});
212+
if (!trimmedLine) continue;
213+
const match = findHeadingLikeMatch(trimmedLine, punctuationMarks);
214+
if (!match) continue;
215+
reportHeadingLikeError(line, index, match, ctx);
216+
}
186217
}
187218

188219
module.exports = {

markdownlint-rules/one-sentence-per-line.js

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,11 @@ function getNextToken(scanned, startIndex) {
9191
}
9292

9393
function isAbbreviation(scanned, i, j, abbreviations) {
94-
if (scanned[i] === "." && i > 0 && /\d/.test(scanned[i - 1])) return true;
94+
if (scanned[i] === "." && i > 0 && /\d/.test(scanned[i - 1])) {
95+
const nextToken = getNextToken(scanned, j);
96+
if (/^\d/.test(nextToken)) return true;
97+
return false;
98+
}
9599
const word = getWordBefore(scanned, i);
96100
if (word.length === 0) return false;
97101
const nextToken = getNextToken(scanned, j);
@@ -102,29 +106,40 @@ function isAbbreviation(scanned, i, j, abbreviations) {
102106
|| abbreviations.has(wordWithNext) || abbreviations.has(wordWithNextLower);
103107
}
104108

105-
/**
106-
* Find index of the first sentence boundary (space before second sentence) in content.
107-
* Uses stripInlineCode; skips link/paren context; avoids decimals and abbreviations.
108-
* @param {string} content - Prose content (no list marker)
109-
* @param {{ abbreviations?: Set<string> }} opts - Optional abbreviations set
110-
* @returns {number|null} Index of space before second sentence, or null if at most one sentence
111-
*/
112-
function trySentenceBoundary(scanned, i, abbreviations) {
109+
/** Skip optional quotes after position i; return next position or null if no space follows. */
110+
function skipQuotesThenSpace(scanned, i) {
113111
let pos = i + 1;
114112
while (pos < scanned.length && (scanned[pos] === "'" || scanned[pos] === '"')) {
115113
pos++;
116114
}
117-
if (pos >= scanned.length || scanned[pos] === "\n") return null;
118-
if (scanned[pos] !== " ") return null;
119-
const spaceStart = pos;
115+
if (pos >= scanned.length || scanned[pos] === "\n" || scanned[pos] !== " ") return null;
116+
return pos;
117+
}
118+
119+
/** From start of spaces, skip spaces and return { spaceStart, j } or null if no word char follows. */
120+
function spaceStartAndNextWordPos(scanned, spaceStart) {
121+
let pos = spaceStart;
120122
while (pos < scanned.length && scanned[pos] === " ") {
121123
pos++;
122124
}
123125
const j = pos;
124-
if (j >= scanned.length || scanned[j] === "\n") return null;
125-
if (!/[a-zA-Z0-9]/.test(scanned[j])) return null;
126-
if (isAbbreviation(scanned, i, j, abbreviations)) return null;
127-
return i > 0 ? spaceStart : null;
126+
if (j >= scanned.length || scanned[j] === "\n" || !/[a-zA-Z0-9]/.test(scanned[j])) return null;
127+
return { spaceStart, j };
128+
}
129+
130+
/**
131+
* Find index of the first sentence boundary (space before second sentence) in content.
132+
* Uses stripInlineCode; skips link/paren context; avoids decimals and abbreviations.
133+
* @param {string} content - Prose content (no list marker)
134+
* @param {{ abbreviations?: Set<string> }} opts - Optional abbreviations set
135+
* @returns {number|null} Index of space before second sentence, or null if at most one sentence
136+
*/
137+
function trySentenceBoundary(scanned, i, abbreviations) {
138+
const spaceStart = skipQuotesThenSpace(scanned, i);
139+
if (spaceStart === null) return null;
140+
const after = spaceStartAndNextWordPos(scanned, spaceStart);
141+
if (!after || isAbbreviation(scanned, i, after.j, abbreviations)) return null;
142+
return i > 0 ? after.spaceStart : null;
128143
}
129144

130145
function getFirstSentenceBoundary(content, opts) {

0 commit comments

Comments
 (0)