diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..565e2c7
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,2 @@
+# Default owners for everything in the repo
+* @cypher0n3
diff --git a/.github/workflows/markdownlint-tests.yml b/.github/workflows/markdownlint-tests.yml
index 6eb0074..7e7b78e 100644
--- a/.github/workflows/markdownlint-tests.yml
+++ b/.github/workflows/markdownlint-tests.yml
@@ -33,5 +33,13 @@ jobs:
- name: Install dependencies
run: npm ci
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: '3.11'
+
+ - name: Install Python dependencies
+ run: pip install PyYAML
+
- name: Run markdownlint fixture tests
run: make test-markdownlint
diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml
index 994c25c..c001163 100644
--- a/.github/workflows/python-tests.yml
+++ b/.github/workflows/python-tests.yml
@@ -1,6 +1,7 @@
name: Python tests
-# Unit tests for test-scripts/*.py (unittest). NOTE: Keep in sync with 'test-python' Makefile target.
+# Unit tests and coverage for test-scripts/*.py. Keep in sync with 'test-python' and
+# 'test-python-coverage' Makefile targets.
on:
push:
@@ -22,5 +23,26 @@ jobs:
with:
python-version: '3.11'
+ - name: Install test dependencies
+ run: pip install PyYAML
+
- name: Run Python unit tests
run: make test-python
+
+ coverage:
+ name: Python coverage
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: '3.11'
+
+ - name: Install test dependencies
+ run: pip install PyYAML coverage
+
+ - name: Run Python coverage (fail if < 90%)
+ run: make test-python-coverage
diff --git a/.github/workflows/rule-unit-tests.yml b/.github/workflows/rule-unit-tests.yml
index 77f8f63..35fa349 100644
--- a/.github/workflows/rule-unit-tests.yml
+++ b/.github/workflows/rule-unit-tests.yml
@@ -1,7 +1,7 @@
name: Rule unit tests
-# Unit tests for .markdownlint-rules/*.js (Node test runner).
-# NOTE: Keep in sync with the 'test-rules' target in the root Makefile.
+# Unit tests for .markdownlint-rules/*.js (Node test runner) with coverage (fails if any file < 90%).
+# NOTE: Keep in sync with the 'test-rules' and 'test-rules-coverage' targets in the root Makefile.
on:
push:
@@ -29,5 +29,5 @@ jobs:
- name: Install dependencies
run: npm ci
- - name: Run rule unit tests
- run: make test-rules
+ - name: Run rule unit tests with coverage
+ run: make test-rules-coverage
diff --git a/.gitignore b/.gitignore
index 4a9d141..5e3c5ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,7 +36,9 @@ tmp/
*.tar.gz
# Ignore coverage files
+coverage/
coverage.*
+.coverage
# Ignore Python cache files
**/__pycache__/
diff --git a/.markdownlint-rules/README.md b/.markdownlint-rules/README.md
index ce99336..ed20dc5 100644
--- a/.markdownlint-rules/README.md
+++ b/.markdownlint-rules/README.md
@@ -49,6 +49,30 @@ heading-title-case:
The same structure works in `.markdownlint.json` (use JSON object keys and arrays instead of YAML).
+### Using in `VS Code` and its Forks
+
+This repo includes [.vscode/settings.json](../.vscode/settings.json) so the [markdownlint extension](https://marketplace.visualstudio.com/items?itemName=DavidAnson.vscode-markdownlint) uses the same custom rules as the CLI when you open the repo in VS Code or a fork (e.g. Cursor).
+
+- **In another repo:** If you copied these rules into that repo, add a `.vscode/settings.json` there with a `markdownlint.customRules` array listing the paths to each rule file (e.g. `"./.markdownlint-rules/ascii-only.js"`).
+ Use paths relative to the workspace root. Rule options still come from that repo's `.markdownlint.yml` or `.markdownlint.json`; the extension reads both the custom rule paths and the config file.
+
+Example for a repo that has copied rules into `.markdownlint-rules/`:
+
+```json
+{
+ "markdownlint.customRules": [
+ "./.markdownlint-rules/allow-custom-anchors.js",
+ "./.markdownlint-rules/ascii-only.js",
+ "./.markdownlint-rules/heading-numbering.js",
+ "./.markdownlint-rules/heading-title-case.js",
+ "./.markdownlint-rules/no-duplicate-headings-normalized.js",
+ "./.markdownlint-rules/no-heading-like-lines.js"
+ ]
+}
+```
+
+Do not list `utils.js` in `markdownlint.customRules`; it is a helper, not a rule.
+
## Rules
### `allow-custom-anchors`
@@ -152,7 +176,8 @@ Relative patterns (no leading `/` or `*`) match both path-prefix (e.g. `dev_docs
**File:** `heading-title-case.js`
-**Description:** Enforce title case (capital case) for headings. Words inside backticks are not checked. A configurable set of words (e.g. "vs", "and", "the") stay lowercase except when they are the first or last word.
+**Description:** Enforce AP-style (Associated Press) headline capitalization for headings.
+ Words inside backticks are not checked. A configurable set of minor words (e.g. "vs", "and", "the", "is") stay lowercase except when they are the first word, last word, or the first word after a colon or after `(` / `[`.
**Configuration:** In `.markdownlint.yml` (or `.markdownlint.json`) under `heading-title-case`:
@@ -167,14 +192,25 @@ heading-title-case:
- "or"
```
-- **`lowercaseWords`** (array of strings, optional): Words that must be lowercase in the middle of a heading. If omitted, a default list is used: a, an, the, and, or, but, nor, so, yet, as, at, by, for, in, of, on, to, vs, via, per, into, with, from, than, when, if, unless, because, although, while.
+- **`lowercaseWords`** (array of strings, optional): Words that must be lowercase in the middle of a heading.
+ If omitted, a default list aligned with AP headline style is used (articles, coordinating conjunctions, short prepositions, and short verb/pronoun):
+
+ ```text
+ a, an, the,
+ and, but, for, nor, or, so, yet,
+ as, at, by, in, of, off, on, out, per, to, up, via,
+ is, its,
+ v, vs
+ ```
-**Behavior:** For each ATX heading, the title part (after stripping any numeric prefix like `1.2.3`) is checked. Content inside inline code (backticks) is ignored.
+**Behavior (AP headline rules):** For each ATX heading, the title part (after stripping any numeric prefix like `1.2.3`) is checked.
+Content inside inline code (backticks) is ignored.
-- First and last words must be capitalized; middle words that are in the lowercase list must be lowercase; all other words must be capitalized.
+- **First and last words** of the heading must be capitalized (including the first and last segment of hyphenated compounds).
+- **First word after a colon** (e.g. `Summary: The Results`) and the **first word inside parentheses or brackets** (e.g. `(in Practice)`, `[optional]`) are treated as phrase starts and must be capitalized even if they are in the lowercase list.
+- **Hyphenated compounds** (e.g. `One-Stop`, `Follow-Up`) are split on hyphens; each segment is checked as above (first/last of title or minor word in the middle).
+- **Minor words** (in the default or configured list) must be lowercase when in the middle of the heading; all other words must be capitalized.
- Leading and trailing punctuation is ignored when evaluating a word (e.g. `(Word)` is evaluated as `Word`).
-- Parenthesized and bracketed phrases behave like a sentence start for title-case purposes:
- the first word inside `(...)` or `[...]` must be capitalized, even if it is in the lowercase list.
### `no-duplicate-headings-normalized`
diff --git a/.markdownlint-rules/allow-custom-anchors.js b/.markdownlint-rules/allow-custom-anchors.js
index fbb586a..29ee22d 100644
--- a/.markdownlint-rules/allow-custom-anchors.js
+++ b/.markdownlint-rules/allow-custom-anchors.js
@@ -10,6 +10,8 @@ const { stripInlineCode } = require("./utils.js");
function safeRegExp(str) {
if (typeof str !== "string" || !str) return null;
try {
+ // Config-provided pattern; invalid patterns caught below. Required for allowedIdPatterns.
+ // eslint-disable-next-line security/detect-non-literal-regexp
return new RegExp(str);
} catch {
return null;
@@ -93,7 +95,7 @@ function checkPlacementLineMatch(opts) {
const anchorPos = line.lastIndexOf("= 0 ? line.slice(0, anchorPos) : line).trim();
if (rule.lineMatch.test(before)) return null;
- return { lineNumber, detail: "[lineMatch] Anchor line must match the configured lineMatch pattern for this id.", context };
+ return { lineNumber, detail: "[lineMatch] The text before the anchor on this line must match the lineMatch pattern configured for this id.", context };
}
/**
@@ -104,7 +106,7 @@ function checkPlacementLineMatch(opts) {
function checkPlacementStandalone(opts) {
const { trimmed, id, rule, lineNumber, context } = opts;
if (!rule.standaloneLine || trimmed === ``) return null;
- return { lineNumber, detail: "[standaloneLine] This anchor must be on its own line (no other content).", context };
+ return { lineNumber, detail: `[standaloneLine] Anchor id "${id}" must be on its own line with no other content before or after.`, context };
}
/**
@@ -116,11 +118,11 @@ function checkPlacementHeadingSection(opts) {
const { matchIndex, rule, sectionStack, sectionAnchorCount, lineNumber, context } = opts;
if (!rule.headingMatch) return null;
const inSection = sectionStack.some((s) => s.patternIndex === matchIndex);
- if (!inSection) return { lineNumber, detail: "[headingMatch] This anchor must appear within a section whose heading matches the configured headingMatch.", context };
+ if (!inSection) return { lineNumber, detail: "[headingMatch] This anchor must appear under a section heading that matches the headingMatch pattern for this id.", context };
if (rule.maxPerSection == null) return null;
const count = sectionAnchorCount.get(matchIndex) || 0;
if (count >= rule.maxPerSection) {
- return { lineNumber, detail: `[maxPerSection] Only ${rule.maxPerSection} anchor(s) of this type allowed per section.`, context };
+ return { lineNumber, detail: `[maxPerSection] This section already has ${rule.maxPerSection} anchor(s) of this type; only ${rule.maxPerSection} allowed per section.`, context };
}
sectionAnchorCount.set(matchIndex, count + 1);
return null;
@@ -139,7 +141,7 @@ function checkPlacementImmediatelyAfter(opts) {
const prevLine = prev >= 0 ? lines[prev].trim() : "";
const matches = rule.headingMatch ? rule.headingMatch.test(prevLine) : /^\s*#{1,6}\s+/.test(prevLine);
if (prev >= 0 && matches) return null;
- return { lineNumber, detail: "[anchorImmediatelyAfterHeading] This anchor must appear immediately after the section heading (blank lines allowed).", context };
+ return { lineNumber, detail: "[anchorImmediatelyAfterHeading] This anchor must appear immediately after the section heading with only blank lines in between.", context };
}
/**
@@ -151,7 +153,7 @@ function checkPlacementImmediatelyAfter(opts) {
*/
function checkRequireAfterBlank(next, lineNumber, context) {
if (next == null || next.trim() !== "") {
- return { lineNumber, detail: "[requireAfter] Anchor line must be followed by a blank line.", context };
+ return { lineNumber, detail: "[requireAfter] Anchor line must be followed immediately by a blank line (no content on the next line).", context };
}
return null;
}
@@ -165,7 +167,7 @@ function checkRequireAfterBlank(next, lineNumber, context) {
*/
function checkRequireAfterFenced(checkLine, lineNumber, context) {
if (checkLine == null || !checkLine.trim().match(/^(```+|~~~+)/)) {
- return { lineNumber, context, detail: "[requireAfter] Anchor line must be followed by a blank line and then a fenced code block." };
+ return { lineNumber, context, detail: "[requireAfter] Anchor line must be followed by a blank line and then a fenced code block (``` or ~~~)." };
}
return null;
}
@@ -179,7 +181,7 @@ function checkRequireAfterFenced(checkLine, lineNumber, context) {
*/
function checkRequireAfterList(checkLine, lineNumber, context) {
if (checkLine == null || !/^\s*(?:[-*+]\s+|\d+[.)]\s+)/.test(checkLine.trim())) {
- return { lineNumber, context, detail: "[requireAfter] Anchor line must be followed by a blank line and then a list (ordered or unordered)." };
+ return { lineNumber, context, detail: "[requireAfter] Anchor line must be followed by a blank line and then a list (ordered or unordered list)." };
}
return null;
}
@@ -239,15 +241,16 @@ const ANCHOR_END_RE = /<\/a>\s*$/;
*/
function getBasicAnchorError(scanLine, line, lineNumber, allowedPatterns) {
if (scanLine.indexOf(" anchor is allowed per line.", context: line };
+ return { lineNumber, detail: "[one-per-line] Only one anchor is allowed per line; this line contains more than one.", context: line };
}
const match = scanLine.match(ANCHOR_TAG_RE);
- if (!match) return { lineNumber, detail: "[anchor-format] Only anchors are allowed, with id as the only attribute.", context: line };
- if (!allowedPatterns.some((re) => re.test(match[1]))) {
- return { lineNumber, detail: "[allowedIdPatterns] Anchor id must match one of the configured allowedIdPatterns.", context: line };
+ if (!match) return { lineNumber, detail: "[anchor-format] Line must use only anchors with id as the only attribute (no other attributes or tags).", context: line };
+ const anchorId = match[1];
+ if (!allowedPatterns.some((re) => re.test(anchorId))) {
+ return { lineNumber, detail: `[allowedIdPatterns] Anchor id "${anchorId}" does not match any configured allowedIdPatterns.`, context: line };
}
if (!scanLine.match(ANCHOR_END_RE)) {
- return { lineNumber, detail: "[end-of-line] Anchors must appear at the end of the line (or be a standalone reference anchor line above a fenced code block).", context: line };
+ return { lineNumber, detail: "[end-of-line] Anchor must be at the end of the line (no content after ), or be a standalone reference anchor above a fenced code block.", context: line };
}
return null;
}
diff --git a/.markdownlint-rules/heading-numbering.js b/.markdownlint-rules/heading-numbering.js
index ba4f537..9e26745 100644
--- a/.markdownlint-rules/heading-numbering.js
+++ b/.markdownlint-rules/heading-numbering.js
@@ -53,9 +53,11 @@ function getSiblings(sorted, parentIndex, i) {
if (parentIndex[j] !== parentIndex[i]) {
continue;
}
+ /* c8 ignore start -- same parent implies same level by tree construction */
if (sorted[j].level !== h.level) {
continue;
}
+ /* c8 ignore stop */
siblings.push({ index: j, ...sorted[j] });
}
siblings.sort((a, b) => a.lineNumber - b.lineNumber);
@@ -76,17 +78,21 @@ function getExpectedNumberInSection(sorted, parentIndex, i) {
const siblings = getSiblings(sorted, parentIndex, i);
const myIdx = siblings.findIndex((s) => s.lineNumber === h.lineNumber);
+ /* c8 ignore start -- current heading is always in its sibling list */
if (myIdx < 0) {
return null;
}
+ /* c8 ignore stop */
const firstNumbered = siblings.find((s) =>
parseHeadingNumberPrefix(s.rawText).numbering != null
);
+ /* c8 ignore start -- sectionUsesNum ensures at least one numbered sibling */
const firstNumbering =
firstNumbered != null
? parseHeadingNumberPrefix(firstNumbered.rawText).numbering
: null;
+ /* c8 ignore stop */
const startAtZero = firstNumbering === "0";
const nextNum = startAtZero ? myIdx : myIdx + 1;
const prefix = parentNum ? parentNum + "." : "";
@@ -111,9 +117,11 @@ function getSectionPeriodStyle(sorted, parentIndex, i) {
const firstNumbered = siblings.find((s) =>
parseHeadingNumberPrefix(s.rawText).numbering != null
);
+ /* c8 ignore start -- getPeriodStyleError only called for numbered headings */
if (firstNumbered == null) {
return null;
}
+ /* c8 ignore stop */
return parseHeadingNumberPrefix(firstNumbered.rawText).hasH2Dot;
}
@@ -129,7 +137,7 @@ function getPeriodStyleError(ctx) {
if (sectionPeriodStyle == null || hasH2Dot === sectionPeriodStyle) return null;
return {
lineNumber: h.lineNumber,
- detail: `Period inconsistency in this section: use ${sectionPeriodStyle ? "period" : "no period"} after number to match sibling.`,
+ detail: `Numbering period style inconsistent: use ${sectionPeriodStyle ? "a period" : "no period"} after the number (e.g. "${sectionPeriodStyle ? "1.2." : "1.2"}") to match other numbered headings in this section.`,
context: contextLine,
};
}
@@ -147,7 +155,7 @@ function checkSegmentCount(ctx) {
const expectedSegmentCount = h.level - rootLevel;
const segments = numbering.split(".");
if (segments.length !== expectedSegmentCount) {
- return { lineNumber: h.lineNumber, detail: `H${h.level} heading has ${segments.length} number(s), expected ${expectedSegmentCount} (level - numbering root).`, context: contextLine };
+ return { lineNumber: h.lineNumber, detail: `H${h.level} heading has ${segments.length} segment(s) in number prefix "${numbering}"; expected ${expectedSegmentCount} (one per level from numbering root).`, context: contextLine };
}
return null;
}
@@ -166,7 +174,7 @@ function getHeadingErrors(h, i, ctx) {
const sectionUsesNum = sectionUsesNumbering(sorted, parentIndex, i);
if (sectionUsesNum && numbering == null) {
- errors.push({ lineNumber: h.lineNumber, detail: "This section uses numbering; add a number prefix to match siblings.", context: contextLine });
+ errors.push({ lineNumber: h.lineNumber, detail: "This heading has no number prefix but other headings in this section are numbered; add a number prefix to match siblings (e.g. \"1.2\" for second under 1).", context: contextLine });
return errors;
}
if (numbering == null) return errors;
@@ -182,7 +190,7 @@ function getHeadingErrors(h, i, ctx) {
if (periodErr) errors.push(periodErr);
const expected = getExpectedNumberInSection(sorted, parentIndex, i);
if (expected != null && numbering !== expected) {
- errors.push({ lineNumber: h.lineNumber, detail: `Non-sequential numbering in this section: got '${numbering}', expected '${expected}'.`, context: contextLine });
+ errors.push({ lineNumber: h.lineNumber, detail: `Number prefix "${numbering}" is out of sequence in this section; expected "${expected}" to match sibling order.`, context: contextLine });
}
return errors;
}
diff --git a/.markdownlint-rules/heading-title-case.js b/.markdownlint-rules/heading-title-case.js
index 17df499..e5ee718 100644
--- a/.markdownlint-rules/heading-title-case.js
+++ b/.markdownlint-rules/heading-title-case.js
@@ -6,11 +6,22 @@ const {
stripInlineCode,
} = require("./utils.js");
-/** Default words that stay lowercase in title case (unless first or last word). */
+/** Default lowercase words for AP-style headings (unless first/last/subphrase-start). */
const DEFAULT_LOWERCASE_WORDS = new Set([
- "a", "an", "the", "and", "or", "but", "nor", "so", "yet", "as", "at", "by",
- "for", "in", "of", "on", "to", "vs", "via", "per", "into", "with", "from",
- "into", "than", "when", "if", "unless", "because", "although", "while",
+ // Articles
+ "a", "an", "the",
+
+ // Coordinating conjunctions
+ "and", "but", "for", "nor", "or", "so", "yet",
+
+ // Prepositions (3 letters or fewer) and infinitive "to"
+ "as", "at", "by", "in", "of", "off", "on", "out", "per", "to", "up", "via",
+
+ // Short verb/pronoun (AP: lowercase when not first/last)
+ "is", "its",
+
+ // Comparison/citations
+ "v", "vs",
]);
/**
@@ -57,19 +68,68 @@ function checkWord(opts) {
// the first word inside them should be capitalized, even if it's in lowercaseWords.
const shouldBeLower = !isFirst && !isLast && !isSubphraseStart && lowercaseWords.has(coreLower);
if (shouldBeLower) {
- return isAllLower(raw) ? null : `"${core}" should be lowercase in title case (middle word).`;
+ return isAllLower(raw) ? null : `Word "${core}" should be lowercase (middle word in title case).`;
}
if (startsWithUpper(raw)) return null;
const kind = (isFirst || isSubphraseStart) ? "first" : isLast ? "last" : "major";
- return `"${core}" should be capitalized (${kind} word).`;
+ return `Word "${core}" should be capitalized (${kind} word in title case).`;
+}
+
+/**
+ * Compute 0-based offset and length of segment j within a hyphenated word.
+ * @param {string[]} rawSegments - Parts of the word split on '-'
+ * @param {number} j - Segment index
+ * @returns {{ segmentOffset: number, segmentLength: number }|undefined}
+ */
+function getSegmentPosition(rawSegments, j) {
+ if (rawSegments.length <= 1) return undefined;
+ let segmentOffset = 0;
+ for (let k = 0; k < j; k++) segmentOffset += rawSegments[k].length + 1;
+ return { segmentOffset, segmentLength: rawSegments[j].length };
+}
+
+/**
+ * Check one segment of a word for title-case; returns error result or null.
+ * @param {{ words: string[], rawSegments: string[], i: number, j: number, wordIsSubphraseStart: boolean, lowercaseWords: Set }} opts
+ * @returns {{ valid: false, detail: string, wordIndex: number, segmentOffset?: number, segmentLength?: number }|null}
+ */
+function checkOneSegment(opts) {
+ const { words, rawSegments, i, j, wordIsSubphraseStart, lowercaseWords } = opts;
+ const rawSeg = rawSegments[j];
+ const core = stripWordPunctuation(rawSeg);
+ if (!core || !/[a-zA-Z]/.test(core)) return null;
+
+ const isFirst = i === 0 && j === 0;
+ const isLast = i === words.length - 1 && j === rawSegments.length - 1;
+ const isSubphraseStart = j === 0 && wordIsSubphraseStart;
+
+ const detail = checkWord({
+ raw: rawSeg,
+ core,
+ isFirst,
+ isLast,
+ lowercaseWords,
+ isSubphraseStart,
+ });
+ if (!detail) return null;
+
+ const seg = getSegmentPosition(rawSegments, j);
+ return {
+ valid: false,
+ detail,
+ wordIndex: i,
+ ...(seg && { segmentOffset: seg.segmentOffset, segmentLength: seg.segmentLength }),
+ };
}
/**
* Validate title case on a heading's title part (numbering stripped).
- * Words inside backticks are excluded from checking.
+ * AP rules: first/last/subphrase-start capitalized; hyphenated segments checked separately;
+ * first word after colon treated as subphrase start. Words in backticks are excluded.
+ *
* @param {string} titleText - Title after stripping numbering
* @param {Set} lowercaseWords - Words that must be lowercase in middle
- * @returns {{ valid: boolean, detail?: string }}
+ * @returns {{ valid: boolean, detail?: string, wordIndex?: number, segmentOffset?: number, segmentLength?: number }}
*/
function checkTitleCase(titleText, lowercaseWords) {
const withCodeStripped = stripInlineCode(titleText);
@@ -78,29 +138,59 @@ function checkTitleCase(titleText, lowercaseWords) {
for (let i = 0; i < words.length; i++) {
const raw = words[i];
- const core = stripWordPunctuation(raw);
- if (!core || !/[a-zA-Z]/.test(core)) continue;
-
const firstAlphaIdx = raw.search(/[A-Za-z0-9]/);
const prefix = firstAlphaIdx > 0 ? raw.slice(0, firstAlphaIdx) : "";
- const isSubphraseStart = prefix.includes("(") || prefix.includes("[");
-
- const detail = checkWord({
- raw,
- core,
- isFirst: i === 0,
- isLast: i === words.length - 1,
- lowercaseWords,
- isSubphraseStart,
- });
- if (detail) return { valid: false, detail };
+ const afterColon = i > 0 && words[i - 1].replace(/\s+$/, "").endsWith(":");
+ const wordIsSubphraseStart = prefix.includes("(") || prefix.includes("[") || afterColon;
+
+ const rawSegments = raw.split(/-/);
+ for (let j = 0; j < rawSegments.length; j++) {
+ const result = checkOneSegment({
+ words,
+ rawSegments,
+ i,
+ j,
+ wordIsSubphraseStart,
+ lowercaseWords,
+ });
+ if (result) return result;
+ }
}
return { valid: true };
}
/**
- * markdownlint rule: enforce title case on headings (first/last/major words capitalized;
- * configurable lowercase words for middle). Words in backticks are skipped.
+ * Get 1-based column and length of the i-th word (or segment within it) in the heading line.
+ * @param {string} line - Full source line (e.g. "## 1.2 The quick Brown")
+ * @param {string} rawText - Content after ATX prefix (e.g. "1.2 The quick Brown")
+ * @param {string} titleText - Content after numbering (e.g. "The quick Brown")
+ * @param {{ wordIndex: number, segmentOffset?: number, segmentLength?: number }} opts
+ * @returns {{ column: number, length: number }|null}
+ */
+function getWordRangeInLine(line, rawText, titleText, opts) {
+ const { wordIndex, segmentOffset, segmentLength } = opts;
+ const wordMatches = [...titleText.matchAll(/\S+/g)];
+ if (wordIndex < 0 || wordIndex >= wordMatches.length) return null;
+ const rawTextStart = line.indexOf(rawText);
+ if (rawTextStart === -1) return null;
+ const titleStartInRaw = rawText.indexOf(titleText);
+ if (titleStartInRaw === -1) return null;
+ const m = wordMatches[wordIndex];
+ let column = rawTextStart + titleStartInRaw + m.index + 1;
+ let length = m[0].length;
+ if (segmentOffset !== undefined && segmentLength !== undefined) {
+ column += segmentOffset;
+ length = segmentLength;
+ }
+ return { column, length };
+}
+
+/**
+ * markdownlint rule: enforce AP-style heading capitalization.
+ * - First and last words must be capitalized.
+ * - Lowercase only a small set of minor words (articles, coordinating conjunctions,
+ * and short prepositions) in the middle.
+ * - Words in backticks are skipped.
*
* @param {object} params - markdownlint params (lines, config)
* @param {function(object): void} onError - Callback to report an error
@@ -117,10 +207,17 @@ function ruleFunction(params, onError) {
const { titleText } = parseHeadingNumberPrefix(h.rawText);
const result = checkTitleCase(titleText, lowercaseWords);
if (!result.valid) {
+ const line = params.lines[h.lineNumber - 1];
+ const rangeInfo = getWordRangeInLine(line, h.rawText, titleText, {
+ wordIndex: result.wordIndex,
+ segmentOffset: result.segmentOffset,
+ segmentLength: result.segmentLength,
+ });
onError({
lineNumber: h.lineNumber,
detail: result.detail,
- context: h.rawText,
+ context: line,
+ ...(rangeInfo && { range: [rangeInfo.column, rangeInfo.length] }),
});
}
}
@@ -128,7 +225,7 @@ function ruleFunction(params, onError) {
module.exports = {
names: ["heading-title-case"],
- description: "Enforce title case (capital case) for headings, with exceptions for words in backticks and configurable lowercase words.",
+ description: "Enforce AP-style capitalization for headings, with exceptions for words in backticks and configurable lowercase words.",
tags: ["headings"],
function: ruleFunction,
};
diff --git a/.markdownlint-rules/no-duplicate-headings-normalized.js b/.markdownlint-rules/no-duplicate-headings-normalized.js
index 065d450..5be55a4 100644
--- a/.markdownlint-rules/no-duplicate-headings-normalized.js
+++ b/.markdownlint-rules/no-duplicate-headings-normalized.js
@@ -28,7 +28,7 @@ function ruleFunction(params, onError) {
byNormalized.get(key).push(h);
}
- for (const [, group] of byNormalized) {
+ for (const [normTitle, group] of byNormalized) {
if (group.length <= 1) {
continue;
}
@@ -38,7 +38,7 @@ function ruleFunction(params, onError) {
const dup = group[i];
onError({
lineNumber: dup.lineNumber,
- detail: `Duplicate heading title (same as line ${first.lineNumber}). Each heading should have a unique title after stripping numbering.`,
+ detail: `Duplicate heading title "${normTitle}" (same normalized text as line ${first.lineNumber}). Each heading must have a unique title after stripping numbering and normalizing.`,
context: params.lines[dup.lineNumber - 1],
});
}
diff --git a/.markdownlint-rules/no-heading-like-lines.js b/.markdownlint-rules/no-heading-like-lines.js
index 786c4cf..f445d8c 100644
--- a/.markdownlint-rules/no-heading-like-lines.js
+++ b/.markdownlint-rules/no-heading-like-lines.js
@@ -10,41 +10,33 @@
function ruleFunction(params, onError) {
const lines = params.lines;
- // Patterns to match heading-like lines:
- // 1. **Text:** - bold with colon inside (^\*\*.*:\*\*$)
- // 2. **Text**: - bold with colon outside (^\*\*.*\*\*:$)
- // 3. 1. **Text** - numbered list with bold (^[0-9]+\. \*\*.*\*\*$)
- // 4. Similar patterns with single asterisks for italic
- const patterns = [
- /^\s*\*\*.*:\*\*\s*$/, // **Text:** (colon inside)
- /^\s*\*\*.*\*\*:\s*$/, // **Text**: (colon outside)
- /^\s*[0-9]+\.\s+\*\*.*\*\*\s*$/, // 1. **Text** (numbered list)
- /^\s*\*.*:\*\s*$/, // *Text:* (italic with colon inside)
- /^\s*\*.*\*:\s*$/, // *Text*: (italic with colon outside)
- /^\s*[0-9]+\.\s+\*.*\*\s*$/, // 1. *Text* (numbered list with italic)
- ];
+ // Patterns: [regex, description for error message]
+ const patterns = [
+ [/^\s*\*\*.*:\*\*\s*$/, "bold with colon inside (**Text:**)"],
+ [/^\s*\*\*.*\*\*:\s*$/, "bold with colon outside (**Text**:)"],
+ [/^\s*[0-9]+\.\s+\*\*.*\*\*\s*$/, "numbered list with bold (1. **Text**)"],
+ [/^\s*\*.*:\*\s*$/, "italic with colon inside (*Text:*)"],
+ [/^\s*\*.*\*:\s*$/, "italic with colon outside (*Text*:)"],
+ [/^\s*[0-9]+\.\s+\*.*\*\s*$/, "numbered list with italic (1. *Text*)"],
+ ];
- lines.forEach((line, index) => {
- const lineNumber = index + 1;
- const trimmedLine = line.trim();
+ lines.forEach((line, index) => {
+ const lineNumber = index + 1;
+ const trimmedLine = line.trim();
- // Skip empty lines
- if (!trimmedLine) {
- return;
- }
+ if (!trimmedLine) return;
- // Check if line matches any heading-like pattern
- for (const pattern of patterns) {
- if (pattern.test(trimmedLine)) {
- onError({
- lineNumber: lineNumber,
- detail: "Use proper Markdown headings instead of heading-like lines",
- context: line,
- });
- break; // Only report once per line
- }
+ for (const [pattern, description] of patterns) {
+ if (pattern.test(trimmedLine)) {
+ onError({
+ lineNumber,
+ detail: `Line looks like ${description}; use an ATX heading (# Title) instead of heading-like formatting.`,
+ context: line,
+ });
+ break;
}
- });
+ }
+ });
}
module.exports = {
diff --git a/.markdownlint-rules/utils.js b/.markdownlint-rules/utils.js
index 9bc4fff..c510caa 100644
--- a/.markdownlint-rules/utils.js
+++ b/.markdownlint-rules/utils.js
@@ -173,6 +173,8 @@ function globToRegExp(pattern) {
i += 1;
}
}
+ // Built from escaped glob segments only (.*, [^/]*, or escaped chars) — safe.
+ // eslint-disable-next-line security/detect-non-literal-regexp
return new RegExp("^" + parts.join("") + "$");
}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..b759ec9
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,10 @@
+{
+ "markdownlint.customRules": [
+ "./.markdownlint-rules/allow-custom-anchors.js",
+ "./.markdownlint-rules/ascii-only.js",
+ "./.markdownlint-rules/heading-numbering.js",
+ "./.markdownlint-rules/heading-title-case.js",
+ "./.markdownlint-rules/no-duplicate-headings-normalized.js",
+ "./.markdownlint-rules/no-heading-like-lines.js"
+ ]
+}
diff --git a/Makefile b/Makefile
index b8d93b5..be59f66 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,7 @@
-.PHONY: ci lint-js lint-readmes lint-python test-markdownlint test-python test-rules venv
+.PHONY: ci lint-js lint-readmes lint-python test-markdownlint test-python test-python-coverage test-rules test-rules-coverage venv
# Run all CI checks (same as GitHub Actions workflows). Run after 'npm install' and optionally 'make venv'.
-ci: lint-js test-rules test-markdownlint lint-python test-python lint-readmes
+ci: lint-js test-rules-coverage test-markdownlint lint-python test-python-coverage test-python lint-readmes
# README linting - performs same checks as GitHub Actions workflow
# NOTE: This target must be kept in sync with .github/workflows/lint-readmes.yml.
@@ -127,17 +127,27 @@ test-rules:
echo "Error: node not found. Install Node.js and run npm install."; \
exit 1; \
}
- @node --test 'test/markdownlint-rules/*.test.js'
+ @node --test test/markdownlint-rules/*.test.js
+
+# Unit test coverage for .markdownlint-rules/*.js (fails if any file < 90% lines/statements).
+# Requires: Node.js, npm; run 'npm install' first.
+test-rules-coverage:
+ @command -v node >/dev/null 2>&1 || { \
+ echo "Error: node not found. Install Node.js and run npm install."; \
+ exit 1; \
+ }
+ @npm run test:rules:coverage
# Markdownlint positive/negative tests - same as .github/workflows/markdownlint-tests.yml
# NOTE: Keep in sync with that workflow. Positive must pass; each negative must fail.
# Requires: Node.js, npm; run 'npm install' or 'npm ci' first.
+# VERBOSE=1 prints each fixture as it is verified.
test-markdownlint:
@command -v node >/dev/null 2>&1 || { \
echo "Error: node not found. Install Node.js and run npm install."; \
exit 1; \
}
- @python3 test-scripts/verify_markdownlint_fixtures.py
+ @python3 test-scripts/verify_markdownlint_fixtures.py $(if $(filter 1,$(VERBOSE)),--verbose)
# Python unit tests - same as .github/workflows/python-tests.yml
# NOTE: Keep in sync with that workflow. Requires: Python 3.
@@ -148,6 +158,21 @@ test-python:
}
@python3 -m unittest discover -s test-scripts -p "test_*.py" -v
+# Python unit test coverage - runs tests with coverage, fails if coverage < 90%.
+# Requires: pip install coverage (or make venv). Sources: test-scripts/*.py (excl. test_*.py).
+test-python-coverage:
+ @command -v python3 >/dev/null 2>&1 || { \
+ echo "Error: python3 not found. Install Python 3 to run coverage."; \
+ exit 1; \
+ }
+ @command -v coverage >/dev/null 2>&1 || [ -x .venv/bin/coverage ] || { \
+ echo "Error: coverage not found. Install with: pip install coverage or run 'make venv'"; \
+ exit 1; \
+ }
+ @if [ -d .venv ]; then PATH="$(CURDIR)/.venv/bin:$$PATH"; export PATH; fi; \
+ coverage run -m unittest discover -s test-scripts -p "test_*.py" && \
+ coverage report --include="test-scripts/*.py" --omit="test-scripts/test_*.py" --fail-under=90
+
# Python venv for lint tooling - creates .venv and installs test-scripts/requirements-lint.txt
# Run once (or after adding/updating test-scripts/requirements-lint.txt) so make lint-python uses the venv.
# Usage: make venv
diff --git a/README.md b/README.md
index d62d819..f7e2183 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,7 @@ Lint and docs-as-code tooling: custom [markdownlint](https://github.com/DavidAns
- **JS linting** for the rule code: ESLint (recommended + complexity/max-lines + eslint-plugin-security), aligned with the GitHub Actions workflow.
- **Markdownlint fixture tests**: [md_test_files/](md_test_files/README.md) includes positive/negative fixtures with explicit expected errors, verified by `test-scripts/verify_markdownlint_fixtures.py`.
- **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`.
+ CI runs `make test-rules-coverage` (fails if any rule file is below 90% line/statement coverage).
- **Python unit tests**: `unittest` tests for [test-scripts/](test-scripts/README.md) in `test-scripts/test_*.py`; run with `make test-python`.
- **Python linting** for repo tooling scripts: `make lint-python` (flake8, pylint, xenon/radon, vulture, bandit).
@@ -72,12 +73,16 @@ npm install
make test-markdownlint
```
+ Use `VERBOSE=1` to print each fixture as it is verified: `make test-markdownlint VERBOSE=1`.
+
- **Run rule unit tests**:
```bash
make test-rules
```
+ With coverage (fails if any rule < 90%): `make test-rules-coverage`
+
- **Run Python unit tests**:
```bash
@@ -100,10 +105,11 @@ npm install
```
- **Use the custom rules**: Copy the `.markdownlint-rules/*.js` files (and optionally the rule [README](.markdownlint-rules/README.md) and config) into your docs repo, then point markdownlint-cli2 at that directory and your `.markdownlint.yml` / `.markdownlint-cli2.jsonc`.
+ For VS Code (and forks like Cursor), see [.markdownlint-rules/README.md](.markdownlint-rules/README.md#using-in-vs-code-and-its-forks).
## Repository Layout
-- **`.github/workflows/`** - CI:
+- **`.github/`** - [CODEOWNERS](.github/CODEOWNERS) and **workflows/** (CI):
- [auto-assign.yml](.github/workflows/auto-assign.yml)
- [js-lint.yml](.github/workflows/js-lint.yml)
- [lint-readmes.yml](.github/workflows/lint-readmes.yml)
@@ -111,6 +117,7 @@ npm install
- [rule-unit-tests.yml](.github/workflows/rule-unit-tests.yml)
- [python-lint.yml](.github/workflows/python-lint.yml)
- [python-tests.yml](.github/workflows/python-tests.yml)
+- **`.vscode/settings.json`** - Editor settings so the markdownlint extension uses this repo's custom rules in VS Code and compatible editors (see [.markdownlint-rules/README.md](.markdownlint-rules/README.md#using-in-vs-code-and-its-forks)).
- **`.markdownlint-cli2.jsonc`** - markdownlint-cli2 config: custom rule paths, extends `.markdownlint.yml`, ignores.
- **`.markdownlint-rules/`** - Custom rule modules (`*.js`) and [README](.markdownlint-rules/README.md). Copy into other repos; do not register `utils.js`.
- **`.markdownlint.yml`** - markdownlint and custom-rule options (e.g. ascii-only, allow-custom-anchors).
diff --git a/md_test_files/README.md b/md_test_files/README.md
index f96188e..56f14ff 100644
--- a/md_test_files/README.md
+++ b/md_test_files/README.md
@@ -5,31 +5,32 @@
## Negative Fixtures (Custom Rules Only)
-| File | Expected custom rule(s) |
-| ---------------------------------------- | --------------------------------------------------------- |
-| negative_heading_like.md | no-heading-like-lines |
-| negative_heading_like_variants.md | no-heading-like-lines (italic, numbered-list variants) |
-| negative_heading_title_case.md | heading-title-case |
-| negative_heading_numbering.md | heading-numbering (segment, sequence, period, unnumbered) |
-| negative_duplicate_headings_normalized.md| no-duplicate-headings-normalized |
-| negative_ascii_only.md | ascii-only |
-| negative_anchor_invalid_id.md | allow-custom-anchors (id not in allowedIdPatterns) |
-| negative_anchor_spec_placement.md | allow-custom-anchors (spec placement) |
-| negative_anchor_ref_placement.md | allow-custom-anchors (ref placement) |
-| negative_anchor_algo_placement.md | allow-custom-anchors (algo placement) |
-| negative_anchor_multiple.md | allow-custom-anchors (multiple per line) |
-| negative_inline_html.md | allow-custom-anchors (attribute, id pattern, end-of-line) |
+| File | Expected custom rule(s) |
+| ----------------------------------------- | --------------------------------------------------------- |
+| negative_anchor_algo_placement.md | allow-custom-anchors (algo placement) |
+| negative_anchor_invalid_id.md | allow-custom-anchors (id not in allowedIdPatterns) |
+| negative_anchor_multiple.md | allow-custom-anchors (multiple per line) |
+| negative_anchor_ref_placement.md | allow-custom-anchors (ref placement) |
+| negative_anchor_spec_placement.md | allow-custom-anchors (spec placement) |
+| negative_ascii_only.md | ascii-only |
+| negative_duplicate_headings_normalized.md | no-duplicate-headings-normalized |
+| negative_heading_like.md | no-heading-like-lines |
+| negative_heading_numbering.md | heading-numbering (segment, sequence, period, unnumbered) |
+| negative_heading_title_case.md | heading-title-case |
+| negative_inline_html.md | allow-custom-anchors (attribute, id pattern, end-of-line) |
Note: some negative fixtures intentionally trigger built-in markdownlint rules in addition to custom rules (e.g. MD031/MD032/MD033), so the test suite can assert multiple errors on specific lines.
## Expectations
-Each fixture includes a `markdownlint-expect` fenced code block containing JSON:
+Expected errors are defined in **expected_errors.yml** (one entry per fixture, keyed by filename). Each entry has:
-- `total`: expected number of markdownlint errors for this file
-- `errors`: list of expected errors (each has `line` and `rule`; optional `column` for rules that report at character level, e.g. `ascii-only`)
+- **errors**: list of expected errors. Each error has:
+ - **line** (required), **rule** (required)
+ - **column** (optional) - for rules that report at character level (e.g. ascii-only, heading-title-case)
+ - **message_contains** (optional) - substring that must appear in the rule's message
-The `make test-markdownlint` target runs `test-scripts/verify_markdownlint_fixtures.py`, which lints each fixture and validates the exact expected errors.
+Total expected count is the length of the errors list. The `make test-markdownlint` target runs `test-scripts/verify_markdownlint_fixtures.py`, which lints each fixture and validates output against this file.
## Linting
@@ -41,4 +42,4 @@ The `make test-markdownlint` target runs `test-scripts/verify_markdownlint_fixtu
`make test-markdownlint`
-See `md_test_files/positive.md` and `md_test_files/negative_*.md` for the current expected errors.
+See **expected_errors.yml** for the expected errors per fixture.
diff --git a/md_test_files/expected_errors.yml b/md_test_files/expected_errors.yml
new file mode 100644
index 0000000..9156bc0
--- /dev/null
+++ b/md_test_files/expected_errors.yml
@@ -0,0 +1,171 @@
+# Expected markdownlint errors per fixture. Key = filename under md_test_files.
+# Used by test-scripts/verify_markdownlint_fixtures.py.
+# Total expected count is derived from the length of the errors list.
+
+positive.md:
+ errors: []
+
+negative_anchor_algo_placement.md:
+ errors:
+ - line: 11
+ rule: allow-custom-anchors
+ message_contains: headingMatch
+
+negative_anchor_invalid_id.md:
+ errors:
+ - line: 9
+ rule: allow-custom-anchors
+ message_contains: allowedIdPatterns
+
+negative_anchor_multiple.md:
+ errors:
+ - line: 10
+ rule: allow-custom-anchors
+ message_contains: one-per-line
+ - line: 10
+ rule: MD032/blanks-around-lists
+ message_contains: surrounded by blank lines
+
+negative_anchor_ref_placement.md:
+ errors:
+ - line: 10
+ rule: allow-custom-anchors
+ message_contains: requireAfter
+ - line: 11
+ rule: MD031/blanks-around-fences
+ message_contains: Fenced code blocks should be surrounded
+
+negative_anchor_spec_placement.md:
+ errors:
+ - line: 10
+ rule: allow-custom-anchors
+ message_contains: lineMatch
+
+negative_ascii_only.md:
+ errors:
+ - line: 7
+ rule: ascii-only
+ column: 32
+ message_contains: "U+2192"
+ - line: 8
+ rule: ascii-only
+ column: 27
+ message_contains: "U+201C"
+ - line: 8
+ rule: ascii-only
+ column: 33
+ message_contains: "U+201D"
+ - line: 8
+ rule: ascii-only
+ column: 39
+ message_contains: "U+2018"
+ - line: 8
+ rule: ascii-only
+ column: 45
+ message_contains: "U+2019"
+ - line: 10
+ rule: ascii-only
+ column: 42
+ message_contains: use ASCII only
+
+negative_duplicate_headings_normalized.md:
+ errors:
+ - line: 9
+ rule: heading-numbering
+ message_contains: expected "2" to match sibling
+ - line: 9
+ rule: MD024/no-duplicate-heading
+ message_contains: Multiple headings with the same content
+ - line: 9
+ rule: no-duplicate-headings-normalized
+ message_contains: Duplicate heading title "introduction"
+ - line: 13
+ rule: heading-numbering
+ message_contains: expected "3" to match sibling
+ - line: 17
+ rule: heading-numbering
+ message_contains: expected "4" to match sibling
+ - line: 17
+ rule: no-duplicate-headings-normalized
+ message_contains: Duplicate heading title "overview"
+
+negative_heading_like.md:
+ errors:
+ - line: 9
+ rule: no-heading-like-lines
+ message_contains: bold with colon inside
+ - line: 11
+ rule: no-heading-like-lines
+ message_contains: italic with colon inside
+ - line: 13
+ rule: no-heading-like-lines
+ message_contains: italic with colon outside
+ - line: 15
+ rule: no-heading-like-lines
+ message_contains: numbered list with bold
+
+negative_heading_numbering.md:
+ errors:
+ - line: 13
+ rule: heading-numbering
+ message_contains: expected "1.2" to match sibling
+ - line: 15
+ rule: heading-numbering
+ message_contains: period style inconsistent
+ - line: 17
+ rule: heading-numbering
+ message_contains: no number prefix
+ - line: 19
+ rule: heading-numbering
+ message_contains: period style inconsistent
+ - line: 19
+ rule: heading-numbering
+ message_contains: expected "3" to match sibling
+ - line: 23
+ rule: heading-numbering
+ message_contains: 2 segment(s) in number prefix "1.1"
+ - line: 25
+ rule: heading-numbering
+ message_contains: Too Many Segments for H4
+
+negative_heading_title_case.md:
+ errors:
+ - line: 7
+ rule: heading-title-case
+ message_contains: 'Word "getting" should be capitalized'
+ - line: 11
+ rule: heading-title-case
+ message_contains: 'Word "And" should be lowercase'
+ - line: 15
+ rule: heading-title-case
+ message_contains: 'Word "practice" should be capitalized'
+ - line: 19
+ rule: heading-title-case
+ message_contains: 'Word "in" should be capitalized'
+ - line: 23
+ rule: heading-title-case
+ message_contains: 'Word "in" should be capitalized'
+ - line: 27
+ rule: heading-title-case
+ message_contains: 'Word "is" should be capitalized'
+ - line: 31
+ rule: heading-title-case
+ message_contains: 'Word "stop" should be capitalized'
+ - line: 35
+ rule: heading-title-case
+ message_contains: 'Word "the" should be capitalized'
+
+negative_inline_html.md:
+ errors:
+ - line: 10
+ rule: MD033/no-inline-html
+ message_contains: Inline HTML
+ - line: 13
+ rule: allow-custom-anchors
+ message_contains: anchor-format
+ - line: 16
+ rule: allow-custom-anchors
+ message_contains: bad-np-core-package-readfile
+ - line: 18
+ rule: allow-custom-anchors
+ message_contains: end-of-line
diff --git a/md_test_files/negative_anchor_algo_placement.md b/md_test_files/negative_anchor_algo_placement.md
index 1609abf..ffd95a0 100644
--- a/md_test_files/negative_anchor_algo_placement.md
+++ b/md_test_files/negative_anchor_algo_placement.md
@@ -4,15 +4,6 @@ Lint: `npx markdownlint-cli2 md_test_files/negative_anchor_algo_placement.md`
Expect: allow-custom-anchors (algorithm anchor must be inside Algorithm section, etc.).
-```markdownlint-expect
-{
- "total": 1,
- "errors": [
- { "line": 20, "rule": "allow-custom-anchors" }
- ]
-}
-```
-
## Bad Algorithm Anchor Placement
This should be rejected because algorithm anchors must be inside an Algorithm section and followed by a blank line and then a list.
diff --git a/md_test_files/negative_anchor_invalid_id.md b/md_test_files/negative_anchor_invalid_id.md
index 727e6dc..4109793 100644
--- a/md_test_files/negative_anchor_invalid_id.md
+++ b/md_test_files/negative_anchor_invalid_id.md
@@ -2,15 +2,6 @@
Expect: `allow-custom-anchors` (id does not match any allowedIdPatterns).
-```markdownlint-expect
-{
- "total": 1,
- "errors": [
- { "line": 18, "rule": "allow-custom-anchors" }
- ]
-}
-```
-
## Bad Anchor ID
An anchor with an id that is not in the configured pattern list.
diff --git a/md_test_files/negative_anchor_multiple.md b/md_test_files/negative_anchor_multiple.md
index b45cb2b..593eb41 100644
--- a/md_test_files/negative_anchor_multiple.md
+++ b/md_test_files/negative_anchor_multiple.md
@@ -4,16 +4,6 @@ Lint: `npx markdownlint-cli2 md_test_files/negative_anchor_multiple.md`
Expect: allow-custom-anchors (only one anchor per line), MD032 (blanks around lists).
-```markdownlint-expect
-{
- "total": 2,
- "errors": [
- { "line": 20, "rule": "allow-custom-anchors" },
- { "line": 20, "rule": "MD032/blanks-around-lists" }
- ]
-}
-```
-
## Multiple Anchors per Line
This should be rejected because only one anchor is allowed per line.
diff --git a/md_test_files/negative_anchor_ref_placement.md b/md_test_files/negative_anchor_ref_placement.md
index 3db5255..2d4ec3d 100644
--- a/md_test_files/negative_anchor_ref_placement.md
+++ b/md_test_files/negative_anchor_ref_placement.md
@@ -4,16 +4,6 @@ Lint: `npx markdownlint-cli2 md_test_files/negative_anchor_ref_placement.md`
Expect: allow-custom-anchors (ref anchor placement), MD031 (blanks around fences).
-```markdownlint-expect
-{
- "total": 2,
- "errors": [
- { "line": 20, "rule": "allow-custom-anchors" },
- { "line": 21, "rule": "MD031/blanks-around-fences" }
- ]
-}
-```
-
## Bad Reference Anchor Placement
This should be rejected because reference anchors must be on their own line directly above a fenced code block.
diff --git a/md_test_files/negative_anchor_spec_placement.md b/md_test_files/negative_anchor_spec_placement.md
index 7ebf36a..2ba675d 100644
--- a/md_test_files/negative_anchor_spec_placement.md
+++ b/md_test_files/negative_anchor_spec_placement.md
@@ -4,15 +4,6 @@ Lint: `npx markdownlint-cli2 md_test_files/negative_anchor_spec_placement.md`
Expect: allow-custom-anchors (spec anchor must be on Spec ID list item line).
-```markdownlint-expect
-{
- "total": 1,
- "errors": [
- { "line": 19, "rule": "allow-custom-anchors" }
- ]
-}
-```
-
## Bad Spec Anchor Placement
This should be rejected because spec anchors must be on the Spec ID list item line.
diff --git a/md_test_files/negative_ascii_only.md b/md_test_files/negative_ascii_only.md
index a90615c..c50c1dd 100644
--- a/md_test_files/negative_ascii_only.md
+++ b/md_test_files/negative_ascii_only.md
@@ -2,20 +2,6 @@
This file intentionally contains non-ASCII characters to trigger the ascii-only rule.
-```markdownlint-expect
-{
- "total": 6,
- "errors": [
- { "line": 21, "rule": "ascii-only", "column": 32 },
- { "line": 22, "rule": "ascii-only", "column": 27 },
- { "line": 22, "rule": "ascii-only", "column": 33 },
- { "line": 22, "rule": "ascii-only", "column": 39 },
- { "line": 22, "rule": "ascii-only", "column": 45 },
- { "line": 24, "rule": "ascii-only", "column": 42 }
- ]
-}
-```
-
## ASCII-Only Tests
- Line with Unicode arrow: use → here (should highlight only the arrow).
diff --git a/md_test_files/negative_duplicate_headings_normalized.md b/md_test_files/negative_duplicate_headings_normalized.md
index 4b64e55..b18d5b1 100644
--- a/md_test_files/negative_duplicate_headings_normalized.md
+++ b/md_test_files/negative_duplicate_headings_normalized.md
@@ -2,20 +2,6 @@
Expect: `no-duplicate-headings-normalized` (same title after stripping numbering/normalization).
-```markdownlint-expect
-{
- "total": 6,
- "errors": [
- { "line": 23, "rule": "heading-numbering" },
- { "line": 23, "rule": "MD024/no-duplicate-heading" },
- { "line": 23, "rule": "no-duplicate-headings-normalized" },
- { "line": 27, "rule": "heading-numbering" },
- { "line": 31, "rule": "heading-numbering" },
- { "line": 31, "rule": "no-duplicate-headings-normalized" }
- ]
-}
-```
-
## 1. Introduction
First occurrence.
diff --git a/md_test_files/negative_heading_like.md b/md_test_files/negative_heading_like.md
index 3cbffdd..6a55fe7 100644
--- a/md_test_files/negative_heading_like.md
+++ b/md_test_files/negative_heading_like.md
@@ -1,18 +1,15 @@
-# Negative Fixture: Heading-like Lines
+# Negative Fixture: Heading-Like Lines
Lint: `npx markdownlint-cli2 md_test_files/negative_heading_like.md`
Expect: `no-heading-like-lines`.
-```markdownlint-expect
-{
- "total": 1,
- "errors": [
- { "line": 18, "rule": "no-heading-like-lines" }
- ]
-}
-```
-
-## Pseudo-headings
+## Pseudo-Headings
**This looks like a heading:**
+
+*Italic with colon inside:*
+
+*Italic with colon outside*:
+
+1. **Numbered list with bold only**
diff --git a/md_test_files/negative_heading_like_variants.md b/md_test_files/negative_heading_like_variants.md
deleted file mode 100644
index 023e0ba..0000000
--- a/md_test_files/negative_heading_like_variants.md
+++ /dev/null
@@ -1,22 +0,0 @@
-# Negative Fixture: Heading-like Line Variants
-
-Expect: `no-heading-like-lines` (italic and numbered-list variants).
-
-```markdownlint-expect
-{
- "total": 3,
- "errors": [
- { "line": 18, "rule": "no-heading-like-lines" },
- { "line": 20, "rule": "no-heading-like-lines" },
- { "line": 22, "rule": "no-heading-like-lines" }
- ]
-}
-```
-
-## Pseudo-headings
-
-*Italic with colon inside:*
-
-*Italic with colon outside*:
-
-1. **Numbered list with bold only**
diff --git a/md_test_files/negative_heading_numbering.md b/md_test_files/negative_heading_numbering.md
index cdcc7a2..c956b25 100644
--- a/md_test_files/negative_heading_numbering.md
+++ b/md_test_files/negative_heading_numbering.md
@@ -4,20 +4,6 @@ Lint: `npx markdownlint-cli2 md_test_files/negative_heading_numbering.md`
Expect: heading-numbering (segment count, sequence, period style, unnumbered sibling). All examples are H3/H4 under one H2 so only this section is affected.
-```markdownlint-expect
-{
- "total": 6,
- "errors": [
- { "line": 27, "rule": "heading-numbering" },
- { "line": 29, "rule": "heading-numbering" },
- { "line": 31, "rule": "heading-numbering" },
- { "line": 31, "rule": "heading-numbering" },
- { "line": 35, "rule": "heading-numbering" },
- { "line": 37, "rule": "heading-numbering" }
- ]
-}
-```
-
## Bad Heading Numbering
### 1. First Section
@@ -26,12 +12,14 @@ Expect: heading-numbering (segment count, sequence, period style, unnumbered sib
#### 1.3 Skip 1.2 (Non-Sequential; Expected 1.2)
+#### 1.3. Has Period but Should Not
+
### Unnumbered Sibling (Section Uses Numbering; Add Number Prefix)
-### 2 No Period (Inconsistent with ### 1.)
+### 2 No Period (Inconsistent With ### 1.)
Wrong segment count: H3 under H2 must have 1 segment, not 2.
-### 1.1. Wrong Segment Count
+### 1.1 Wrong Segment Count
-### 1.1. Too Many Segments for H3
+#### 1.1.1 Too Many Segments for H4
diff --git a/md_test_files/negative_heading_title_case.md b/md_test_files/negative_heading_title_case.md
index b49dfb1..04a3f98 100644
--- a/md_test_files/negative_heading_title_case.md
+++ b/md_test_files/negative_heading_title_case.md
@@ -4,19 +4,6 @@ Lint: `npx markdownlint-cli2 md_test_files/negative_heading_title_case.md`
Expect: `heading-title-case` (first word lowercase; middle "and" capitalized; last word lowercase).
-```markdownlint-expect
-{
- "total": 5,
- "errors": [
- { "line": 20, "rule": "heading-title-case" },
- { "line": 24, "rule": "heading-title-case" },
- { "line": 28, "rule": "heading-title-case" },
- { "line": 32, "rule": "heading-title-case" },
- { "line": 36, "rule": "heading-title-case" }
- ]
-}
-```
-
## getting started
Lowercase first word.
@@ -36,3 +23,15 @@ This should fail: first word inside parentheses should be capitalized (treated a
## Using Tools (in practice) Again
This should fail: "in" should be capitalized (first word inside parentheses) and "practice" should be capitalized (major word).
+
+## is This Going to Catch
+
+First word "is" should be capitalized.
+
+## One-stop Shop
+
+Second segment "stop" in hyphenated word should be capitalized (AP: major word).
+
+## Overview: the Basics
+
+First word after colon "the" should be capitalized (AP: subphrase start).
diff --git a/md_test_files/negative_inline_html.md b/md_test_files/negative_inline_html.md
index 9d48230..05e8a69 100644
--- a/md_test_files/negative_inline_html.md
+++ b/md_test_files/negative_inline_html.md
@@ -4,18 +4,6 @@ Lint: `npx markdownlint-cli2 md_test_files/negative_inline_html.md`
Expect: MD033, allow-custom-anchors (wrong attribute, bad id, not end-of-line).
-```markdownlint-expect
-{
- "total": 4,
- "errors": [
- { "line": 22, "rule": "MD033/no-inline-html" },
- { "line": 25, "rule": "allow-custom-anchors" },
- { "line": 28, "rule": "allow-custom-anchors" },
- { "line": 30, "rule": "allow-custom-anchors" }
- ]
-}
-```
-
## Bad Inline HTML and Anchors
This should fail MD033 (inline HTML).
diff --git a/md_test_files/positive.md b/md_test_files/positive.md
index b82ad68..eb5322e 100644
--- a/md_test_files/positive.md
+++ b/md_test_files/positive.md
@@ -4,13 +4,6 @@ This file contains examples that should pass the Markdown standards enforced by
It is intended to be linted explicitly via `npx markdownlint-cli2 md_test_files/positive.md`.
-```markdownlint-expect
-{
- "total": 0,
- "errors": []
-}
-```
-
## Formatting
This paragraph has one sentence.
@@ -91,3 +84,11 @@ This heading demonstrates that leading/trailing punctuation like parentheses doe
### 3. Using Tools (In Practice)
This heading demonstrates that the first word inside parentheses is treated as a new sentence start for title case.
+
+### 4. How to Do a Follow-Up
+
+AP style: hyphenated compounds (each segment capitalized); "to" and "a" lowercase in the middle.
+
+### 5. Summary: The Results
+
+AP style: first word after a colon is capitalized (subphrase start).
diff --git a/package-lock.json b/package-lock.json
index df4ecf5..1678521 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,12 +7,23 @@
"name": "docs-as-code-tools",
"devDependencies": {
"@eslint/js": "^9.0.0",
+ "c8": "^10.1.2",
"eslint": "^9.15.0",
"eslint-plugin-security": "^3.0.1",
"globals": "^15.0.0",
"markdownlint-cli2": "^0.14.0"
}
},
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@@ -222,6 +233,62 @@
"url": "https://github.com/sponsors/nzakas"
}
},
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+ "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@istanbuljs/schema": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
+ "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -260,6 +327,17 @@
"node": ">= 8"
}
},
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/@sindresorhus/merge-streams": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz",
@@ -280,6 +358,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/istanbul-lib-coverage": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
+ "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -327,6 +412,19 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -381,6 +479,40 @@
"node": ">=8"
}
},
+ "node_modules/c8": {
+ "version": "10.1.3",
+ "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz",
+ "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.1",
+ "@istanbuljs/schema": "^0.1.3",
+ "find-up": "^5.0.0",
+ "foreground-child": "^3.1.1",
+ "istanbul-lib-coverage": "^3.2.0",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.1.6",
+ "test-exclude": "^7.0.1",
+ "v8-to-istanbul": "^9.0.0",
+ "yargs": "^17.7.2",
+ "yargs-parser": "^21.1.1"
+ },
+ "bin": {
+ "c8": "bin/c8.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "monocart-coverage-reports": "^2"
+ },
+ "peerDependenciesMeta": {
+ "monocart-coverage-reports": {
+ "optional": true
+ }
+ }
+ },
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -408,6 +540,84 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/cliui/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cliui/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -435,6 +645,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -475,6 +692,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@@ -488,6 +719,16 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -796,6 +1037,55 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/foreground-child": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+ "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "cross-spawn": "^7.0.6",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/glob": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -809,6 +1099,32 @@
"node": ">=10.13.0"
}
},
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/globals": {
"version": "15.15.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
@@ -853,6 +1169,13 @@
"node": ">=8"
}
},
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -900,6 +1223,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -930,6 +1263,61 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/jackspeak": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
+ "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/cliui": "^8.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ },
+ "optionalDependencies": {
+ "@pkgjs/parseargs": "^0.11.0"
+ }
+ },
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
@@ -1028,6 +1416,29 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/markdown-it": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
@@ -1170,6 +1581,16 @@
"node": "*"
}
},
+ "node_modules/minipass": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
+ "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1234,6 +1655,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0"
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -1267,6 +1695,23 @@
"node": ">=8"
}
},
+ "node_modules/path-scurry": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^10.2.0",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/path-type": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz",
@@ -1354,6 +1799,16 @@
"regexp-tree": "bin/regexp-tree"
}
},
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -1409,6 +1864,19 @@
"regexp-tree": "~0.1.1"
}
},
+ "node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -1432,6 +1900,19 @@
"node": ">=8"
}
},
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/slash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
@@ -1445,6 +1926,110 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/string-width-cjs": {
+ "name": "string-width",
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string-width-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
+ "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/strip-ansi-cjs": {
+ "name": "strip-ansi",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -1471,6 +2056,47 @@
"node": ">=8"
}
},
+ "node_modules/test-exclude": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
+ "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@istanbuljs/schema": "^0.1.2",
+ "glob": "^10.4.1",
+ "minimatch": "^9.0.4"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/test-exclude/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/test-exclude/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -1527,6 +2153,21 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/v8-to-istanbul": {
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
+ "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.12",
+ "@types/istanbul-lib-coverage": "^2.0.1",
+ "convert-source-map": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10.12.0"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -1553,6 +2194,185 @@
"node": ">=0.10.0"
}
},
+ "node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs": {
+ "name": "wrap-ansi",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/yargs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
diff --git a/package.json b/package.json
index f2b2de4..6a34cb0 100644
--- a/package.json
+++ b/package.json
@@ -3,13 +3,27 @@
"private": true,
"description": "Docs-as-code tooling: custom markdownlint rules and lint/test helpers.",
"scripts": {
- "test:rules": "node --test 'test/markdownlint-rules/*.test.js'"
+ "test": "npm run test:rules:coverage",
+ "test:rules": "node --test test/markdownlint-rules/*.test.js",
+ "test:rules:coverage": "c8 --per-file node --test test/markdownlint-rules/*.test.js"
},
"devDependencies": {
"@eslint/js": "^9.0.0",
+ "c8": "^10.1.2",
"eslint": "^9.15.0",
"eslint-plugin-security": "^3.0.1",
"globals": "^15.0.0",
"markdownlint-cli2": "^0.14.0"
+ },
+ "c8": {
+ "include": [".markdownlint-rules/**/*.js"],
+ "exclude": ["test/**"],
+ "reporter": ["text", "lcov"],
+ "all": true,
+ "lines": 90,
+ "statements": 90,
+ "branches": 90,
+ "check-coverage": true,
+ "per-file": true
}
}
diff --git a/test-scripts/README.md b/test-scripts/README.md
index c66d4f7..d3439a8 100644
--- a/test-scripts/README.md
+++ b/test-scripts/README.md
@@ -5,7 +5,7 @@ This directory contains Python scripts used to support this repository's test su
## Scripts
- `verify_markdownlint_fixtures.py`
- - Verifies markdownlint test fixtures in `md_test_files/` against their embedded `markdownlint-expect` blocks.
+ - Verifies markdownlint test fixtures in `md_test_files/` against expectations in `md_test_files/expected_errors.yml`.
- Used by `make test-markdownlint` and the `markdownlint-tests` GitHub Actions workflow.
- `test_verify_markdownlint_fixtures.py`
- Unit tests for the fixture verifier (run via `make test-python`).
@@ -21,6 +21,8 @@ This directory contains Python scripts used to support this repository's test su
`make test-markdownlint`
+ Use `VERBOSE=1` to print each fixture as it is verified: `make test-markdownlint VERBOSE=1`.
+
- Run Python unit tests (test-scripts/test_*.py):
`make test-python`
diff --git a/test-scripts/requirements-lint.txt b/test-scripts/requirements-lint.txt
index 13f49d3..1782d45 100644
--- a/test-scripts/requirements-lint.txt
+++ b/test-scripts/requirements-lint.txt
@@ -1,3 +1,5 @@
+PyYAML
+coverage
flake8
pylint
radon
diff --git a/test-scripts/test_verify_markdownlint_fixtures.py b/test-scripts/test_verify_markdownlint_fixtures.py
index d47d86c..318977d 100644
--- a/test-scripts/test_verify_markdownlint_fixtures.py
+++ b/test-scripts/test_verify_markdownlint_fixtures.py
@@ -2,7 +2,7 @@
"""
Unit tests for verify_markdownlint_fixtures.py.
-Tests parsing of markdownlint-expect blocks, markdownlint output parsing,
+Tests parsing of expectations from YAML/data, markdownlint output parsing,
and multiset comparison. Does not require markdownlint-cli2 except for
integration-style tests (skipped when unavailable).
"""
@@ -49,6 +49,12 @@ def test_first_element_is_executable_name(self):
cmd = v.find_markdownlint_cmd()
self.assertTrue(cmd[0].endswith("markdownlint-cli2") or cmd[0] == "npx")
+ def test_returns_npx_when_local_missing(self):
+ """When node_modules binary does not exist, returns npx command."""
+ with patch.object(v, "repo_root", return_value=Path("/nonexistent_repo")):
+ cmd = v.find_markdownlint_cmd()
+ self.assertEqual(cmd, ["npx", "markdownlint-cli2"])
+
class TestListFixtureFiles(unittest.TestCase):
"""Tests for list_fixture_files()."""
@@ -71,187 +77,134 @@ def test_rest_are_negative_md(self):
self.assertTrue(f.name.endswith(".md"))
-class TestParseExpectations(unittest.TestCase):
- """Tests for parse_expectations()."""
-
- def _parse(self, markdown: str, path: str = "test.md") -> tuple:
- return v.parse_expectations(markdown, Path(path))
+class TestLoadExpectedErrors(unittest.TestCase):
+ """Tests for load_expected_errors()."""
- def test_valid_block_returns_total_and_errors(self):
- md = """
-# Doc
-Content
+ def test_missing_file_raises(self):
+ with self.assertRaises(FileNotFoundError):
+ v.load_expected_errors(Path("/nonexistent/expected_errors.yml"))
-```markdownlint-expect
-{"total": 1, "errors": [{"line": 2, "rule": "MD001"}]}
-```
-"""
- total, errors = self._parse(md)
+ def test_valid_yaml_returns_dict(self):
+ path = _REPO_ROOT / "md_test_files" / "expected_errors.yml"
+ if not path.exists():
+ self.skipTest("expected_errors.yml not found")
+ data = v.load_expected_errors(path)
+ self.assertIsInstance(data, dict)
+ self.assertIn("positive.md", data)
+
+ def test_invalid_yaml_raises(self):
+ import tempfile
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f:
+ f.write("not: valid: yaml: [")
+ f.flush()
+ try:
+ with self.assertRaises(ValueError) as ctx:
+ v.load_expected_errors(Path(f.name))
+ self.assertIn("Invalid YAML", str(ctx.exception))
+ finally:
+ Path(f.name).unlink(missing_ok=True)
+
+ def test_yaml_not_dict_raises(self):
+ import tempfile
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f:
+ f.write("- list\n- not dict\n")
+ f.flush()
+ try:
+ with self.assertRaises(ValueError) as ctx:
+ v.load_expected_errors(Path(f.name))
+ self.assertIn("must be a YAML object", str(ctx.exception))
+ finally:
+ Path(f.name).unlink(missing_ok=True)
+
+
+class TestParseExpectationsFromData(unittest.TestCase):
+ """Tests for parse_expectations_from_data()."""
+
+ def _parse(self, data: dict, path: str = "test.md") -> tuple:
+ return v.parse_expectations_from_data(data, Path(path))
+
+ def test_valid_data_returns_total_and_errors(self):
+ data = {"errors": [{"line": 2, "rule": "MD001"}]}
+ total, errors = self._parse(data)
self.assertEqual(total, 1)
self.assertEqual(len(errors), 1)
self.assertEqual(errors[0].line, 2)
self.assertEqual(errors[0].rule, "MD001")
def test_zero_errors(self):
- md = """
-# Doc
-```markdownlint-expect
-{"total": 0, "errors": []}
-```
-"""
- total, errors = self._parse(md)
+ data = {"errors": []}
+ total, errors = self._parse(data)
self.assertEqual(total, 0)
self.assertEqual(errors, [])
def test_multiple_errors_same_line(self):
- md = """
-x
-```markdownlint-expect
-{"total": 2, "errors": [{"line": 1, "rule": "A"}, {"line": 1, "rule": "B"}]}
-```
-"""
- total, errors = self._parse(md)
+ data = {
+ "errors": [{"line": 1, "rule": "A"}, {"line": 1, "rule": "B"}],
+ }
+ total, errors = self._parse(data)
self.assertEqual(total, 2)
self.assertEqual([e.rule for e in errors], ["A", "B"])
- def test_missing_block_raises(self):
- md = "# No expect block"
- with self.assertRaises(ValueError) as ctx:
- self._parse(md)
- self.assertIn("Missing", str(ctx.exception))
-
- def test_unterminated_block_raises(self):
- md = """
-# Doc
-```markdownlint-expect
-{"total": 0, "errors": []}
-"""
+ def test_data_not_dict_raises(self):
with self.assertRaises(ValueError) as ctx:
- self._parse(md)
- self.assertIn("Unterminated", str(ctx.exception))
-
- def test_invalid_json_raises(self):
- md = """
-# Doc
-```markdownlint-expect
-{ total: 0 }
-```
-"""
- with self.assertRaises(ValueError) as ctx:
- self._parse(md)
- self.assertIn("Invalid JSON", str(ctx.exception))
-
- def test_total_not_int_raises(self):
- md = """
-# Doc
-```markdownlint-expect
-{"total": "0", "errors": []}
-```
-"""
- with self.assertRaises(ValueError) as ctx:
- self._parse(md)
- self.assertIn("total", str(ctx.exception))
-
- def test_total_negative_raises(self):
- md = """
-# Doc
-```markdownlint-expect
-{"total": -1, "errors": []}
-```
-"""
- with self.assertRaises(ValueError) as ctx:
- self._parse(md)
- self.assertIn("total", str(ctx.exception))
+ self._parse([])
+ self.assertIn("must be an object", str(ctx.exception))
def test_errors_not_list_raises(self):
- md = """
-# Doc
-```markdownlint-expect
-{"total": 0, "errors": {}}
-```
-"""
with self.assertRaises(ValueError) as ctx:
- self._parse(md)
+ self._parse({"errors": {}})
self.assertIn("errors", str(ctx.exception))
def test_error_item_not_dict_raises(self):
- md = """
-# Doc
-```markdownlint-expect
-{"total": 1, "errors": ["not an object"]}
-```
-"""
with self.assertRaises(ValueError) as ctx:
- self._parse(md)
+ self._parse({"errors": ["not an object"]})
self.assertIn("errors[0]", str(ctx.exception))
def test_error_missing_line_or_rule_raises(self):
- md = """
-# Doc
-```markdownlint-expect
-{"total": 1, "errors": [{"line": 1}]}
-```
-"""
with self.assertRaises(ValueError) as ctx:
- self._parse(md)
+ self._parse({"errors": [{"line": 1}]})
self.assertIn("errors[0]", str(ctx.exception))
def test_error_line_below_one_raises(self):
- md = """
-# Doc
-```markdownlint-expect
-{"total": 1, "errors": [{"line": 0, "rule": "X"}]}
-```
-"""
with self.assertRaises(ValueError) as ctx:
- self._parse(md)
+ self._parse({"errors": [{"line": 0, "rule": "X"}]})
self.assertIn("errors[0]", str(ctx.exception))
- def test_total_mismatch_len_raises(self):
- md = """
-# Doc
-```markdownlint-expect
-{"total": 2, "errors": [{"line": 1, "rule": "X"}]}
-```
-"""
- with self.assertRaises(ValueError) as ctx:
- self._parse(md)
- self.assertIn("total", str(ctx.exception))
- self.assertIn("errors.length", str(ctx.exception))
-
def test_rule_stripped_of_whitespace(self):
- md = """
-# Doc
-```markdownlint-expect
-{"total": 1, "errors": [{"line": 1, "rule": " MD001 "}]}
-```
-"""
- _, errors = self._parse(md)
+ data = {"errors": [{"line": 1, "rule": " MD001 "}]}
+ _, errors = self._parse(data)
self.assertEqual(errors[0].rule, "MD001")
def test_optional_column_parsed(self):
- md = """
-# Doc
-```markdownlint-expect
-{"total": 1, "errors": [{"line": 2, "rule": "ascii-only", "column": 32}]}
-```
-"""
- _, errors = self._parse(md)
+ data = {
+ "errors": [{"line": 2, "rule": "ascii-only", "column": 32}],
+ }
+ _, errors = self._parse(data)
self.assertEqual(errors[0].line, 2)
self.assertEqual(errors[0].rule, "ascii-only")
self.assertEqual(errors[0].column, 32)
def test_error_column_below_one_raises(self):
- md = """
-# Doc
-```markdownlint-expect
-{"total": 1, "errors": [{"line": 1, "rule": "X", "column": 0}]}
-```
-"""
with self.assertRaises(ValueError) as ctx:
- self._parse(md)
+ self._parse({"errors": [{"line": 1, "rule": "X", "column": 0}]})
self.assertIn("column", str(ctx.exception))
+ def test_message_contains_parsed(self):
+ data = {
+ "errors": [
+ {"line": 1, "rule": "X", "message_contains": "expected substring"},
+ ],
+ }
+ _, errors = self._parse(data)
+ self.assertEqual(errors[0].message_contains, "expected substring")
+
+ def test_message_contains_not_string_raises(self):
+ with self.assertRaises(ValueError) as ctx:
+ self._parse({
+ "errors": [{"line": 1, "rule": "X", "message_contains": 123}],
+ })
+ self.assertIn("message_contains", str(ctx.exception))
+
class TestParseMarkdownlintOutput(unittest.TestCase):
"""Tests for parse_markdownlint_output()."""
@@ -278,10 +231,26 @@ def test_parses_column_format(self):
self.assertEqual(errors[0].line, 5)
self.assertEqual(errors[0].column, 3)
+ def test_parses_message(self):
+ output = (
+ 'md_test_files/foo.md:7:4 heading-title-case '
+ '[Word "getting" should be capitalized.]'
+ )
+ errors = v.parse_markdownlint_output(output, "md_test_files/foo.md")
+ self.assertEqual(len(errors), 1)
+ self.assertIn("getting", errors[0].message)
+ self.assertIn("capitalized", errors[0].message)
+
def test_empty_output_returns_empty_list(self):
errors = v.parse_markdownlint_output("", "any.md")
self.assertEqual(errors, [])
+ def test_skips_line_with_prefix_but_no_regex_match(self):
+ """Lines starting with file_label but not matching error regex are skipped."""
+ output = "md_test_files/foo.md: not an error line format\n"
+ errors = v.parse_markdownlint_output(output, "md_test_files/foo.md")
+ self.assertEqual(errors, [])
+
class TestToCountMap(unittest.TestCase):
"""Tests for to_count_map()."""
@@ -299,6 +268,15 @@ def test_counts_duplicates(self):
def test_empty_list_returns_empty_map(self):
self.assertEqual(v.to_count_map([]), {})
+ def test_includes_column_in_key(self):
+ errors = [
+ v.ExpectedError(line=1, rule="R", column=5),
+ v.ExpectedError(line=1, rule="R", column=10),
+ ]
+ m = v.to_count_map(errors)
+ self.assertEqual(m[(1, "R", 5)], 1)
+ self.assertEqual(m[(1, "R", 10)], 1)
+
class TestVerifyFile(unittest.TestCase):
"""Tests for verify_file(): integration (real markdownlint) and exit-code mismatch (mocked)."""
@@ -306,11 +284,13 @@ class TestVerifyFile(unittest.TestCase):
def test_verify_file_positive_integration(self):
"""Run verify_file on positive.md for real; skip if markdownlint unavailable."""
path = _REPO_ROOT / "md_test_files" / "positive.md"
- if not path.exists():
- self.skipTest("positive.md fixture not found")
+ expect_path = _REPO_ROOT / "md_test_files" / "expected_errors.yml"
+ if not path.exists() or not expect_path.exists():
+ self.skipTest("fixtures or expected_errors.yml not found")
cmd = v.find_markdownlint_cmd()
+ expectations = v.load_expected_errors(expect_path)
try:
- v.verify_file(cmd, path)
+ v.verify_file(cmd, path, expectations)
except (OSError, FileNotFoundError) as e:
self.skipTest(f"markdownlint not runnable: {e}")
@@ -319,6 +299,7 @@ def test_verify_file_raises_when_exit_code_mismatch(self):
path = _REPO_ROOT / "md_test_files" / "positive.md"
if not path.exists():
self.skipTest("positive.md fixture not found")
+ expectations = {"positive.md": {"errors": []}}
with patch("verify_markdownlint_fixtures.subprocess.run") as run:
run.return_value = type(
"Result",
@@ -326,9 +307,252 @@ def test_verify_file_raises_when_exit_code_mismatch(self):
{"returncode": 1, "stdout": "", "stderr": "error"},
)()
with self.assertRaises(AssertionError) as ctx:
- v.verify_file(["markdownlint-cli2"], path)
+ v.verify_file(["markdownlint-cli2"], path, expectations)
self.assertIn("exit code", str(ctx.exception))
+ def test_verify_file_raises_when_message_contains_mismatch(self):
+ """When message_contains is specified but actual message does not contain it."""
+ path = _REPO_ROOT / "md_test_files" / "positive.md"
+ if not path.exists():
+ self.skipTest("positive.md fixture not found")
+ expectations = {
+ "positive.md": {
+ "errors": [
+ {
+ "line": 1,
+ "rule": "MD001",
+ "message_contains": "this string is not in the output",
+ },
+ ],
+ },
+ }
+ output = "md_test_files/positive.md:1 MD001 Some other message\n"
+ with patch("verify_markdownlint_fixtures.subprocess.run") as run:
+ run.return_value = type(
+ "Result",
+ (),
+ {"returncode": 1, "stdout": output, "stderr": ""},
+ )()
+ with self.assertRaises(AssertionError) as ctx:
+ v.verify_file(["markdownlint-cli2"], path, expectations)
+ self.assertIn("message", str(ctx.exception).lower())
+ self.assertIn("message_contains", str(ctx.exception))
+
+ def test_verify_file_raises_when_no_expectations_for_file(self):
+ path = _REPO_ROOT / "md_test_files" / "positive.md"
+ if not path.exists():
+ self.skipTest("positive.md fixture not found")
+ with self.assertRaises(ValueError) as ctx:
+ v.verify_file(["mdl"], path, {})
+ self.assertIn("No expectations", str(ctx.exception))
+
+ def test_verify_file_raises_when_expect_errors_got_zero_exit(self):
+ """Expect non-zero errors but subprocess returns 0."""
+ path = _REPO_ROOT / "md_test_files" / "positive.md"
+ if not path.exists():
+ self.skipTest("positive.md fixture not found")
+ expectations = {"positive.md": {"errors": [{"line": 1, "rule": "X"}]}}
+ with patch("verify_markdownlint_fixtures.subprocess.run") as run:
+ run.return_value = type(
+ "Result",
+ (),
+ {"returncode": 0, "stdout": "", "stderr": ""},
+ )()
+ with self.assertRaises(AssertionError) as ctx:
+ v.verify_file(["markdownlint-cli2"], path, expectations)
+ self.assertIn("exit code", str(ctx.exception))
+
+ def test_verify_file_raises_when_error_count_mismatch(self):
+ """Expected 1 error but got 2 in output."""
+ path = _REPO_ROOT / "md_test_files" / "positive.md"
+ if not path.exists():
+ self.skipTest("positive.md fixture not found")
+ expectations = {"positive.md": {"errors": [{"line": 1, "rule": "X"}]}}
+ output = (
+ "md_test_files/positive.md:1 X msg1\n"
+ "md_test_files/positive.md:2 X msg2\n"
+ )
+ with patch("verify_markdownlint_fixtures.subprocess.run") as run:
+ run.return_value = type(
+ "Result",
+ (),
+ {"returncode": 1, "stdout": output, "stderr": ""},
+ )()
+ with self.assertRaises(AssertionError) as ctx:
+ v.verify_file(["markdownlint-cli2"], path, expectations)
+ self.assertIn("error count", str(ctx.exception))
+
+ def test_verify_file_raises_when_line_rule_count_mismatch(self):
+ """Expected 2 errors on same line/rule but got 1 at line 1 and 1 at line 2."""
+ path = _REPO_ROOT / "md_test_files" / "positive.md"
+ if not path.exists():
+ self.skipTest("positive.md fixture not found")
+ expectations = {
+ "positive.md": {
+ "errors": [
+ {"line": 1, "rule": "X"},
+ {"line": 1, "rule": "X"},
+ ],
+ },
+ }
+ output = (
+ "md_test_files/positive.md:1 X msg1\n"
+ "md_test_files/positive.md:2 X msg2\n"
+ )
+ with patch("verify_markdownlint_fixtures.subprocess.run") as run:
+ run.return_value = type(
+ "Result",
+ (),
+ {"returncode": 1, "stdout": output, "stderr": ""},
+ )()
+ with self.assertRaises(AssertionError) as ctx:
+ v.verify_file(["markdownlint-cli2"], path, expectations)
+ self.assertIn("expected 2, got 1", str(ctx.exception))
+
+ def test_verify_file_raises_when_column_count_mismatch(self):
+ """Expected 2 errors at column 5 but actual has one at 5 and one at 10."""
+ path = _REPO_ROOT / "md_test_files" / "positive.md"
+ if not path.exists():
+ self.skipTest("positive.md fixture not found")
+ expectations = {
+ "positive.md": {
+ "errors": [
+ {"line": 1, "rule": "X", "column": 5},
+ {"line": 1, "rule": "X", "column": 5},
+ ],
+ },
+ }
+ output = (
+ "md_test_files/positive.md:1:5 X msg1\n"
+ "md_test_files/positive.md:1:10 X msg2\n"
+ )
+ with patch("verify_markdownlint_fixtures.subprocess.run") as run:
+ run.return_value = type(
+ "Result",
+ (),
+ {"returncode": 1, "stdout": output, "stderr": ""},
+ )()
+ with self.assertRaises(AssertionError) as ctx:
+ v.verify_file(["markdownlint-cli2"], path, expectations)
+ self.assertIn("column", str(ctx.exception))
+
+ def test_verify_file_raises_when_expected_line_rule_not_in_actual(self):
+ """Expected at line 99 rule X but actual has only line 1 (line/rule multiset mismatch)."""
+ path = _REPO_ROOT / "md_test_files" / "positive.md"
+ if not path.exists():
+ self.skipTest("positive.md fixture not found")
+ expectations = {
+ "positive.md": {
+ "errors": [
+ {"line": 99, "rule": "X", "message_contains": "needle"},
+ ],
+ },
+ }
+ output = "md_test_files/positive.md:1 X msg\n"
+ with patch("verify_markdownlint_fixtures.subprocess.run") as run:
+ run.return_value = type(
+ "Result",
+ (),
+ {"returncode": 1, "stdout": output, "stderr": ""},
+ )()
+ with self.assertRaises(AssertionError) as ctx:
+ v.verify_file(["markdownlint-cli2"], path, expectations)
+ self.assertIn("expected 0, got 1", str(ctx.exception))
+
+ def test_verify_file_combines_stdout_and_stderr(self):
+ """verify_file uses combined stdout+stderr for parsing."""
+ path = _REPO_ROOT / "md_test_files" / "positive.md"
+ if not path.exists():
+ self.skipTest("positive.md fixture not found")
+ expectations = {"positive.md": {"errors": [{"line": 1, "rule": "X"}]}}
+ with patch("verify_markdownlint_fixtures.subprocess.run") as run:
+ run.return_value = type(
+ "Result",
+ (),
+ {
+ "returncode": 1,
+ "stdout": "md_test_files/positive.md:1 X msg\n",
+ "stderr": "",
+ },
+ )()
+ v.verify_file(["markdownlint-cli2"], path, expectations)
+ with patch("verify_markdownlint_fixtures.subprocess.run") as run:
+ run.return_value = type(
+ "Result",
+ (),
+ {
+ "returncode": 1,
+ "stdout": "",
+ "stderr": "md_test_files/positive.md:1 X msg\n",
+ },
+ )()
+ v.verify_file(["markdownlint-cli2"], path, expectations)
+
+
+class TestMain(unittest.TestCase):
+ """Tests for main()."""
+
+ def test_main_returns_zero_when_all_pass(self):
+ with patch("sys.argv", ["prog"]):
+ with patch.object(v, "find_markdownlint_cmd") as cmd:
+ with patch.object(v, "list_fixture_files") as files:
+ with patch.object(v, "load_expected_errors") as load:
+ with patch.object(v, "verify_file"):
+ cmd.return_value = ["markdownlint-cli2"]
+ expect_path = _REPO_ROOT / "md_test_files" / "expected_errors.yml"
+ if not expect_path.exists():
+ self.skipTest("expected_errors.yml not found")
+ load.return_value = {
+ p.name: {"errors": []} for p in v.list_fixture_files()
+ }
+ files.return_value = [
+ _REPO_ROOT / "md_test_files" / "positive.md",
+ ]
+ result = v.main()
+ self.assertEqual(result, 0)
+
+ def test_main_prints_success_message(self):
+ with patch("sys.argv", ["prog"]):
+ with patch.object(v, "find_markdownlint_cmd", return_value=["markdownlint-cli2"]):
+ with patch.object(v, "list_fixture_files") as files:
+ with patch.object(v, "load_expected_errors") as load:
+ with patch.object(v, "verify_file"):
+ expect_path = _REPO_ROOT / "md_test_files" / "expected_errors.yml"
+ if not expect_path.exists():
+ self.skipTest("expected_errors.yml not found")
+ files.return_value = [_REPO_ROOT / "md_test_files" / "positive.md"]
+ load.return_value = {"positive.md": {"errors": []}}
+ with patch("sys.stdout") as mock_stdout:
+ self.assertEqual(v.main(), 0)
+ mock_stdout.write.assert_called()
+ out = "".join(c[0][0] for c in mock_stdout.write.call_args_list)
+ self.assertIn("All markdownlint", out)
+
+ def test_main_returns_one_on_failure(self):
+ with patch("sys.argv", ["prog"]):
+ with patch.object(v, "find_markdownlint_cmd", return_value=["markdownlint-cli2"]):
+ with patch.object(v, "list_fixture_files") as files:
+ with patch.object(v, "load_expected_errors") as load:
+ with patch.object(v, "verify_file", side_effect=AssertionError("fail")):
+ files.return_value = [_REPO_ROOT / "md_test_files" / "positive.md"]
+ load.return_value = {"positive.md": {"errors": []}}
+ result = v.main()
+ self.assertEqual(result, 1)
+
+ def test_main_verbose_writes_per_fixture(self):
+ with patch.object(v, "find_markdownlint_cmd", return_value=["markdownlint-cli2"]):
+ with patch.object(v, "list_fixture_files") as files:
+ with patch.object(v, "load_expected_errors") as load:
+ with patch.object(v, "verify_file"):
+ files.return_value = [_REPO_ROOT / "md_test_files" / "positive.md"]
+ load.return_value = {"positive.md": {"errors": []}}
+ with patch("sys.argv", ["prog", "--verbose"]):
+ with patch("sys.stderr") as mock_stderr:
+ v.main()
+ self.assertGreater(mock_stderr.write.call_count, 0)
+ calls = "".join(c[0][0] for c in mock_stderr.write.call_args_list)
+ self.assertIn("Verifying", calls)
+
if __name__ == "__main__":
unittest.main()
diff --git a/test-scripts/verify_markdownlint_fixtures.py b/test-scripts/verify_markdownlint_fixtures.py
index 1fd141b..16142cc 100644
--- a/test-scripts/verify_markdownlint_fixtures.py
+++ b/test-scripts/verify_markdownlint_fixtures.py
@@ -1,49 +1,46 @@
#!/usr/bin/env python3
"""
-Verify markdownlint fixture expectations embedded in md_test_files/*.md.
-
-Each fixture must contain a trailing fenced code block:
-
-```markdownlint-expect
-{
- "total": 2,
- "errors": [
- { "line": 10, "rule": "MD032/blanks-around-lists" }
- ]
-}
-```
+Verify markdownlint fixture expectations from md_test_files/expected_errors.yml.
+Expected errors are keyed by fixture filename (e.g. positive.md, negative_*.md).
The verifier runs markdownlint-cli2 on each fixture and asserts:
- exit code matches (0 for total=0, non-zero otherwise)
- total error count matches
- the multiset of (line, rule) or (line, rule, column) matches exactly (duplicates allowed).
When "column" is present in an error object, the rule is assumed to report at character level
and the verifier will match actual column numbers from markdownlint output.
+- optionally, each error may specify message_contains: the actual error message (text after
+ the rule name in markdownlint output) must contain that string, so the specific error on
+ each line can be validated.
"""
from __future__ import annotations
-import json
+import argparse
import os
import re
import subprocess # nosec B404 (tooling script runs local commands)
import sys
from dataclasses import dataclass
from pathlib import Path
-from typing import Dict, List, Optional, Tuple
-
+from typing import Any, Dict, List, Optional, Tuple
-EXPECT_FENCE = "```markdownlint-expect"
-FENCE_END = "```"
+import yaml
@dataclass(frozen=True)
class ExpectedError:
- """A single expected markdownlint error: line, rule, and optional column (1-based)."""
+ """
+ A single expected markdownlint error: line, rule, optional column (1-based),
+ and optional message_contains (substring that must appear in the actual error message).
+ When parsed from markdownlint output, message is set to the full message text.
+ """
line: int
rule: str
column: Optional[int] = None
+ message_contains: Optional[str] = None
+ message: Optional[str] = None # Set when parsing actual output; not from YAML
def repo_root() -> Path:
@@ -71,25 +68,6 @@ def list_fixture_files() -> List[Path]:
return [positive, *negatives]
-def _find_expect_block(lines: List[str], file_path: Path) -> Tuple[int, int]:
- """Return (start, end) line indices of the markdownlint-expect block. Raises if missing."""
- start = None
- for i in range(len(lines) - 1, -1, -1):
- if lines[i].strip() == EXPECT_FENCE:
- start = i
- break
- if start is None:
- raise ValueError(f"Missing `{EXPECT_FENCE}` block in {file_path.as_posix()}")
- end = None
- for i in range(start + 1, len(lines)):
- if lines[i].strip() == FENCE_END:
- end = i
- break
- if end is None:
- raise ValueError(f"Unterminated `{EXPECT_FENCE}` block in {file_path.as_posix()}")
- return start, end
-
-
def _parse_one_error(item: object, idx: int, file_path: Path) -> ExpectedError:
"""Parse and validate a single error object from the expectations JSON."""
if not isinstance(item, dict):
@@ -109,59 +87,64 @@ def _parse_one_error(item: object, idx: int, file_path: Path) -> ExpectedError:
f"Invalid errors[{idx}] in {file_path.as_posix()} expectations "
'(optional "column" must be int >= 1).'
)
+ msg_contains = item.get("message_contains")
+ if msg_contains is not None and not isinstance(msg_contains, str):
+ raise ValueError(
+ f"Invalid errors[{idx}] in {file_path.as_posix()} expectations "
+ '(optional "message_contains" must be a string).'
+ )
col = column if isinstance(column, int) else None
- return ExpectedError(line=line, rule=rule.strip(), column=col)
+ msg_c = msg_contains if isinstance(msg_contains, str) else None
+ return ExpectedError(line=line, rule=rule.strip(), column=col, message_contains=msg_c)
-def parse_expectations(markdown: str, file_path: Path) -> Tuple[int, List[ExpectedError]]:
- """
- Parse the trailing ```markdownlint-expect JSON block from markdown.
+def load_expected_errors(expect_path: Path) -> Dict[str, Any]:
+ """Load expected_errors.yml; return dict keyed by fixture filename."""
+ if not expect_path.exists():
+ raise FileNotFoundError(f"Expected errors file not found: {expect_path}")
+ text = expect_path.read_text(encoding="utf-8")
+ try:
+ data = yaml.safe_load(text)
+ except yaml.YAMLError as e:
+ raise ValueError(f"Invalid YAML in {expect_path}: {e}") from e
+ if not isinstance(data, dict):
+ raise ValueError(f"Expected errors file must be a YAML object: {expect_path}")
+ return data
+
- Returns (total, list of ExpectedError). Validates total and errors array shape.
- Raises ValueError if block missing, JSON invalid, or structure invalid.
+def parse_expectations_from_data(
+ data: Any, file_path: Path
+) -> Tuple[int, List[ExpectedError]]:
"""
- lines = markdown.splitlines()
- start, end = _find_expect_block(lines, file_path)
- json_text = "\n".join(lines[start + 1:end]).strip()
- try:
- data = json.loads(json_text)
- except json.JSONDecodeError as e:
- raise ValueError(
- f"Invalid JSON in {file_path.as_posix()} expectations: {e.msg}"
- ) from e
+ Parse expectations from a dict with an "errors" list. Total is derived from len(errors).
- total = data.get("total")
- errors = data.get("errors")
- if not isinstance(total, int) or total < 0:
+ Returns (total, list of ExpectedError). Raises ValueError if structure invalid.
+ """
+ if not isinstance(data, dict):
raise ValueError(
- f'Invalid "total" in {file_path.as_posix()} expectations (must be non-negative int).'
+ f"Expectations for {file_path.as_posix()} must be an object."
)
+ errors = data.get("errors")
if not isinstance(errors, list):
raise ValueError(
f'Invalid "errors" in {file_path.as_posix()} expectations (must be an array).'
)
parsed = [_parse_one_error(item, idx, file_path) for idx, item in enumerate(errors)]
-
- if len(parsed) != total:
- raise ValueError(
- f"Expectation mismatch in {file_path.as_posix()}: total={total} "
- f"but errors.length={len(parsed)}"
- )
- return total, parsed
+ return len(parsed), parsed
_RE_ERROR_LINE = re.compile(
- r"^[^:]+:(\d+)(?::(\d+))?\s+(?:error\s+)?(\S+)\s+"
+ r"^[^:]+:(\d+)(?::(\d+))?\s+(?:error\s+)?(\S+)\s+(.*)$"
)
def parse_markdownlint_output(output: str, file_label: str) -> List[ExpectedError]:
"""
- Parse markdownlint-cli2 stderr/stdout into a list of errors (line, rule, optional column).
+ Parse markdownlint-cli2 stderr/stdout into a list of errors (line, rule, column, message).
Only lines starting with file_label (e.g. "md_test_files/foo.md:") are considered.
- When output is file:line:column rule, column is captured; otherwise column is None.
+ Captures optional column and the rest of the line as the error message.
"""
errors: List[ExpectedError] = []
prefix = file_label + ":"
@@ -172,14 +155,20 @@ def parse_markdownlint_output(output: str, file_label: str) -> List[ExpectedErro
if not m:
continue
column = int(m.group(2)) if m.group(2) else None
+ message = (m.group(4) or "").strip()
errors.append(
- ExpectedError(line=int(m.group(1)), rule=m.group(3), column=column)
+ ExpectedError(
+ line=int(m.group(1)),
+ rule=m.group(3),
+ column=column,
+ message=message,
+ )
)
return errors
def _count_map_key(e: ExpectedError) -> Tuple[int, str, Optional[int]]:
- """Key for count maps: (line, rule, column)."""
+ """Key for count maps: (line, rule, column). Message fields are ignored."""
return (e.line, e.rule, e.column)
@@ -192,14 +181,50 @@ def to_count_map(errors: List[ExpectedError]) -> Dict[Tuple[int, str, Optional[i
return m
-def verify_file(cmd: List[str], file_path: Path) -> None:
+def _assert_message_contains(
+ exp_errors: List[ExpectedError],
+ act_errors: List[ExpectedError],
+ file_label: str,
+ combined: str,
+) -> None:
+ """Raise AssertionError if any expected message_contains is not found in actual messages."""
+ for exp in exp_errors:
+ if exp.message_contains is None:
+ continue
+ candidates = [
+ a for a in act_errors
+ if a.line == exp.line and a.rule == exp.rule
+ and (exp.column is None or a.column == exp.column)
+ ]
+ if not candidates:
+ raise AssertionError(
+ f"Unexpected errors for {file_label}: no actual error at line {exp.line} "
+ f"rule {exp.rule} to check message_contains.\n\nOutput:\n{combined}\n"
+ )
+ if not any(
+ (a.message or "").find(exp.message_contains) >= 0 for a in candidates
+ ):
+ raise AssertionError(
+ f"Unexpected error message for {file_label} at line {exp.line} rule {exp.rule}: "
+ f'message_contains "{exp.message_contains}" not found in actual message. '
+ f"Got: {candidates[0].message!r}\n\nOutput:\n{combined}\n"
+ )
+
+
+def verify_file(
+ cmd: List[str], file_path: Path, expectations_by_file: Dict[str, Any]
+) -> None:
"""
Run markdownlint on one fixture file and assert exit code, count, and (line, rule) multiset.
Raises AssertionError or ValueError on mismatch; OSError/SubprocessError on run failure.
"""
- markdown = file_path.read_text(encoding="utf-8")
- exp_total, exp_errors = parse_expectations(markdown, file_path)
+ key = file_path.name
+ if key not in expectations_by_file:
+ raise ValueError(f"No expectations in expected_errors.yml for {key}")
+ exp_total, exp_errors = parse_expectations_from_data(
+ expectations_by_file[key], file_path
+ )
file_label = file_path.relative_to(repo_root()).as_posix()
proc = subprocess.run(
@@ -260,6 +285,8 @@ def line_rule_pairs(m: Dict[Tuple[int, str, Optional[int]], int]) -> set:
f"Unexpected errors for {file_label}:\n{diff_text}\n\nOutput:\n{combined}\n"
)
+ _assert_message_contains(exp_errors, act_errors, file_label, combined)
+
def main() -> int:
"""
@@ -267,14 +294,40 @@ def main() -> int:
Returns 0 if all pass, 1 if any fail.
"""
+ parser = argparse.ArgumentParser(description="Verify markdownlint fixture expectations.")
+ parser.add_argument(
+ "--verbose",
+ "-v",
+ action="store_true",
+ help="Print each fixture as it is verified.",
+ )
+ args = parser.parse_args()
+ verbose = args.verbose
+
cmd = find_markdownlint_cmd()
files = list_fixture_files()
+ expect_path = repo_root() / "md_test_files" / "expected_errors.yml"
+ expectations_by_file = load_expected_errors(expect_path)
failures: List[str] = []
for f in files:
+ if verbose:
+ exp_total = 0
+ if f.name in expectations_by_file:
+ err_list = expectations_by_file[f.name].get("errors") or []
+ exp_total = len(err_list)
+ file_label = f.relative_to(repo_root()).as_posix()
+ plural = "" if exp_total == 1 else "s"
+ sys.stderr.write(
+ f"Verifying {file_label} ({exp_total} expected error{plural}) ... "
+ )
try:
- verify_file(cmd, f)
+ verify_file(cmd, f, expectations_by_file)
+ if verbose:
+ sys.stderr.write("ok\n")
except (AssertionError, ValueError, OSError, subprocess.SubprocessError) as e:
+ if verbose:
+ sys.stderr.write("FAIL\n")
failures.append(str(e))
if failures:
diff --git a/test/markdownlint-rules/allow-custom-anchors.test.js b/test/markdownlint-rules/allow-custom-anchors.test.js
index 5ebfcfe..2c4313d 100644
--- a/test/markdownlint-rules/allow-custom-anchors.test.js
+++ b/test/markdownlint-rules/allow-custom-anchors.test.js
@@ -26,6 +26,7 @@ describe("allow-custom-anchors", () => {
assert.strictEqual(errors.length, 1);
assert.strictEqual(errors[0].lineNumber, 1);
assert.ok(errors[0].detail.includes("allowed") || errors[0].detail.includes("pattern"));
+ assert.ok(errors[0].detail.includes("custom"), "detail should include the actual anchor id that was rejected");
});
it("reports no errors when anchor id matches allowed pattern", () => {
@@ -33,4 +34,138 @@ describe("allow-custom-anchors", () => {
const errors = runRule(rule, lines, { allowedIdPatterns: ["^spec-"] });
assert.strictEqual(errors.length, 0);
});
+
+ it("ignores anchor inside fenced code block", () => {
+ const lines = ["```", "", "```", ""];
+ const errors = runRule(rule, lines, { allowedIdPatterns: ["^spec-"] });
+ assert.strictEqual(errors.length, 1);
+ assert.strictEqual(errors[0].lineNumber, 4);
+ });
+
+ it("applies placement when pattern has placement and strictPlacement is true", () => {
+ const lines = ["# Spec", "", "Content"];
+ const errors = runRule(rule, lines, {
+ allowedIdPatterns: [{ pattern: "^spec-", placement: { headingMatch: "^#\\s+Spec" } }],
+ strictPlacement: true,
+ });
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports one-per-line error when two anchors on same line", () => {
+ const lines = [" "];
+ const errors = runRule(rule, lines, { allowedIdPatterns: ["^spec-"] });
+ assert.strictEqual(errors.length, 1);
+ assert.ok(errors[0].detail.includes("one-per-line"));
+ });
+
+ it("reports anchor-format error when line is not valid anchor tag", () => {
+ const lines = [""];
+ const errors = runRule(rule, lines, { allowedIdPatterns: ["^spec-"] });
+ assert.strictEqual(errors.length, 1);
+ assert.ok(errors[0].detail.includes("anchor") && errors[0].detail.includes("format"));
+ });
+
+ it("reports end-of-line error when content appears after anchor", () => {
+ const lines = [" trailing text"];
+ const errors = runRule(rule, lines, { allowedIdPatterns: ["^spec-"] });
+ assert.strictEqual(errors.length, 1);
+ assert.ok(errors[0].detail.includes("end of the line") || errors[0].detail.includes("end-of-line"));
+ });
+
+ it("reports requireAfter error when anchor not followed by blank line", () => {
+ const lines = ["# Spec", "", "No blank line after"];
+ const errors = runRule(rule, lines, {
+ allowedIdPatterns: [{ pattern: "^spec-", placement: { headingMatch: "^#\\s+Spec", requireAfter: ["blank"] } }],
+ strictPlacement: true,
+ });
+ assert.strictEqual(errors.length, 1);
+ assert.ok(errors[0].detail.includes("requireAfter") && errors[0].detail.includes("blank"));
+ });
+
+ it("reports lineMatch error when text before anchor does not match pattern", () => {
+ const lines = ["# Spec", "wrong prefix "];
+ const errors = runRule(rule, lines, {
+ allowedIdPatterns: [{ pattern: "^spec-", placement: { headingMatch: "^#\\s+Spec", lineMatch: "^Spec:" } }],
+ strictPlacement: true,
+ });
+ assert.strictEqual(errors.length, 1);
+ assert.ok(errors[0].detail.includes("lineMatch"));
+ });
+
+ it("reports standaloneLine error when anchor has content before it on same line", () => {
+ const lines = ["# Spec", "prefix "];
+ const errors = runRule(rule, lines, {
+ allowedIdPatterns: [{ pattern: "^spec-", placement: { headingMatch: "^#\\s+Spec", standaloneLine: true } }],
+ strictPlacement: true,
+ });
+ assert.strictEqual(errors.length, 1);
+ assert.ok(errors[0].detail.includes("standaloneLine"));
+ });
+
+ it("reports maxPerSection error when section exceeds allowed anchor count", () => {
+ const lines = ["# Spec", "", ""];
+ const errors = runRule(rule, lines, {
+ allowedIdPatterns: [{ pattern: "^spec-", placement: { headingMatch: "^#\\s+Spec", maxPerSection: 1 } }],
+ strictPlacement: true,
+ });
+ assert.strictEqual(errors.length, 1);
+ assert.ok(errors[0].detail.includes("maxPerSection"));
+ });
+
+ it("reports anchorImmediatelyAfterHeading error when anchor not after heading", () => {
+ const lines = ["# Spec", "Content in between", ""];
+ const errors = runRule(rule, lines, {
+ allowedIdPatterns: [{ pattern: "^spec-", placement: { anchorImmediatelyAfterHeading: true } }],
+ strictPlacement: true,
+ });
+ assert.strictEqual(errors.length, 1);
+ assert.ok(errors[0].detail.includes("anchorImmediatelyAfterHeading"));
+ });
+
+ it("reports requireAfter fencedBlock error when not followed by fenced code block", () => {
+ const lines = ["# Spec", "", "", "plain text not a fence"];
+ const errors = runRule(rule, lines, {
+ allowedIdPatterns: [{ pattern: "^spec-", placement: { headingMatch: "^#\\s+Spec", requireAfter: ["blank", "fencedBlock"] } }],
+ strictPlacement: true,
+ });
+ assert.strictEqual(errors.length, 1);
+ assert.ok(errors[0].detail.includes("fenced") && errors[0].detail.includes("requireAfter"));
+ });
+
+ it("reports requireAfter list error when not followed by list", () => {
+ const lines = ["# Spec", "", "", "plain paragraph"];
+ const errors = runRule(rule, lines, {
+ allowedIdPatterns: [{ pattern: "^spec-", placement: { headingMatch: "^#\\s+Spec", requireAfter: ["blank", "list"] } }],
+ strictPlacement: true,
+ });
+ assert.strictEqual(errors.length, 1);
+ assert.ok(errors[0].detail.includes("list") && errors[0].detail.includes("requireAfter"));
+ });
+
+ it("reports no errors when anchor followed by blank and fenced block (requireAfter fencedBlock pass)", () => {
+ const lines = ["# Spec", "", "", "```", "code", "```"];
+ const errors = runRule(rule, lines, {
+ allowedIdPatterns: [{ pattern: "^spec-", placement: { headingMatch: "^#\\s+Spec", requireAfter: ["blank", "fencedBlock"] } }],
+ strictPlacement: true,
+ });
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports no errors when requireAfter is fencedBlock only and next line is fence (needBlank false)", () => {
+ const lines = ["# Spec", "", "```", "code"];
+ const errors = runRule(rule, lines, {
+ allowedIdPatterns: [{ pattern: "^spec-", placement: { headingMatch: "^#\\s+Spec", requireAfter: ["fencedBlock"] } }],
+ strictPlacement: true,
+ });
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports no errors when requireAfter blank and list are satisfied (return null path)", () => {
+ const lines = ["# Spec", "", "", "- item"];
+ const errors = runRule(rule, lines, {
+ allowedIdPatterns: [{ pattern: "^spec-", placement: { headingMatch: "^#\\s+Spec", requireAfter: ["blank", "list"] } }],
+ strictPlacement: true,
+ });
+ assert.strictEqual(errors.length, 0);
+ });
});
diff --git a/test/markdownlint-rules/ascii-only.test.js b/test/markdownlint-rules/ascii-only.test.js
index d280ad3..55f2d49 100644
--- a/test/markdownlint-rules/ascii-only.test.js
+++ b/test/markdownlint-rules/ascii-only.test.js
@@ -25,6 +25,9 @@ describe("ascii-only", () => {
const errors = runRule(rule, lines, {}, "doc.md");
assert.ok(errors.length >= 1);
assert.ok(errors.some((e) => e.detail.includes("ASCII") || e.detail.includes("U+")));
+ const withRange = errors.find((e) => Array.isArray(e.range) && e.range.length === 2);
+ assert.ok(withRange, "at least one error should include range [column, length] for the violating character");
+ assert.ok(withRange.detail.includes("U+") || withRange.detail.includes("'"), "detail should identify the character or code point");
});
it("reports no errors when path matches allowedPathPatternsUnicode", () => {
@@ -35,4 +38,118 @@ describe("ascii-only", () => {
}, "doc.md");
assert.strictEqual(errors.length, 0);
});
+
+ it("reports error with emoji-list message when path is emoji-only and char not in list", () => {
+ const lines = ["Café"];
+ const errors = runRule(rule, lines, {
+ allowedPathPatternsEmoji: ["*.md"],
+ allowedEmoji: ["\u263A"],
+ }, "doc.md");
+ assert.ok(errors.length >= 1);
+ assert.ok(errors.some((e) => e.detail.includes("not in allowed emoji") || e.detail.includes("U+")), "detail should mention emoji list or code point");
+ });
+
+ it("allows non-ASCII when path matches relative pattern (utils matchGlob **/ branch)", () => {
+ const lines = ["Café"];
+ const errors = runRule(rule, lines, {
+ allowedPathPatternsUnicode: ["foo.md"],
+ }, "sub/foo.md");
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports error when path does not match any unicode pattern (utils pathMatchesAny)", () => {
+ const lines = ["Café"];
+ const errors = runRule(rule, lines, {
+ allowedPathPatternsUnicode: ["other.md"],
+ }, "doc.md");
+ assert.ok(errors.length >= 1);
+ });
+
+ it("skips content inside ~~~ fenced block (utils iterateNonFencedLines)", () => {
+ const lines = ["~~~", "Café inside tildes", "~~~", "Plain"];
+ const errors = runRule(rule, lines, {}, "doc.md");
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports no errors when path is emoji-only and content has only allowed emoji", () => {
+ const lines = ["Hello \u263A"]; // ☺ in allowed list
+ const errors = runRule(rule, lines, {
+ allowedPathPatternsEmoji: ["*.md"],
+ allowedEmoji: ["\u263A"],
+ }, "doc.md");
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports no errors when path is emoji-only and content has emoji plus variation selector", () => {
+ const lines = ["\u263A\uFE00"]; // ☺ + variation selector
+ const errors = runRule(rule, lines, {
+ allowedPathPatternsEmoji: ["*.md"],
+ allowedEmoji: ["\u263A"],
+ }, "doc.md");
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports no errors when non-ASCII char is in allowedUnicode set", () => {
+ const lines = ["Café"];
+ const errors = runRule(rule, lines, {
+ allowedUnicode: ["\u00E9"],
+ }, "doc.md");
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("includes suggested replacement when unicodeReplacements is object", () => {
+ const lines = ["Café"];
+ const errors = runRule(rule, lines, {
+ unicodeReplacements: { "\u00E9": "e" },
+ }, "doc.md");
+ assert.ok(errors.length >= 1);
+ assert.ok(errors.some((e) => e.detail.includes("suggested replacement") && e.detail.includes("e")));
+ });
+
+ it("includes suggested replacement when unicodeReplacements is array", () => {
+ const lines = ["Café"];
+ const errors = runRule(rule, lines, {
+ unicodeReplacements: [["\u00E9", "e"]],
+ }, "doc.md");
+ assert.ok(errors.length >= 1);
+ assert.ok(errors.some((e) => e.detail.includes("suggested replacement")));
+ });
+
+ it("formats astral character with 6-digit code point in error", () => {
+ const lines = ["\u{1F600}"]; // 😀
+ const errors = runRule(rule, lines, {}, "doc.md");
+ assert.ok(errors.length >= 1);
+ assert.ok(errors.some((e) => /U\+[0-9A-F]{6}/.test(e.detail)), "astral code point should be 6 hex digits");
+ });
+
+ it("strips inline code before checking (utils stripInlineCode fence match)", () => {
+ const lines = ["Café ``code``"];
+ const errors = runRule(rule, lines, {}, "doc.md");
+ assert.ok(errors.length >= 1);
+ assert.ok(errors.some((e) => e.detail.includes("Café") || e.detail.includes("U+")));
+ });
+
+ it("skips non-string entries in path patterns (utils pathMatchesAny)", () => {
+ const lines = ["Café"];
+ const errors = runRule(rule, lines, {
+ allowedPathPatternsUnicode: ["*.md", 123],
+ }, "doc.md");
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("handles empty pattern in path list (utils matchGlob)", () => {
+ const lines = ["Café"];
+ const errors = runRule(rule, lines, {
+ allowedPathPatternsUnicode: ["", "*.md"],
+ }, "doc.md");
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("uses default replacements when unicodeReplacements is falsy (buildReplacementsMap early return)", () => {
+ const lines = ["Café"];
+ const errors = runRule(rule, lines, {
+ unicodeReplacements: "",
+ }, "doc.md");
+ assert.ok(errors.length >= 1);
+ });
});
diff --git a/test/markdownlint-rules/heading-numbering.test.js b/test/markdownlint-rules/heading-numbering.test.js
index d544909..bd4a8c8 100644
--- a/test/markdownlint-rules/heading-numbering.test.js
+++ b/test/markdownlint-rules/heading-numbering.test.js
@@ -31,6 +31,56 @@ describe("heading-numbering", () => {
const lines = ["# Doc", "## 1. First", "## 2. Second", "## 4. Skip"];
const errors = runRule(rule, lines);
assert.ok(errors.length >= 1);
- assert.ok(errors.some((e) => e.detail.includes("expected") || e.detail.includes("Non-sequential")));
+ const seqError = errors.find((e) => e.detail.includes("sequence") || e.detail.includes("expected"));
+ assert.ok(seqError, "should report a sequencing error");
+ assert.ok(seqError.detail.includes("4"), "detail should include the actual prefix");
+ assert.ok(seqError.detail.includes("3"), "detail should include the expected prefix");
+ });
+
+ it("reports error when heading in numbered section has no number prefix", () => {
+ const lines = ["# Doc", "## 1. First", "## 2. Second", "## Unnumbered"];
+ const errors = runRule(rule, lines);
+ const missingNum = errors.find((e) => e.detail.includes("no number prefix") || e.detail.includes("numbered"));
+ assert.ok(missingNum, "should report missing number in numbered section");
+ assert.strictEqual(missingNum.lineNumber, 4);
+ });
+
+ it("reports error for wrong segment count (level vs numbering depth)", () => {
+ const lines = ["# Doc", "## 1. First", "## 2. Second", "### 2.1.2. Too many segments"];
+ const errors = runRule(rule, lines);
+ const segmentErr = errors.find((e) => e.detail.includes("segment") && e.detail.includes("expected"));
+ assert.ok(segmentErr, "should report segment count error");
+ });
+
+ it("reports error for period style inconsistency in section", () => {
+ const lines = ["# Doc", "## 1. First", "## 2. Second", "## 3 No period"];
+ const errors = runRule(rule, lines);
+ const periodErr = errors.find((e) => e.detail.includes("period") && e.detail.includes("section"));
+ assert.ok(periodErr, "should report period style inconsistency");
+ });
+
+ it("accepts 0-based numbering in section (0., 1., 2.)", () => {
+ const lines = ["# Doc", "## 0. Zero", "## 1. One", "## 2. Two"];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports no errors for unnumbered subsections under numbered section", () => {
+ const lines = ["# Doc", "## 1. First", "### Sub A", "### Sub B"];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports segment error when root heading has number prefix (exercises numbering root level 1)", () => {
+ const lines = ["# 1. Root", "## 1.1. Child"];
+ const errors = runRule(rule, lines);
+ const segmentErr = errors.find((e) => e.detail.includes("segment"));
+ assert.ok(segmentErr, "root with number prefix should report segment count error");
+ });
+
+ it("reports no errors for numbered H2s and H3 under one H2 (getSiblings level/parent branches)", () => {
+ const lines = ["# Doc", "## 1. First", "## 2. Second", "### 2.1. Sub"];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 0);
});
});
diff --git a/test/markdownlint-rules/heading-title-case.test.js b/test/markdownlint-rules/heading-title-case.test.js
index 3804e35..d275e52 100644
--- a/test/markdownlint-rules/heading-title-case.test.js
+++ b/test/markdownlint-rules/heading-title-case.test.js
@@ -1,9 +1,10 @@
"use strict";
/**
- * Unit tests for heading-title-case: enforce title case on headings (first/last
- * and major words capitalized; small words like "and"/"the" lowercase in the
- * middle). Words in backticks are ignored; lowercase words are configurable.
+ * Unit tests for heading-title-case: enforce AP-style headline capitalization
+ * (first/last/subphrase-start capitalized; minor words lowercase in middle;
+ * hyphenated segments and first word after colon checked). Words in backticks
+ * are ignored; lowercase words are configurable.
*/
const { describe, it } = require("node:test");
@@ -13,7 +14,8 @@ const { runRule } = require("./run-rule.js");
describe("heading-title-case", () => {
it("reports no errors for valid title case", () => {
- const lines = ["# This Is a Valid Title", "## Another Good Heading"];
+ // AP style: "is" and "a" lowercase in middle; first/last capitalized
+ const lines = ["# This is a Valid Title", "## Another Good Heading"];
const errors = runRule(rule, lines);
assert.strictEqual(errors.length, 0);
});
@@ -25,14 +27,98 @@ describe("heading-title-case", () => {
assert.strictEqual(errors.length, 1);
assert.strictEqual(errors[0].lineNumber, 1);
assert.ok(errors[0].detail.includes("lowercase"));
+ assert.ok(errors[0].detail.includes("Word ") && errors[0].detail.includes("lowercase"), "detail should name the violation and expected case");
+ assert.ok(Array.isArray(errors[0].range) && errors[0].range.length === 2, "error should include range [column, length] for the violating word");
});
it("allows lowercase and/or in the middle when configured", () => {
- // Custom lowercaseWords permits "of" and "and"; first/last ("Use", "Other") stay capped.
- const lines = ["# Use of and Other"];
+ // Custom lowercaseWords permits "through" and "and"; first/last ("Use", "Other") stay capped.
+ // Without config, AP-style rules treat "through" as a major word (so "through" would be invalid).
+ const lines = ["# Use through and Other"];
const errors = runRule(rule, lines, {
- "heading-title-case": { lowercaseWords: ["of", "and"] },
+ "heading-title-case": { lowercaseWords: ["through", "and"] },
});
assert.strictEqual(errors.length, 0);
});
+
+ it("reports error with range pointing to the violating word", () => {
+ const lines = ["# The Cat And the Hat"];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 1);
+ assert.strictEqual(errors[0].lineNumber, 1);
+ assert.ok(Array.isArray(errors[0].range) && errors[0].range.length === 2);
+ const [col, len] = errors[0].range;
+ assert.strictEqual(len, 3, "range length should match word 'And'");
+ const line = lines[0];
+ const wordAtRange = line.slice(col - 1, col - 1 + len);
+ assert.strictEqual(wordAtRange, "And", "range should highlight the violating word");
+ });
+
+ it("reports first-word violation with expected case in detail", () => {
+ const lines = ["## getting started"];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 1);
+ assert.ok(errors[0].detail.includes("getting"));
+ assert.ok(errors[0].detail.includes("capitalized") || errors[0].detail.includes("first"));
+ });
+
+ it("reports last-word violation with expected case in detail", () => {
+ const lines = ["## Using Tools in practice"];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 1);
+ assert.ok(errors[0].detail.includes("practice"));
+ assert.ok(errors[0].detail.includes("capitalized") || errors[0].detail.includes("last"));
+ });
+
+ it("accepts valid hyphenated compounds (AP: each segment capitalized)", () => {
+ const lines = ["# One-Stop Shop", "## How to Do a Follow-Up"];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports error for lowercase segment in hyphenated word", () => {
+ const lines = ["# One-stop Shop"];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 1);
+ assert.ok(errors[0].detail.includes("stop") && errors[0].detail.includes("capitalized"));
+ });
+
+ it("capitalizes first word after colon (AP subphrase start)", () => {
+ const lines = ["## Summary: The Results"];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports error when first word after colon is lowercase", () => {
+ const lines = ["## Summary: the Results"];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 1);
+ assert.ok(errors[0].detail.includes("the") && errors[0].detail.includes("capitalized"));
+ });
+
+ it("reports no errors for heading that is only inline code (words.length === 0)", () => {
+ const lines = ["## `code only`"];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports no errors for word with bracket prefix (firstAlphaIdx > 0)", () => {
+ const lines = ["## (Optional) Section Title"];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports no errors for hyphenated word with punctuation-only segment (skip non-alpha segment)", () => {
+ const lines = ["## Test---Here"];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports error for hyphenated word with range on segment (segmentOffset/segmentLength)", () => {
+ const lines = ["# One-stop Shop"];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 1);
+ assert.ok(Array.isArray(errors[0].range) && errors[0].range.length === 2);
+ assert.strictEqual(errors[0].range[1], 4, "range length should be segment 'stop'");
+ });
});
diff --git a/test/markdownlint-rules/no-duplicate-headings-normalized.test.js b/test/markdownlint-rules/no-duplicate-headings-normalized.test.js
index 3f567a9..27d9d3c 100644
--- a/test/markdownlint-rules/no-duplicate-headings-normalized.test.js
+++ b/test/markdownlint-rules/no-duplicate-headings-normalized.test.js
@@ -27,6 +27,7 @@ describe("no-duplicate-headings-normalized", () => {
assert.strictEqual(errors[0].lineNumber, 3);
assert.ok(errors[0].detail.includes("Duplicate"));
assert.ok(errors[0].detail.includes("line 1"));
+ assert.ok(errors[0].detail.includes("introduction"), "detail should include the normalized duplicate title");
});
it("reports error when numbering differs but title same", () => {
@@ -35,5 +36,21 @@ describe("no-duplicate-headings-normalized", () => {
const errors = runRule(rule, lines);
assert.strictEqual(errors.length, 1);
assert.strictEqual(errors[0].lineNumber, 3);
+ assert.ok(errors[0].detail.includes("overview"), "detail should include the normalized duplicate title");
+ assert.ok(errors[0].detail.includes("line 1"), "detail should reference first occurrence line");
+ });
+
+ it("normalizes empty heading title (utils normalizeHeadingTitleForDup)", () => {
+ const lines = ["# ", "## Section", "content"];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports multiple duplicates when same normalized title appears three times", () => {
+ const lines = ["# Overview", "## 1. Overview", "## 2. Overview"];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 2);
+ assert.ok(errors.every((e) => e.detail.includes("Overview") || e.detail.includes("overview")));
+ assert.ok(errors.some((e) => e.detail.includes("line 1")));
});
});
diff --git a/test/markdownlint-rules/no-heading-like-lines.test.js b/test/markdownlint-rules/no-heading-like-lines.test.js
index 6d0b743..b348e70 100644
--- a/test/markdownlint-rules/no-heading-like-lines.test.js
+++ b/test/markdownlint-rules/no-heading-like-lines.test.js
@@ -24,7 +24,8 @@ describe("no-heading-like-lines", () => {
const errors = runRule(rule, lines);
assert.strictEqual(errors.length, 1);
assert.strictEqual(errors[0].lineNumber, 1);
- assert.ok(errors[0].detail.includes("proper Markdown headings"));
+ assert.ok(errors[0].detail.includes("ATX heading") && errors[0].detail.includes("heading-like"));
+ assert.ok(errors[0].detail.includes("bold") && errors[0].detail.includes("colon"), "detail should describe the matched pattern");
});
it("reports error for 1. **Text** style", () => {
@@ -33,6 +34,7 @@ describe("no-heading-like-lines", () => {
const errors = runRule(rule, lines);
assert.strictEqual(errors.length, 1);
assert.strictEqual(errors[0].lineNumber, 1);
+ assert.ok(errors[0].detail.includes("numbered") || errors[0].detail.includes("bold"), "detail should describe the matched pattern");
});
it("ignores empty lines", () => {